feat(\db-tool\): introduce unified db-tool package with postgres harness and tests (T0-T8)

This commit is contained in:
2026-04-30 09:06:44 +00:00
parent 395bddee94
commit b5dcbf08a1
27 changed files with 2417 additions and 1 deletions

99
package/db-tool/README.md Normal file
View File

@@ -0,0 +1,99 @@
# db-tool
PostgreSQL development database management tool. Drop-in replacement for per-project database.sh / postgres-init.sh / postgres-cleanup.sh scripts. Provides database, postgres-init, and postgres-cleanup binaries.
## Provided Binaries
| Binary | Description |
| --- | --- |
| `database` | Main script for managing migrations, deployments, and logs. |
| `postgres-init` | Ephemeral PostgreSQL cluster initialization and startup. |
| `postgres-cleanup` | Graceful shutdown and cleanup of the PostgreSQL cluster. |
## Required Environment Variables
These variables must be set for `db-tool` to function.
| Variable | Description |
| --- | --- |
| `LOCAL_DIR` | Absolute path to the project root directory. |
| `DB_URL` | Full PostgreSQL connection string (e.g., `postgresql://user@localhost/dbname?host=$PG_WORKING_DIR`). |
| `PG_WORKING_DIR` | Directory where the PostgreSQL cluster data and sockets are stored. |
## Optional Environment Variables
| Variable | Default Value | Description |
| --- | --- | --- |
| `DATABASE_DIR` | `${LOCAL_DIR}/db` | Root directory for database-related files. |
| `MIGRATION_DIR` | `${DATABASE_DIR}/migration` | Directory containing SQL migration files. |
| `DATABASE_SOURCE` | `${DATABASE_DIR}/src` | Directory containing source SQL files for hydration. |
| `PG_URL_VAR` | `PGURL` | The name of the environment variable where the computed PG URL will be exported. |
| `PG_LOG_PATH` | (unset) | Path to redirect PostgreSQL server logs. |
| `PATCH_LOG` | (stdout) | Path to log the output of database patches. |
| `HYDRATE_LOG` | (stdout) | Path to log the output of database hydration. |
## pull_staging Contract
The `pull_staging` subcommand allows importing data from a remote staging environment into the local `test-data.sql` file. This functionality requires four specific environment variables to be defined:
1. `STAGING_SSH_HOST`: The SSH destination for the staging server.
2. `STAGING_DB_URL`: The PostgreSQL connection string for the remote staging database.
3. `STAGING_DUMP_TABLES`: A space-separated list of tables to include in the data dump.
4. `STAGING_DUMP_FLAGS`: Additional flags to pass to `pg_dump` (e.g., `--column-inserts`).
If any of these variables are missing when `pull_staging` is invoked, the tool will exit with code 3 and print the name of the missing variable to stderr.
## Subcommands
- `deploy`: Execute the full deployment flow (hydrate + patch). Supports `--cleanup` to teardown after success.
- `log`: Inspect database logs. Supports `list` and index-based selection.
- `test`: Execute database tests located in `${DATABASE_DIR}/test/test.sql`.
- `check`: Run a deployment validation in an isolated, temporary PostgreSQL cluster.
- `cleanup`: Stop the local database cluster and remove the `PG_WORKING_DIR`.
- `pull_staging`: Import data from the staging environment based on the env contract.
- `init`: Wrapper around `postgres-init` to start the cluster.
- `migrator`: Directly invoke the migration tool with the correct environment context.
## shellHook Example
To use `db-tool` in a Nix development shell, add the following to your `flake.nix` or `shell.nix`:
```nix
{
# ...
devShells.default = pkgs.mkShell {
packages = [
pkgs.hectic.db-tool
pkgs.hectic.postgres-init
pkgs.hectic.postgres-cleanup
];
shellHook = ''
export LOCAL_DIR="$PWD"
export DATABASE_DIR="$LOCAL_DIR/db"
export MIGRATION_DIR="$DATABASE_DIR/migration"
export DATABASE_SOURCE="$DATABASE_DIR/src"
export PG_WORKING_DIR="$LOCAL_DIR/focus/postgresql"
export DB_URL="postgresql://user@localhost/dbname?host=$PG_WORKING_DIR&port=5432"
# for other non-db scripts (deploy.sh, task.sh, etc.):
export HECTIC_LIB="${pkgs.hectic.helpers.posix-shell.log}"
# Initialize and start the ephemeral database cluster
. ${pkgs.hectic.postgres-init}/bin/postgres-init
'';
};
}
```
## Exit Codes
| Code | Meaning |
| --- | --- |
| 1 | Generic error. |
| 2 | Ambiguous arguments or state. |
| 3 | Missing required argument or environment variable. |
| 5 | Provided table does not exist. |
| 9 | Argument or command not found. |
| 13 | Program bug or unexpected system state. |
| 127 | Command not found (missing dependency). |

1539
package/db-tool/database.sh Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
{ dash, hectic, postgresql_17, neovim, openssh, coreutils, gawk, lib }:
let
shell = "${dash}/bin/dash";
database = hectic.writeShellApplication {
inherit shell;
bashOptions = [
"errexit"
"nounset"
];
# SC2209: false positive — PAGER_OR_CAT=cat stores the string "cat" intentionally
excludeShellChecks = [ "SC2209" ];
name = "database";
runtimeInputs = [ hectic.migrator hectic.parse-uri postgresql_17 neovim openssh coreutils gawk ];
text = ''
${builtins.readFile hectic.helpers.posix-shell.log}
${builtins.readFile hectic.helpers.posix-shell.change_namespace}
${builtins.readFile hectic.helpers.posix-shell.quote}
${builtins.readFile hectic.helpers.posix-shell.pager_or_cat}
${builtins.readFile ./database.sh}
'';
meta = {
description = "PostgreSQL development database management";
mainProgram = "database";
};
};
postgresInit = hectic.writeShellApplication {
inherit shell;
bashOptions = [ ];
name = "postgres-init";
runtimeInputs = [ postgresql_17 coreutils ];
text = builtins.readFile ./postgres-init.sh;
meta = {
description = "Initialize local PostgreSQL instance";
mainProgram = "postgres-init";
};
};
postgresCleanup = hectic.writeShellApplication {
inherit shell;
bashOptions = [ ];
name = "postgres-cleanup";
runtimeInputs = [ postgresql_17 coreutils ];
text = builtins.readFile ./postgres-cleanup.sh;
meta = {
description = "Clean up local PostgreSQL instance";
mainProgram = "postgres-cleanup";
};
};
in
{
"db-tool" = database;
"postgres-init" = postgresInit;
"postgres-cleanup" = postgresCleanup;
}

View File

@@ -0,0 +1,14 @@
#!/bin/dash
postgres_cleanup_main() {
if [ -z "${PG_WORKING_DIR:-}" ] && [ -z "${LOCAL_DIR:-}" ]; then return 0; fi
: "${PG_WORKING_DIR:=$LOCAL_DIR/focus/postgresql}"
if [ -f "${PG_WORKING_DIR}/data/postmaster.pid" ]; then
pg_ctl -D "${PG_WORKING_DIR}/data" -m fast -w stop || :
fi
return 0
}
if [ "$(basename "$0")" = 'postgres-cleanup' ]; then
postgres_cleanup_main "$@"
fi

View File

@@ -0,0 +1,49 @@
#!/bin/dash
postgres_init_main() {
if [ -z "${PG_WORKING_DIR:-}" ] && [ -z "${LOCAL_DIR:-}" ]; then
printf '%s\n' 'postgres-init: PG_WORKING_DIR or LOCAL_DIR is required' >&2
return 1
fi
: "${PG_WORKING_DIR:=$LOCAL_DIR/focus/postgresql}"
: "${PG_PORT:=5432}"
: "${PG_DATABASE:=testdb}"
: "${PG_DISABLE_LOGGING:=0}"
[ "${PG_SHARED_PRELOAD_LIBRARIES+x}" ] || PG_SHARED_PRELOAD_LIBRARIES='pg_cron'
: "${PG_URL_VAR:=PGURL}"
mkdir -p "$PG_WORKING_DIR" || return 1
wd="$PG_WORKING_DIR"; data="$wd/data"; sockdir="$wd/sock"; db="$PG_DATABASE"
pg_ctl -D "$data" -m fast -w stop >/dev/null 2>&1 || :
mkdir -p "$sockdir" || return 1
if [ "${PG_REUSE+x}" ] && [ -f "$data/PG_VERSION" ]; then PG_REUSE=1; else PG_REUSE=0; fi
if [ "$PG_REUSE" -eq 0 ]; then
rm -rf "$data" "$sockdir" || return 1
mkdir -p "$sockdir" || return 1
initdb -D "$data" --no-locale -E UTF8 || return 1
{ printf '%s\n' "listen_addresses = ''"; [ "$PG_DISABLE_LOGGING" -eq 0 ] && { printf '%s\n' 'logging_collector = on'; printf '%s\n' "log_directory = 'log'"; }; [ -n "$PG_SHARED_PRELOAD_LIBRARIES" ] && { printf '%s\n' "shared_preload_libraries = '$PG_SHARED_PRELOAD_LIBRARIES'"; printf '%s\n' "cron.database_name = '$db'"; printf '%s\n' "cron.host = '$sockdir'"; }; :; } >> "$data/postgresql.conf" || return 1
sed -i "1ilocal all all trust" "$data/pg_hba.conf" || return 1
fi
sed -i '/^[[:space:]]*port[[:space:]]*=/d' "$data/postgresql.conf" || return 1
sed -i '/^[[:space:]]*unix_socket_directories[[:space:]]*=/d' "$data/postgresql.conf" || return 1
{ printf '%s\n' "port = $PG_PORT"; printf '%s\n' "unix_socket_directories = '$sockdir'"; } >> "$data/postgresql.conf" || return 1
pg_ctl -D "$data" -o "-F" -w start || return 2
user="$(id -un)" || return 1
if [ "$PG_REUSE" -eq 0 ]; then createdb -h "$sockdir" -U "$user" "$db" || return 1; fi
psql -h "$sockdir" -p "$PG_PORT" -d "$db" -v ON_ERROR_STOP=1 -c 'select 1;' || return 1
export POSTGRESQL_HOST="$sockdir" POSTGRESQL_PORT="$PG_PORT" POSTGRESQL_USER="$user" POSTGRESQL_DATABASE="$db"
_pg_url="postgresql://${POSTGRESQL_USER}@/${POSTGRESQL_DATABASE}?host=${POSTGRESQL_HOST}&port=${POSTGRESQL_PORT}"
case $PG_URL_VAR in ''|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_]* ) printf '%s\n' 'postgres-init: invalid PG_URL_VAR' >&2; return 1 ;; esac
export "${PG_URL_VAR}=${_pg_url}" || return 1
return 0
}
if [ "$(basename "$0")" = 'postgres-init' ]; then
postgres_init_main "$@"
fi

View File

@@ -107,6 +107,7 @@
};
nativeBuildInputs = with pkgs; [pkg-config curl];
};
dbToolPkgs = pkgs.callPackage ./db-tool {};
in {
py3-datetime = pkgs.callPackage ./py3-datetime.nix {};
py3-marzban = pkgs.callPackage ./py3-marzban.nix { inherit self; };
@@ -140,6 +141,10 @@ in {
shellplot = pkgs.callPackage ./shellplot {};
onlinepubs2man = pkgs.callPackage ./onlinepubs2man {};
migrator = pkgs.callPackage ./migrator {};
"parse-uri" = pkgs.callPackage ./parse-uri {};
"db-tool" = dbToolPkgs."db-tool";
"postgres-init" = dbToolPkgs."postgres-init";
"postgres-cleanup" = dbToolPkgs."postgres-cleanup";
nbt2json = pkgs.callPackage ./nbt2json {};
hemar-parser = pkgs.callPackage ./hemar/parser {};
AstroTuxLauncher = pkgs.callPackage ./AstroTuxLauncher.nix {};

View File

@@ -0,0 +1,29 @@
{ stdenv, gcc, libpq, lib, bash }:
stdenv.mkDerivation {
pname = "parse-uri";
version = "1.0";
src = ./.;
doCheck = false;
nativeBuildInputs = [ gcc ];
buildInputs = [ libpq ];
INCLUDES = "-I${libpq.dev}/include";
LDFLAGS = "-L${libpq.out}/lib -lpq";
buildPhase = ''
${bash}/bin/sh ./make.sh build
'';
installPhase = ''
mkdir -p $out/bin
cp target/parse-uri $out/bin/
'';
meta = {
description = "parse-uri";
license = lib.licenses.mit;
};
}

32
package/parse-uri/main.c Normal file
View File

@@ -0,0 +1,32 @@
#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>
#include <libpq-fe.h>
int main(int argc, char *argv[]) {
if (argc != 2) {
printf("only 1 argument allow, please provide uri\n");
exit(1);
}
const char *conninfo = argv[1];
char *errmsg = NULL;
PQconninfoOption *options = PQconninfoParse(conninfo, &errmsg);
if (!options) {
printf("Parse failed: %s\n", errmsg);
return 1;
}
for (PQconninfoOption *opt = options; opt->keyword != NULL; opt++) {
char upper[128];
size_t i = 0;
for (; opt->keyword[i] != '\0' && i < sizeof(upper)-1; i++)
upper[i] = toupper((unsigned char)opt->keyword[i]);
upper[i] = '\0';
printf("URI_%s=%s\n", upper, opt->val ? opt->val : "");
}
PQconninfoFree(options);
return 0;
}

41
package/parse-uri/make.sh Normal file
View File

@@ -0,0 +1,41 @@
#!/bin/sh
# Usage: make.sh [build|check] [--norun] [--debug] [--color]
PACKAGE_NAME="parse-uri"
check_dependencies() {
for dep in cc; do
if ! command -v "$dep" >/dev/null 2>&1; then
echo "Error: Required dependency '$dep' not found." >&2
exit 1
fi
done
}
check_dependencies
# Default flags
OPTFLAGS="-O2"
CFLAGS="-Wall -Wextra -Werror -pedantic"
STD_FLAGS="-std=c99"
MODE="${1:-build}"
shift
build() {
mkdir -p target
echo "# Build $PACKAGE_NAME"
# shellcheck disable=SC2086
cc $CFLAGS $OPTFLAGS $STD_FLAGS main.c -o "target/$PACKAGE_NAME" $LDFLAGS $INCLUDES
}
case "$MODE" in
build)
build
;;
check)
echo "No tests to run"
;;
*)
exit 1
;;
esac