diff --git a/lib/default.nix b/lib/default.nix index 51ab7fe..0de7a96 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -100,6 +100,19 @@ in { # -- Cargo.toml -- cargoToml = src: (builtins.fromTOML (builtins.readFile "${src}/Cargo.toml")); + # SQL bundle bootstrapping `hectic.created_at` / `hectic.updated_at` inheritance enforcement. + # Consumers can either: + # * read the SQL string for inline pipelines: `self.lib.hecticInheritance.sql` + # * reference the source path: `self.lib.hecticInheritance.path` + # * use the per-system package: `pkgs.hectic.hectic-inheritance` (provides + # `$out/share/hectic/hectic-inheritance.sql`) + hecticInheritance = let + path = ../package/db-tool/sql/hectic-inheritance.sql; + in { + inherit path; + sql = builtins.readFile path; + }; + ssh.keys = { hetzner-test = { yukkop = ''ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ8scy1tv6zfXX6xyaukhO/fsZwif5rC89DvXNc6XxOf''; diff --git a/package/db-tool/README.md b/package/db-tool/README.md index 9003257..0e37e4c 100644 --- a/package/db-tool/README.md +++ b/package/db-tool/README.md @@ -32,6 +32,8 @@ These variables must be set for `db-tool` to function. | `PG_CONF_FILE` | (unset) | Path to a `postgresql.conf` file. When set, replaces the script-generated config entirely on fresh init. `port` and `unix_socket_directories` are still appended at runtime (always overridden). When set, `PG_DISABLE_LOGGING` and `PG_SHARED_PRELOAD_LIBRARIES` are ignored. | | `PG_SHARED_PRELOAD_LIBRARIES` | `pg_cron` | Comma-separated `shared_preload_libraries` value. Set to empty string to disable. Ignored when `PG_CONF_FILE` is set. | | `PG_DISABLE_LOGGING` | `0` | Set to `1` to disable PostgreSQL logging collector. Ignored when `PG_CONF_FILE` is set. | +| `PG_HECTIC_INHERITANCE` | `0` | Set to `1` to apply the [`hectic` inheritance bundle](#hectic-inheritance-bundle) to the target database after init. | +| `HECTIC_INHERITANCE_SQL` | (auto) | Override path to the SQL file applied by `PG_HECTIC_INHERITANCE=1`. Defaults to the SQL shipped with `postgres-init`. | | `PATCH_LOG` | (stdout) | Path to log the output of database patches. | | `HYDRATE_LOG` | (stdout) | Path to log the output of database hydration. | @@ -107,6 +109,54 @@ To use `db-tool` in a Nix development shell, add the following to your `flake.ni } ``` +## hectic Inheritance Bundle + +`pkgs.hectic.hectic-inheritance` ships a SQL artifact that bootstraps a `hectic` +schema with two parent tables and DDL event triggers: + +- `hectic.created_at(created_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` — every + user table must `INHERITS (hectic.created_at)`. The event trigger + `hectic_enforce_created_at_inheritance` raises an exception on `CREATE TABLE` + otherwise. +- `hectic.updated_at(updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` — optional. + Any table that inherits from it automatically gets a `BEFORE UPDATE FOR EACH + ROW` trigger calling `hectic.set_updated_at()` attached by + `hectic_attach_updated_at_trigger`. + +Always-exempt schemas: `hectic`, `information_schema`, anything matching +`pg_*`. Declarative partitions (`relispartition = true`) and temporary tables +are also auto-exempt. + +Per-database opt-out for additional schemas via the +`hectic.inheritance_extra_excluded_schemas` GUC (comma-separated): + +```sql +ALTER DATABASE mydb SET hectic.inheritance_extra_excluded_schemas = 'legacy,etl'; +``` + +### Apply via `postgres-init` + +```sh +export PG_HECTIC_INHERITANCE=1 +postgres-init +``` + +### Apply via `migrator` or any psql pipeline + +```nix +# in your devshell +shellHook = '' + export HECTIC_INHERITANCE_SQL=${pkgs.hectic.hectic-inheritance}/share/hectic/hectic-inheritance.sql +''; +``` + +```sh +psql "$DB_URL" -v ON_ERROR_STOP=1 -f "$HECTIC_INHERITANCE_SQL" +``` + +The SQL is also exposed via `self.lib.hecticInheritance.sql` (string) and +`self.lib.hecticInheritance.path` (Nix path) for inline pipelines. + ## Exit Codes | Code | Meaning | diff --git a/package/db-tool/default.nix b/package/db-tool/default.nix index 83073a1..94aab4e 100644 --- a/package/db-tool/default.nix +++ b/package/db-tool/default.nix @@ -1,7 +1,14 @@ -{ dash, hectic, postgresql_17, neovim, openssh, coreutils, gawk, lib }: +{ dash, hectic, postgresql_17, neovim, openssh, coreutils, gawk, lib, runCommand }: let shell = "${dash}/bin/dash"; + hecticInheritanceSqlPath = ./sql/hectic-inheritance.sql; + + hecticInheritance = runCommand "hectic-inheritance" { } '' + mkdir -p "$out/share/hectic" + cp ${hecticInheritanceSqlPath} "$out/share/hectic/hectic-inheritance.sql" + ''; + mkDatabase = { postgresql ? postgresql_17 }: hectic.writeShellApplication { @@ -37,7 +44,11 @@ let name = "postgres-init"; runtimeInputs = [ postgresql coreutils ]; - text = builtins.readFile ./postgres-init.sh; + text = '' + HECTIC_INHERITANCE_SQL_DEFAULT="${hecticInheritance}/share/hectic/hectic-inheritance.sql" + export HECTIC_INHERITANCE_SQL_DEFAULT + ${builtins.readFile ./postgres-init.sh} + ''; meta = { description = "Initialize local PostgreSQL instance"; @@ -62,7 +73,8 @@ let }; in { - "db-tool" = lib.makeOverridable mkDatabase { }; - "postgres-init" = lib.makeOverridable mkPostgresInit { }; - "postgres-cleanup" = lib.makeOverridable mkPostgresCleanup { }; + "db-tool" = lib.makeOverridable mkDatabase { }; + "postgres-init" = lib.makeOverridable mkPostgresInit { }; + "postgres-cleanup" = lib.makeOverridable mkPostgresCleanup { }; + "hectic-inheritance" = hecticInheritance; } diff --git a/package/db-tool/postgres-init.sh b/package/db-tool/postgres-init.sh index d0074b9..c298cf6 100644 --- a/package/db-tool/postgres-init.sh +++ b/package/db-tool/postgres-init.sh @@ -48,6 +48,16 @@ postgres_init_main() { fi psql -h "$sockdir" -p "$PG_PORT" -d "$db" -v ON_ERROR_STOP=1 -c 'select 1;' || return 1 + if [ "${PG_HECTIC_INHERITANCE:-0}" = "1" ]; then + sql_file="${HECTIC_INHERITANCE_SQL:-${HECTIC_INHERITANCE_SQL_DEFAULT:-}}" + if [ -z "$sql_file" ]; then + printf '%s\n' 'postgres-init: PG_HECTIC_INHERITANCE=1 but no SQL file resolved (set HECTIC_INHERITANCE_SQL)' >&2 + return 3 + fi + [ -r "$sql_file" ] || { printf '%s\n' "postgres-init: hectic-inheritance SQL not readable: $sql_file" >&2; return 1; } + psql -h "$sockdir" -p "$PG_PORT" -U "$user" -d "$db" -v ON_ERROR_STOP=1 -f "$sql_file" || return 1 + fi + 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 diff --git a/package/db-tool/sql/hectic-inheritance.sql b/package/db-tool/sql/hectic-inheritance.sql new file mode 100644 index 0000000..0eb3506 --- /dev/null +++ b/package/db-tool/sql/hectic-inheritance.sql @@ -0,0 +1,155 @@ +-- hectic.created_at / hectic.updated_at inheritance machinery. +-- +-- Provides: +-- * schema hectic +-- * tables hectic.created_at(created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()) +-- hectic.updated_at(updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()) +-- * function hectic.set_updated_at() -- BEFORE UPDATE row trigger function +-- * GUC hectic.inheritance_extra_excluded_schemas +-- (text, comma-separated list of schemas the enforcement trigger skips) +-- * event trigger hectic_enforce_created_at_inheritance +-- RAISE EXCEPTION on CREATE TABLE that does not inherit hectic.created_at +-- * event trigger hectic_attach_updated_at_trigger +-- auto-attaches BEFORE UPDATE row trigger calling hectic.set_updated_at() +-- on any new table that inherits hectic.updated_at and lacks one. +-- +-- Idempotent: safe to run on an already-bootstrapped database. + +CREATE SCHEMA IF NOT EXISTS "hectic"; + +CREATE TABLE IF NOT EXISTS "hectic"."created_at" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS "hectic"."updated_at" ( + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +DO $bootstrap$ +BEGIN + PERFORM set_config('hectic.inheritance_extra_excluded_schemas', + current_setting('hectic.inheritance_extra_excluded_schemas', true), + false); +EXCEPTION WHEN undefined_object THEN + PERFORM set_config('hectic.inheritance_extra_excluded_schemas', '', false); +END +$bootstrap$; + +CREATE OR REPLACE FUNCTION "hectic"."set_updated_at"() RETURNS trigger +LANGUAGE plpgsql AS $fn$ +BEGIN + NEW."updated_at" := NOW(); + RETURN NEW; +END +$fn$; + +CREATE OR REPLACE FUNCTION "hectic"."_is_excluded_schema"(p_schema text) RETURNS boolean +LANGUAGE plpgsql STABLE AS $fn$ +DECLARE + extra text; + s text; +BEGIN + IF p_schema = 'hectic' + OR p_schema = 'information_schema' + OR p_schema LIKE 'pg\_%' ESCAPE '\' + THEN + RETURN true; + END IF; + BEGIN + extra := current_setting('hectic.inheritance_extra_excluded_schemas', true); + EXCEPTION WHEN OTHERS THEN + extra := ''; + END; + IF extra IS NULL OR extra = '' THEN + RETURN false; + END IF; + FOREACH s IN ARRAY string_to_array(extra, ',') LOOP + IF btrim(s) = p_schema THEN + RETURN true; + END IF; + END LOOP; + RETURN false; +END +$fn$; + +CREATE OR REPLACE FUNCTION "hectic"."_table_inherits"(p_oid oid, p_parent regclass) RETURNS boolean +LANGUAGE sql STABLE AS $fn$ + SELECT EXISTS ( + SELECT 1 FROM pg_inherits + WHERE inhrelid = p_oid AND inhparent = p_parent + ); +$fn$; + +CREATE OR REPLACE FUNCTION "hectic"."enforce_created_at_inheritance"() RETURNS event_trigger +LANGUAGE plpgsql AS $fn$ +DECLARE + obj record; + rel pg_class; + schema_name text; + parent_oid oid; +BEGIN + parent_oid := 'hectic.created_at'::regclass; + FOR obj IN SELECT * FROM pg_event_trigger_ddl_commands() WHERE command_tag = 'CREATE TABLE' + LOOP + SELECT * INTO rel FROM pg_class WHERE oid = obj.objid; + IF NOT FOUND THEN CONTINUE; END IF; + IF rel.relpersistence = 't' THEN CONTINUE; END IF; + IF rel.relispartition THEN CONTINUE; END IF; + SELECT nspname INTO schema_name FROM pg_namespace WHERE oid = rel.relnamespace; + IF "hectic"."_is_excluded_schema"(schema_name) THEN CONTINUE; END IF; + IF NOT "hectic"."_table_inherits"(rel.oid, parent_oid) THEN + RAISE EXCEPTION + 'hectic: table %.% must INHERITS (hectic.created_at)', + quote_ident(schema_name), quote_ident(rel.relname) + USING HINT = 'add INHERITS ("hectic"."created_at") to the CREATE TABLE statement, ' + || 'or add the schema to hectic.inheritance_extra_excluded_schemas'; + END IF; + END LOOP; +END +$fn$; + +CREATE OR REPLACE FUNCTION "hectic"."attach_updated_at_trigger"() RETURNS event_trigger +LANGUAGE plpgsql AS $fn$ +DECLARE + obj record; + rel pg_class; + schema_name text; + parent_oid oid; + trigger_name text; + has_trigger boolean; +BEGIN + parent_oid := 'hectic.updated_at'::regclass; + FOR obj IN SELECT * FROM pg_event_trigger_ddl_commands() WHERE command_tag = 'CREATE TABLE' + LOOP + SELECT * INTO rel FROM pg_class WHERE oid = obj.objid; + IF NOT FOUND THEN CONTINUE; END IF; + IF rel.relpersistence = 't' THEN CONTINUE; END IF; + IF rel.relispartition THEN CONTINUE; END IF; + SELECT nspname INTO schema_name FROM pg_namespace WHERE oid = rel.relnamespace; + IF schema_name = 'hectic' THEN CONTINUE; END IF; + IF NOT "hectic"."_table_inherits"(rel.oid, parent_oid) THEN CONTINUE; END IF; + trigger_name := 'hectic_set_updated_at'; + SELECT EXISTS ( + SELECT 1 FROM pg_trigger + WHERE tgrelid = rel.oid AND tgname = trigger_name AND NOT tgisinternal + ) INTO has_trigger; + IF has_trigger THEN CONTINUE; END IF; + EXECUTE format( + 'CREATE TRIGGER %I BEFORE UPDATE ON %I.%I FOR EACH ROW EXECUTE FUNCTION "hectic"."set_updated_at"()', + trigger_name, schema_name, rel.relname + ); + END LOOP; +END +$fn$; + +DROP EVENT TRIGGER IF EXISTS "hectic_enforce_created_at_inheritance"; +CREATE EVENT TRIGGER "hectic_enforce_created_at_inheritance" + ON ddl_command_end + WHEN TAG IN ('CREATE TABLE') + EXECUTE FUNCTION "hectic"."enforce_created_at_inheritance"(); + +DROP EVENT TRIGGER IF EXISTS "hectic_attach_updated_at_trigger"; +CREATE EVENT TRIGGER "hectic_attach_updated_at_trigger" + ON ddl_command_end + WHEN TAG IN ('CREATE TABLE') + EXECUTE FUNCTION "hectic"."attach_updated_at_trigger"(); diff --git a/package/default.nix b/package/default.nix index d946d61..dbbdaad 100644 --- a/package/default.nix +++ b/package/default.nix @@ -145,6 +145,7 @@ in { "db-tool" = dbToolPkgs."db-tool"; "postgres-init" = dbToolPkgs."postgres-init"; "postgres-cleanup" = dbToolPkgs."postgres-cleanup"; + "hectic-inheritance" = dbToolPkgs."hectic-inheritance"; nbt2json = pkgs.callPackage ./nbt2json {}; hemar-parser = pkgs.callPackage ./hemar/parser {}; AstroTuxLauncher = pkgs.callPackage ./AstroTuxLauncher.nix {}; diff --git a/test/package/db-tool/test/postgresql/postgres-init-hectic-inheritance/run.sh b/test/package/db-tool/test/postgresql/postgres-init-hectic-inheritance/run.sh new file mode 100644 index 0000000..7d2361a --- /dev/null +++ b/test/package/db-tool/test/postgresql/postgres-init-hectic-inheritance/run.sh @@ -0,0 +1,93 @@ +# shellcheck shell=dash + +HECTIC_NAMESPACE=test-db-tool-hectic-inheritance + +PG_WORKING_DIR=$(mktemp -d) +export PG_WORKING_DIR PG_DATABASE=testdb PG_PORT=5432 PG_SHARED_PRELOAD_LIBRARIES='' +export PG_HECTIC_INHERITANCE=1 + +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 with PG_HECTIC_INHERITANCE=1 failed" + exit 1 +fi + +sockdir="$PG_WORKING_DIR/sock" +user=$(id -un) +pgurl="postgresql://${user}@/testdb?host=${sockdir}&port=5432" + +run_sql() { + psql "$pgurl" -v ON_ERROR_STOP=1 -tAc "$1" +} + +run_sql_expect_fail() { + if psql "$pgurl" -v ON_ERROR_STOP=1 -c "$1" >/dev/null 2>&1; then + return 1 + fi + return 0 +} + +log notice "case 1: hectic schema and parent tables exist" +got=$(run_sql "SELECT count(*) FROM pg_namespace WHERE nspname='hectic';") || exit 1 +[ "$got" = 1 ] || { log error "hectic schema missing"; exit 1; } +got=$(run_sql "SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='hectic' AND c.relname IN ('created_at','updated_at');") || exit 1 +[ "$got" = 2 ] || { log error "parent tables missing"; exit 1; } + +log notice "case 2: CREATE TABLE without inheritance is rejected" +if ! run_sql_expect_fail 'CREATE TABLE public.bad_table (id int);'; then + log error "non-inheriting CREATE TABLE was accepted" + exit 1 +fi + +log notice "case 3: CREATE TABLE inheriting hectic.created_at is accepted" +run_sql 'CREATE TABLE public.good_table (id int) INHERITS ("hectic"."created_at");' || exit 1 + +log notice "case 4: tables inheriting hectic.updated_at get auto BEFORE UPDATE trigger" +run_sql 'CREATE TABLE public.with_updated (id int, val text) INHERITS ("hectic"."created_at", "hectic"."updated_at");' || exit 1 +got=$(run_sql "SELECT count(*) FROM pg_trigger WHERE tgrelid='public.with_updated'::regclass AND tgname='hectic_set_updated_at' AND NOT tgisinternal;") || exit 1 +[ "$got" = 1 ] || { log error "auto updated_at trigger missing"; exit 1; } + +run_sql "INSERT INTO public.with_updated (id, val) VALUES (1, 'a');" || exit 1 +sleep 1 +run_sql "UPDATE public.with_updated SET val='b' WHERE id=1;" || exit 1 +got=$(run_sql "SELECT (updated_at > created_at)::int FROM public.with_updated WHERE id=1;") || exit 1 +[ "$got" = 1 ] || { log error "updated_at not bumped on UPDATE (got: $got)"; exit 1; } + +log notice "case 5: GUC hectic.inheritance_extra_excluded_schemas exempts schemas" +run_sql 'CREATE SCHEMA legacy;' || exit 1 +if ! run_sql_expect_fail 'CREATE TABLE legacy.t1 (id int);'; then + log error "legacy.t1 should be rejected before GUC set" + exit 1 +fi +run_sql "ALTER DATABASE testdb SET hectic.inheritance_extra_excluded_schemas = 'legacy';" || exit 1 +psql "$pgurl" -v ON_ERROR_STOP=1 -c 'CREATE TABLE legacy.t1 (id int);' || { + log error "legacy.t1 rejected even after GUC exclusion" + exit 1 +} + +log notice "case 6: declarative partitions are exempt" +run_sql 'CREATE TABLE public.parted (id int, region text) PARTITION BY LIST (region) INHERITS ("hectic"."created_at");' && { + log error "PARTITION BY combined with INHERITS unexpectedly succeeded" + exit 1 +} || : +run_sql 'CREATE TABLE public.events (id int, region text, created_at timestamptz NOT NULL DEFAULT NOW()) PARTITION BY LIST (region);' && { + log error "partitioned parent without inheritance unexpectedly succeeded" + exit 1 +} || : +run_sql "ALTER DATABASE testdb SET hectic.inheritance_extra_excluded_schemas = 'legacy,parts';" || exit 1 +run_sql 'CREATE SCHEMA parts;' || exit 1 +psql "$pgurl" -v ON_ERROR_STOP=1 -c 'CREATE TABLE parts.events (id int, region text) PARTITION BY LIST (region);' || { + log error "partitioned parent in excluded schema rejected" + exit 1 +} +psql "$pgurl" -v ON_ERROR_STOP=1 -c "CREATE TABLE parts.events_us PARTITION OF parts.events FOR VALUES IN ('us');" || { + log error "declarative partition was rejected (should be exempt via relispartition)" + exit 1 +} + +log notice "test passed"