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:
parent
eb0f93e032
commit
9032fd3a45
2 changed files with 109 additions and 48 deletions
12
README.md
12
README.md
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
145
run_setup.py
145
run_setup.py
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue