run_setup: refuse an existing installation; remove --clean-slate

- Removed --clean-slate: it dropped every schema in the database,
  including user data. Nothing used it.
- Pre-check (same rule as the Python client's init_postgres_db()): refuse
  if meta or log exists, series is not empty, or the prevent_series_drops
  trigger exists, naming the installed version. User schemas and tables
  are allowed and never touched; an empty series schema is reused.
- --drop-existing drops every registered series schema plus meta and log,
  and refuses if a series schema holds non-series objects.
- All files run in one transaction: a failure leaves nothing behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
peter 2026-10-06 11:58:46 +01:00
parent eb0f93e032
commit 9032fd3a45
2 changed files with 109 additions and 48 deletions

View file

@ -52,10 +52,18 @@ cp db_config.json.example db_config.json # then edit host/user/password
# 2. create the schema # 2. create the schema
python run_setup.py # uses db_config.json / defaults python run_setup.py # uses db_config.json / defaults
python run_setup.py --host 192.168.1.7 --user claude --password claudepwd python run_setup.py --host 192.168.1.7 --user claude --password claudepwd
python run_setup.py --drop-existing # drop meta+series+log first python run_setup.py --drop-existing # drop the existing installation first
python run_setup.py --clean-slate # drop ALL user objects first # (ALL series data); user schemas untouched
``` ```
The database may hold the user's own schemas and tables; provisioning never
touches them. It **refuses** a database that already holds the library's
objects -- `meta`, `log`, a non-empty `series` schema, or the
`prevent_series_drops` event trigger -- and reports the installed version.
There are no migrations: re-provision with `--drop-existing` (deletes all
series) or use another database. All files run in one transaction, so a
failure leaves the database untouched.
Requires `psycopg2` and a PostgreSQL user with CREATE privileges on the target Requires `psycopg2` and a PostgreSQL user with CREATE privileges on the target
database. database.

View file

@ -22,8 +22,12 @@ Connection settings come from (highest precedence first):
Usage: Usage:
python run_setup.py # use db_config.json / defaults python run_setup.py # use db_config.json / defaults
python run_setup.py --host 192.168.1.7 --user claude --password claudepwd python run_setup.py --host 192.168.1.7 --user claude --password claudepwd
python run_setup.py --drop-existing # drop meta+series schemas first python run_setup.py --drop-existing # drop the existing installation first
python run_setup.py --clean-slate # drop ALL user objects first # (all series!); user schemas untouched
A database that already holds the library's objects (meta, log, a non-empty
series schema, the event trigger) is refused -- there are no migrations.
User schemas and tables may exist and are never touched.
""" """
import sys import sys
@ -82,8 +86,7 @@ def execute_sql_file(conn, sql_file: Path):
try: try:
sql = sql_file.read_text(encoding="utf-8") sql = sql_file.read_text(encoding="utf-8")
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(sql) cur.execute(sql) # committed by the caller, all files at once
conn.commit()
return True, f"[OK] Executed {sql_file.name}" return True, f"[OK] Executed {sql_file.name}"
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
@ -122,23 +125,72 @@ def verify_setup(conn) -> None:
print(f"[OK] schema_version: {row[0]} ({row[1]})") print(f"[OK] schema_version: {row[0]} ({row[1]})")
def clean_slate(conn) -> None: # Pre-check, shared rule with the Python client's init_postgres_db():
print("Performing clean slate...") # provisioning needs the library's own objects to be ABSENT. User schemas and
# tables are irrelevant and never touched. Never drop-and-recreate: an
# existing installation is refused, because there are no migrations and
# re-provisioning deletes all series.
PRECHECK_SQL = """
SELECT 'schema ' || nspname || ' exists'
FROM pg_namespace WHERE nspname IN ('meta', 'log')
UNION ALL
SELECT 'schema series is not empty (' || count(*) || ' objects)'
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'series' AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S')
HAVING count(*) > 0
UNION ALL
SELECT 'event trigger prevent_series_drops exists'
FROM pg_event_trigger WHERE evtname = 'prevent_series_drops'
"""
def existing_installation(conn) -> list:
"""What would stop a fresh install; [] means go ahead."""
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(PRECHECK_SQL)
"SELECT schema_name FROM information_schema.schemata " found = [r[0] for r in cur.fetchall()]
"WHERE schema_name NOT IN ('pg_catalog','information_schema','pg_toast','public') " cur.execute("SELECT to_regclass('meta.schema_version') IS NOT NULL")
"AND schema_name NOT LIKE 'pg_temp_%' AND schema_name NOT LIKE 'pg_toast_temp_%'" if cur.fetchone()[0]:
) cur.execute("SELECT string_agg(version, ', ') FROM meta.schema_version")
for (schema,) in cur.fetchall(): version = cur.fetchone()[0]
print(f" drop schema {schema}") found.append(f"installed schema version: {version or 'none recorded'}")
return found
def drop_existing(conn) -> None:
"""Drop the library's objects: every registered series schema, meta, log,
the event trigger. Refuses if a series schema holds non-series objects --
those are user data, which this never deletes."""
with conn.cursor() as cur:
schemas = ['series']
cur.execute("SELECT to_regclass('meta.series_schemas') IS NOT NULL")
if cur.fetchone()[0]:
cur.execute("SELECT name FROM meta.series_schemas")
schemas = sorted({r[0] for r in cur.fetchall()} | {'series'})
cur.execute("SELECT to_regclass('meta.series') IS NOT NULL")
has_registry = cur.fetchone()[0]
stray = []
for schema in schemas:
cur.execute(
"SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
"WHERE n.nspname = %s AND c.relkind IN ('r','p','v','m','f','S')", (schema,))
names = [r[0] for r in cur.fetchall()]
if has_registry:
cur.execute("SELECT table_name FROM meta.series WHERE db_schema = %s", (schema,))
names = sorted(set(names) - {r[0] for r in cur.fetchall()})
stray += [f"{schema}.{n}" for n in names]
if stray:
raise RuntimeError(
"refusing to drop: these objects in series schemas are not series "
f"(user data?): {', '.join(stray)}. Move or drop them first.")
cur.execute("DROP EVENT TRIGGER IF EXISTS prevent_series_drops")
for schema in schemas:
cur.execute(f"DROP SCHEMA IF EXISTS {schema} CASCADE") cur.execute(f"DROP SCHEMA IF EXISTS {schema} CASCADE")
cur.execute("SELECT evtname FROM pg_event_trigger") print(f" dropped schema {schema}")
for (trig,) in cur.fetchall(): cur.execute("DROP SCHEMA IF EXISTS meta CASCADE")
cur.execute(f"DROP EVENT TRIGGER IF EXISTS {trig}") cur.execute("DROP SCHEMA IF EXISTS log CASCADE")
cur.execute("CREATE SCHEMA IF NOT EXISTS public") conn.commit()
conn.commit() print("[OK] existing installation dropped\n")
print("[OK] clean slate done\n")
def main() -> int: def main() -> int:
@ -149,9 +201,8 @@ def main() -> int:
p.add_argument("--user") p.add_argument("--user")
p.add_argument("--password") p.add_argument("--password")
p.add_argument("--drop-existing", action="store_true", p.add_argument("--drop-existing", action="store_true",
help="Drop meta and series schemas before creating (destructive)") help="Drop the existing installation first: every series schema "
p.add_argument("--clean-slate", action="store_true", "(ALL series data), meta, log. User schemas are untouched.")
help="Drop ALL user objects before creating (very destructive)")
p.add_argument("--yes", action="store_true", help="Skip confirmation prompts") p.add_argument("--yes", action="store_true", help="Skip confirmation prompts")
args = p.parse_args() args = p.parse_args()
@ -170,8 +221,8 @@ def main() -> int:
print(f"Target : {cfg['host']}:{cfg['port']}/{cfg['database']} (user {cfg['user']})") print(f"Target : {cfg['host']}:{cfg['port']}/{cfg['database']} (user {cfg['user']})")
print(f"SQL dir: {SQL_DIR}") print(f"SQL dir: {SQL_DIR}")
if (args.clean_slate or args.drop_existing) and not args.yes: if args.drop_existing and not args.yes:
what = "ALL user objects" if args.clean_slate else "meta and series schemas" what = "the existing installation, including ALL series data"
if input(f"This will DROP {what}. Continue? (yes/no): ").lower() != "yes": if input(f"This will DROP {what}. Continue? (yes/no): ").lower() != "yes":
print("Aborted.") print("Aborted.")
return 1 return 1
@ -186,32 +237,34 @@ def main() -> int:
return 1 return 1
try: try:
if args.clean_slate: if args.drop_existing:
clean_slate(conn) try:
elif args.drop_existing: drop_existing(conn)
with conn.cursor() as cur: except RuntimeError as e:
cur.execute("DROP SCHEMA IF EXISTS meta CASCADE") print(f"[FAIL] {e}")
cur.execute("DROP SCHEMA IF EXISTS series CASCADE") return 1
cur.execute("DROP SCHEMA IF EXISTS log CASCADE")
cur.execute("DROP EVENT TRIGGER IF EXISTS prevent_series_drops")
conn.commit()
print("[OK] existing schemas dropped\n")
failed = [] existing = existing_installation(conn)
if existing:
print("[FAIL] the database already holds a tsapi installation:")
for line in existing:
print(f" - {line}")
print(" There are no migrations. Re-provision with --drop-existing "
"(deletes all series), or use another database.")
return 1
missing = [n for n in SQL_FILES if not (SQL_DIR / n).exists()]
if missing:
print(f"[FAIL] missing {', '.join(missing)}")
return 1
# One transaction: a failure part-way leaves the database untouched.
for name in SQL_FILES: for name in SQL_FILES:
f = SQL_DIR / name ok, msg = execute_sql_file(conn, SQL_DIR / name)
if not f.exists():
print(f"[FAIL] missing {name}")
failed.append(name)
continue
ok, msg = execute_sql_file(conn, f)
print(msg) print(msg)
if not ok: if not ok:
failed.append(name) print(f"\n[FAIL] {name} failed; nothing was installed")
return 1
if failed: conn.commit()
print(f"\n[FAIL] {len(failed)} file(s) failed: {', '.join(failed)}")
return 1
verify_setup(conn) verify_setup(conn)
print("\n[SUCCESS] schema setup complete") print("\n[SUCCESS] schema setup complete")
return 0 return 0