feat: db-tool: +secrets load

This commit is contained in:
2026-06-09 23:55:05 +00:00
parent a20381e343
commit 7c25e3b46d
14 changed files with 439 additions and 27 deletions
+1 -1
View File
@@ -103,7 +103,7 @@ in {
# Consolidated SQL bundles for the `hectic` schema. Single source of truth
# for everything that creates objects in the `hectic` namespace, used by
# migrator (init-time) and db-tool (postgres-init + hydrate). Consumers apply
# migrator (init-time), db-dev/database hydrate, and db-ops secrets loading. Consumers apply
# the full bundle via lib/hook/apply-hectic-bundle.sh.
#
# The whole hectic system shares one `versionString`; `hectic-version.sql`
+4 -6
View File
@@ -10,8 +10,8 @@
# Usage:
# apply_hectic_bundle <PGURL> [<DOTENV_CONTENT>]
#
# If DOTENV_CONTENT is non-empty, it is loaded into hectic.secret via
# hectic.load_secrets_from_env() after the bundle is applied.
# If DOTENV_CONTENT is non-empty, it is base64-encoded and then loaded into
# hectic.secret via hectic.load_secrets_from_env() after the bundle is applied.
# SQL file paths are substituted by Nix evaluation time.
apply_hectic_bundle() {
@@ -41,11 +41,9 @@ apply_hectic_bundle() {
done
if [ -n "$env_content" ]; then
# Dollar-quote with $ps_env$ tag to preserve all content verbatim.
env_content_b64="$(printf '%s' "$env_content" | base64 | tr -d '\n')" || return 1
psql "$pgurl" -v ON_ERROR_STOP=1 <<SQL || return 1
SELECT hectic.load_secrets_from_env(\$ps_env\$
$env_content
\$ps_env\$);
SELECT hectic.load_secrets_from_env(convert_from(decode('$env_content_b64', 'base64'), 'UTF8'));
SQL
fi
+6 -5
View File
@@ -4,8 +4,8 @@ Single source of truth for every object created in the `hectic` PostgreSQL
schema. Consumed by:
- `package/migrator` — applies the bundle on `migrator init` (mandatory).
- `package/db-tool` — applies the bundle in `database hydrate` (default; opt
out with `--no-hook`).
- `package/db-tool` — applies the bundle in `db-dev` / `database hydrate`
(default; opt out with `--no-hook`) and in `db-ops secrets load`.
- External consumers (e.g. `proxydoe`) — invoke `psql -f` directly against the
paths exposed via `self.lib.hectic.*.path`.
@@ -60,7 +60,7 @@ verbatim source files in the store.
`lib/hook/apply-hectic-bundle.sh` is a dash-compatible helper template.
`self.lib.hectic.applyBundleScript` is the generated shell source with concrete
SQL paths embedded at Nix evaluation time. `migrator` and `db-tool` splice that
SQL paths embedded at Nix evaluation time. `migrator`, `db-dev`, and `db-ops` splice that
shell source directly into their generated scripts. Public entry point:
```sh
@@ -87,8 +87,9 @@ External consumers that do not want to source the helper can still invoke
4. Add a matching placeholder/replacement in `lib.hectic.applyBundleScript` and
update `lib/hook/apply-hectic-bundle.sh` to apply the file.
5. Bump `HECTIC_VERSION` if the new content changes existing semantics.
6. Update tests in `test/package/migrator/test/postgresql/init-hectic-bundle/`
and `test/package/db-tool/test/postgresql/hydrate-hook/`.
6. Update tests in `test/package/migrator/test/postgresql/init-hectic-bundle/`,
`test/package/db-tool/test/postgresql/hydrate-hook/`, and any `db-ops`
bundle-loading coverage.
## Versioning
+15 -8
View File
@@ -1,18 +1,21 @@
# 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.
PostgreSQL utility package exposing separate development and operations binaries.
`db-dev` preserves the current development workflow via the `database` binary;
`db-ops` provides target-safe operational helpers such as secrets hydration.
## Provided Binaries
| Binary | Description |
| --- | --- |
| `database` | Main script for managing migrations, deployments, and logs. |
| `database` | Main `db-dev` script for managing local development databases, migrations, deployments, and logs. |
| `db-ops` | Operational helper binary for target/runtime database actions. |
| `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.
These variables must be set for `db-dev` / `database` to function.
| Variable | Description |
| --- | --- |
@@ -39,7 +42,7 @@ These variables must be set for `db-tool` to function.
## Postgres Package Override
By default, `db-tool`/`postgres-init`/`postgres-cleanup` use plain `postgresql_17` from nixpkgs. If you need extensions (e.g. `pg_cron`), override the postgres package per-output:
By default, `db-dev`/`db-ops`/`postgres-init`/`postgres-cleanup` use plain `postgresql_17` from nixpkgs. If you need extensions (e.g. `pg_cron`), override the postgres package per-output:
```nix
let
@@ -48,13 +51,16 @@ let
]);
in {
packages = [
(pkgs.hectic."db-tool".override { postgresql = myPg; })
(pkgs.hectic."db-dev".override { postgresql = myPg; })
(pkgs.hectic."db-ops".override { postgresql = myPg; })
(pkgs.hectic."postgres-init".override { postgresql = myPg; })
(pkgs.hectic."postgres-cleanup".override { postgresql = myPg; })
];
}
```
`pkgs.hectic."db-tool"` remains as a compatibility alias to `db-dev`.
## 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:
@@ -102,14 +108,14 @@ used by `database restore`.
## shellHook Example
To use `db-tool` in a Nix development shell, add the following to your `flake.nix` or `shell.nix`:
To use `db-dev` 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.db-dev
pkgs.hectic.postgres-init
pkgs.hectic.postgres-cleanup
];
@@ -134,7 +140,7 @@ To use `db-tool` in a Nix development shell, add the following to your `flake.ni
## hectic Bundle
`db-tool` and `migrator` apply a single bundle of SQL files that bootstrap the
`db-dev`, `db-ops`, and `migrator` apply a single bundle of SQL files that bootstrap the
`hectic` schema. The bundle lives in
[`lib/hook/sql/`](../../lib/hook/sql/README.md) — see that README for full
contract, file layout, and the `self.lib.hectic.*` Nix API.
@@ -190,6 +196,7 @@ ALTER DATABASE mydb SET hectic.inheritance_extra_excluded_schemas = 'legacy,etl'
| `postgres-init` | **No.** Pure PostgreSQL provisioner — starts a vanilla cluster, nothing more. |
| `migrator init` | **Yes, mandatory.** The bundle is a hard prerequisite for `hectic.migration`. |
| `database hydrate` | **Yes, by default.** Re-applied on every hydrate. Skip with `--no-hook`. After applying the bundle, hydrate also calls `hectic.load_secrets_from_env(<dotenv>)` if `HECTIC_DOTENV_FILE` (or `${LOCAL_DIR}/.env.${ENVIRONMENT}`) is readable. |
| `db-ops secrets load` | **Yes, mandatory.** Applies the bundle and requires a dotenv source before loading secrets into `hectic.secret`. |
The bundle is idempotent — repeated application is safe.
+222
View File
@@ -0,0 +1,222 @@
# shellcheck shell=dash
: "${SCRIPT_NAME:=$(basename "$0")}"
help() {
# shellcheck disable=SC2059
printf "$(cat <<EOF
${BGREEN}Usage:${NC} $SCRIPT_NAME [OPTIONS] <SUBCOMMAND> [OPTIONS]
PostgreSQL operations utility.
${BGREEN}Global Options:${NC}
${BCYAN}-h${NC}, ${BCYAN}--help${NC} Show this help message
${BCYAN}-u${NC}, ${BCYAN}--url${NC} <url> PostgreSQL connection string
${BGREEN}Subcommands:${NC}
${BCYAN}secrets load${NC} [OPTIONS] Apply hectic bundle and load secrets
${BGREEN}Environment:${NC}
${BBLACK}PGURL${NC} PostgreSQL connection string fallback
${BBLACK}DB_URL${NC} PostgreSQL connection string fallback
${BBLACK}HECTIC_DOTENV_CONTENT${NC} Raw dotenv content to load
${BBLACK}HECTIC_DOTENV_FILE${NC} Dotenv file to read when readable
${BBLACK}LOCAL_DIR${NC} Used with ENVIRONMENT for .env fallback
${BBLACK}ENVIRONMENT${NC} Falls back to ${BBLACK}\$LOCAL_DIR/.env.\$ENVIRONMENT${NC}
EOF
)"
}
help_secrets_load() {
# shellcheck disable=SC2059
printf "$(cat <<EOF
${BGREEN}Usage:${NC} $SCRIPT_NAME ${BCYAN}secrets load${NC} [OPTIONS]
Apply the hectic SQL bundle and load dotenv-backed secrets into
${BBLACK}hectic.secret${NC}.
${BGREEN}Options:${NC}
${BCYAN}-h${NC}, ${BCYAN}--help${NC} Show this help message
${BCYAN}-u${NC}, ${BCYAN}--url${NC} <url> PostgreSQL connection string
${BCYAN}-f${NC}, ${BCYAN}--dotenv-file${NC} <path> Dotenv file path
${BGREEN}Resolution order:${NC}
1. ${BBLACK}HECTIC_DOTENV_CONTENT${NC}
2. ${BBLACK}--dotenv-file${NC} / ${BBLACK}HECTIC_DOTENV_FILE${NC}
3. ${BBLACK}\$LOCAL_DIR/.env.\$ENVIRONMENT${NC}
Fails with exit code ${BBLACK}3${NC} when neither a PostgreSQL URL nor a dotenv
source can be resolved.
EOF
)"
}
resolve_pgurl() {
if [ -n "${PGURL:-}" ]; then
printf '%s' "$PGURL"
elif [ -n "${DB_URL:-}" ]; then
printf '%s' "$DB_URL"
else
return 1
fi
}
resolve_dotenv_content() {
dotenv_file="${1:-}"
if [ -n "${HECTIC_DOTENV_CONTENT:-}" ]; then
printf '%s' "$HECTIC_DOTENV_CONTENT"
elif [ -n "$dotenv_file" ]; then
if [ ! -f "$dotenv_file" ] || [ ! -r "$dotenv_file" ]; then
return 2
fi
cat "$dotenv_file"
elif [ -n "${ENVIRONMENT:-}" ] && [ -n "${LOCAL_DIR:-}" ] && [ -r "${LOCAL_DIR}/.env.${ENVIRONMENT}" ]; then
cat "${LOCAL_DIR}/.env.${ENVIRONMENT}"
else
return 1
fi
}
subcommand_secrets_load() {
change_namespace 'db ops secrets load'
dotenv_file="${HECTIC_DOTENV_FILE:-}"
while [ $# -gt 0 ]; do
case $1 in
-h|--help)
help_secrets_load
restore_namespace
exit 0
;;
-u|--url)
if [ $# -lt 2 ]; then
log error "missing value for $1"
restore_namespace
exit 3
fi
PGURL=$2
DB_URL=$2
shift 2
;;
-f|--dotenv-file)
if [ $# -lt 2 ]; then
log error "missing value for $1"
restore_namespace
exit 3
fi
dotenv_file=$2
shift 2
;;
--*|-*)
log error "secrets load argument $1 does not exist"
restore_namespace
exit 9
;;
*)
log error "secrets load subcommand $1 does not exist"
restore_namespace
exit 9
;;
esac
done
if ! pgurl="$(resolve_pgurl)"; then
log error "PGURL or DB_URL is required"
restore_namespace
exit 3
fi
if dotenv_content="$(resolve_dotenv_content "$dotenv_file")"; then
:
else
dotenv_content_exit_code=$?
if [ "$dotenv_content_exit_code" -eq 2 ]; then
log error "dotenv file is not readable: $dotenv_file"
else
log error "dotenv source is required (HECTIC_DOTENV_CONTENT, readable dotenv file, or \$LOCAL_DIR/.env.\$ENVIRONMENT)"
fi
restore_namespace
exit 3
fi
log notice "apply hectic bundle and load secrets"
apply_hectic_bundle "$pgurl" "$dotenv_content"
restore_namespace
}
subcommand_secrets() {
change_namespace 'db ops secrets'
if [ $# -eq 0 ]; then
help_secrets_load
restore_namespace
exit 0
fi
subcommand=$1
shift
case $subcommand in
load)
subcommand_secrets_load "$@"
;;
-h|--help)
help_secrets_load
restore_namespace
exit 0
;;
*)
log error "secrets subcommand $subcommand does not exist"
restore_namespace
exit 9
;;
esac
}
while [ $# -gt 0 ]; do
case $1 in
-h|--help)
help
exit 0
;;
-u|--url)
if [ $# -lt 2 ]; then
log error "missing value for $1"
exit 3
fi
PGURL=$2
DB_URL=$2
shift 2
;;
--*|-*)
log error "argument $1 does not exist"
exit 9
;;
*)
break
;;
esac
done
subcommand="${1:-}"
if [ -z "$subcommand" ]; then
help
exit 0
fi
shift
case $subcommand in
secrets)
subcommand_secrets "$@"
;;
*)
log error "subcommand $subcommand does not exist"
exit 9
;;
esac
+28 -2
View File
@@ -8,7 +8,7 @@ let
applyBundle = self.lib.hectic.applyBundleScript;
mkDatabase =
mkDbDev =
{ postgresql ? postgresql_17 }:
hectic.writeShellApplication {
inherit shell;
@@ -36,6 +36,31 @@ let
};
};
mkDbOps =
{ postgresql ? postgresql_17 }:
hectic.writeShellApplication {
inherit shell;
bashOptions = [
"errexit"
"nounset"
];
excludeShellChecks = [ "SC2209" ];
name = "db-ops";
runtimeInputs = [ postgresql coreutils ];
text = ''
${builtins.readFile hectic.helpers.posix-shell.log}
${builtins.readFile hectic.helpers.posix-shell.change_namespace}
${applyBundle}
${builtins.readFile ./db-ops.sh}
'';
meta = {
description = "PostgreSQL operations utility";
mainProgram = "db-ops";
};
};
mkPostgresInit =
{ postgresql ? postgresql_17 }:
hectic.writeShellApplication {
@@ -73,7 +98,8 @@ let
};
in
{
"db-tool" = lib.makeOverridable mkDatabase { };
"db-dev" = lib.makeOverridable mkDbDev { };
"db-ops" = lib.makeOverridable mkDbOps { };
"postgres-init" = lib.makeOverridable mkPostgresInit { };
"postgres-cleanup" = lib.makeOverridable mkPostgresCleanup { };
"hectic-inheritance" = hecticInheritance;
+2
View File
@@ -151,6 +151,8 @@ in {
onlinepubs2man = pkgs.callPackage ./onlinepubs2man {};
migrator = pkgs.callPackage ./migrator { inherit self; };
"parse-uri" = pkgs.callPackage ./parse-uri {};
"db-dev" = dbToolPkgs."db-dev";
"db-ops" = dbToolPkgs."db-ops";
"db-tool" = dbToolPkgs."db-tool";
"postgres-init" = dbToolPkgs."postgres-init";
"postgres-cleanup" = dbToolPkgs."postgres-cleanup";
+2 -2
View File
@@ -1,4 +1,4 @@
{ dash, hectic, sqlite, postgresql_17, gawk, self }:
{ dash, hectic, sqlite, postgresql_17, gawk, coreutils, self }:
let
shell = "${dash}/bin/dash";
bashOptions = [
@@ -11,7 +11,7 @@ let
migrator = hectic.writeShellApplication {
inherit shell bashOptions;
name = "migrator";
runtimeInputs = [ sqlite postgresql_17 gawk ];
runtimeInputs = [ sqlite postgresql_17 gawk coreutils ];
text = ''
${builtins.readFile hectic.helpers.posix-shell.log}
+4 -3
View File
@@ -31,7 +31,8 @@
) (lib.filterAttrs (_: v: v != null)
(lib.mapAttrs (n: t: mkTestDrv folder n t) (testDir folder)));
database = self.packages.${system}."db-tool";
database = self.packages.${system}."db-dev";
dbOps = self.packages.${system}."db-ops";
postgresInit = self.packages.${system}."postgres-init";
postgresCleanup = self.packages.${system}."postgres-cleanup";
@@ -48,7 +49,7 @@
mkNonPgTest = testName: testDrv: pkgs.runCommand "db-tool-${testName}"
{
nativeBuildInputs = [ pkgs.coreutils pkgs.gnugrep pkgs.gnused ];
buildInputs = [ database postgresInit postgresCleanup pkgs.postgresql_17 pkgs.dash ];
buildInputs = [ database dbOps postgresInit postgresCleanup pkgs.postgresql_17 pkgs.dash ];
} ''
${builtins.readFile self.legacyPackages.${system}.helpers.posix-shell.log}
test=${testDrv}
@@ -64,7 +65,7 @@
mkPgTest = testName: testDrv: pkgs.runCommand "db-tool-${testName}"
{
nativeBuildInputs = [ pkgs.coreutils pkgs.gnugrep pkgs.gnused ];
buildInputs = [ database postgresInit postgresCleanup pkgs.postgresql_17 pkgs.dash pkgs.netcat-openbsd ];
buildInputs = [ database dbOps postgresInit postgresCleanup pkgs.postgresql_17 pkgs.dash pkgs.netcat-openbsd ];
} ''
${builtins.readFile self.legacyPackages.${system}.helpers.posix-shell.log}
test=${testDrv}
+19
View File
@@ -0,0 +1,19 @@
# shellcheck shell=dash
export HECTIC_NAMESPACE=test-db-ops-help
log notice "test case: db-ops --help exits 0"
if ! db-ops --help > /tmp/db-ops-help-out.txt 2>&1; then
log error "test failed: db-ops --help exited non-zero"
exit 1
fi
for tok in secrets load HECTIC_DOTENV_FILE PGURL DB_URL; do
if ! grep -qF "$tok" /tmp/db-ops-help-out.txt; then
log error "test failed: db-ops --help output missing token: $tok"
cat /tmp/db-ops-help-out.txt >&2
exit 1
fi
done
log notice "test passed"
@@ -0,0 +1,24 @@
# shellcheck shell=dash
export HECTIC_NAMESPACE=test-db-ops-missing-dotenv
if db-ops --url 'postgresql://example.invalid/test' secrets load > /tmp/db-ops-missing-dotenv.out 2>&1; then
log error "expected db-ops secrets load to fail without dotenv source"
exit 1
else
exit_code=$?
fi
[ "$exit_code" -eq 3 ] || {
log error "expected exit code 3 without dotenv source, got: $exit_code"
cat /tmp/db-ops-missing-dotenv.out >&2
exit 1
}
if ! grep -qF 'dotenv source is required' /tmp/db-ops-missing-dotenv.out; then
log error "missing dotenv source error message"
cat /tmp/db-ops-missing-dotenv.out >&2
exit 1
fi
log notice "test passed"
@@ -0,0 +1,28 @@
# shellcheck shell=dash
export HECTIC_NAMESPACE=test-db-ops-missing-pgurl
dotenv_file=$(mktemp)
trap 'rm -f "$dotenv_file"' EXIT INT TERM
printf 'TEST_SECRET=hello-world\n' > "$dotenv_file"
if db-ops secrets load --dotenv-file "$dotenv_file" > /tmp/db-ops-missing-pgurl.out 2>&1; then
log error "expected db-ops secrets load to fail without PGURL"
exit 1
else
exit_code=$?
fi
[ "$exit_code" -eq 3 ] || {
log error "expected exit code 3 without PGURL, got: $exit_code"
cat /tmp/db-ops-missing-pgurl.out >&2
exit 1
}
if ! grep -qF 'PGURL or DB_URL is required' /tmp/db-ops-missing-pgurl.out; then
log error "missing PGURL error message"
cat /tmp/db-ops-missing-pgurl.out >&2
exit 1
fi
log notice "test passed"
@@ -0,0 +1,28 @@
# shellcheck shell=dash
export HECTIC_NAMESPACE=test-db-ops-unreadable-dotenv
dotenv_dir=$(mktemp -d)
dotenv_file="$dotenv_dir/missing.env"
trap 'rm -rf "$dotenv_dir"' EXIT INT TERM
if db-ops --url 'postgresql://example.invalid/test' secrets load --dotenv-file "$dotenv_file" > /tmp/db-ops-unreadable-dotenv.out 2>&1; then
log error "expected db-ops secrets load to fail for unreadable explicit dotenv path"
exit 1
else
exit_code=$?
fi
[ "$exit_code" -eq 3 ] || {
log error "expected exit code 3 for unreadable explicit dotenv path, got: $exit_code"
cat /tmp/db-ops-unreadable-dotenv.out >&2
exit 1
}
if ! grep -qF 'dotenv file is not readable' /tmp/db-ops-unreadable-dotenv.out; then
log error "missing unreadable dotenv file error message"
cat /tmp/db-ops-unreadable-dotenv.out >&2
exit 1
fi
log notice "test passed"
@@ -0,0 +1,56 @@
# shellcheck shell=dash
HECTIC_NAMESPACE=test-db-ops-secrets-load
PG_WORKING_DIR=$(mktemp -d)
export PG_WORKING_DIR PG_DATABASE=testdb PG_PORT=5432 PG_SHARED_PRELOAD_LIBRARIES=''
cleanup() {
postgres-cleanup >/dev/null 2>&1 || :
rm -rf "$PG_WORKING_DIR"
}
trap 'cleanup' EXIT INT TERM
if ! postgres-init; then
log error "postgres-init failed"
exit 1
fi
PGURL="postgresql://$(id -un)@/testdb?host=${PG_WORKING_DIR}/sock&port=5432"
export PGURL
dotenv_file=$(mktemp)
printf 'TEST_SECRET=hello-world\nQUOTED_SECRET="quoted value"\nDELIMITER_SECRET=before$ps_env$after\n' > "$dotenv_file"
if ! db-ops secrets load --dotenv-file "$dotenv_file"; then
log error "db-ops secrets load failed"
rm -f "$dotenv_file"
exit 1
fi
rm -f "$dotenv_file"
secret_value=$(psql "$PGURL" -v ON_ERROR_STOP=1 -tAc "SELECT value FROM hectic.secret WHERE key = 'TEST_SECRET';") || exit 1
[ "$secret_value" = "hello-world" ] || {
log error "expected TEST_SECRET to be loaded, got: $secret_value"
exit 1
}
quoted_value=$(psql "$PGURL" -v ON_ERROR_STOP=1 -tAc "SELECT value FROM hectic.secret WHERE key = 'QUOTED_SECRET';") || exit 1
[ "$quoted_value" = "quoted value" ] || {
log error "expected QUOTED_SECRET to strip outer quotes, got: $quoted_value"
exit 1
}
delimiter_value=$(psql "$PGURL" -v ON_ERROR_STOP=1 -tAc "SELECT value FROM hectic.secret WHERE key = 'DELIMITER_SECRET';") || exit 1
[ "$delimiter_value" = 'before$ps_env$after' ] || {
log error "expected DELIMITER_SECRET to preserve dollar-quote delimiter text, got: $delimiter_value"
exit 1
}
hectic_schema=$(psql "$PGURL" -v ON_ERROR_STOP=1 -tAc "SELECT count(*) FROM pg_namespace WHERE nspname='hectic';") || exit 1
[ "$hectic_schema" = 1 ] || {
log error "expected hectic schema to exist, got: $hectic_schema"
exit 1
}
log notice "test passed"