Files
FabledSteward/steward/backup/restore.py
T
bvandeusenandClaude Opus 5.5 aab8581f20
CI / lint (push) Failing after 3s
CI / unit (push) Successful in 1m41s
CI / integration (push) Failing after 1m45s
CI / publish (push) Skipped
feat(backup): restore a same-version backup in place, history kept, all or nothing (#5815)
#5815 "Restore engine: in place for a same-version backup".

restore_in_place (steward/backup/restore.py) runs in one transaction:
1. It keeps aside what Steward records as it runs: ACTIVITY columns of rows
   that come back, and STATE_SETTINGS.
2. It drops every foreign key touching a replaced table.
3. It truncates the replaced tables and COPYs the backup's rows in
   unparsed, through asyncpg.
4. It puts the kept state back and moves sequences past their rows.
5. It puts right the rows whose parent didn't come back, by each key's own
   ON DELETE. A RESTRICT key that may be empty is cleared, so a run whose
   user is gone keeps its record.
6. It re-adds the keys, which checks every row.

A failure anywhere rolls the whole restore back. A backup made at another
schema is refused with NotSameSchema ("older": a full replace is the way;
"newer": update Steward).

The COPY-text reading moves into steward/backup/pgdata.py, shared by the
check and the restore. The integration tests stand in for pg_restore with
this database's own COPY output:
- settings come back while the audit log and the history of what remains
  stay;
- history of what didn't come back goes;
- a failure part-way changes nothing;
- a newer schema is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:53:38 -04:00

416 lines
20 KiB
Python

"""Bringing Steward back from a backup file: into an empty database
(`restore`, the CLI and first-run setup), or in place on a running Steward
(`restore_in_place`, below).
pg_restore runs in its three sections so the rows can be put right between
the data and the constraints: a kept table may point at a row of a table
whose rows were left out (an alert event naming the Ansible run it
started). Each such reference gets what its foreign key's ON DELETE says
would happen had that row been deleted: SET NULL clears it, anything else
removes the row. Then the constraints and indexes go on.
Migrations are not run here: the next start of Steward runs them, as it
always does, so a backup made by an older Steward comes up to date.
"""
from __future__ import annotations
import asyncio
import io
import logging
import tempfile
from dataclasses import dataclass
from pathlib import Path
from sqlalchemy import MetaData, text
from sqlalchemy.ext.asyncio import create_async_engine
from .contents import EXCLUDED
from .dump import BackupError, pg_env, read_backup
from .pgdata import async_url as _async_url
logger = logging.getLogger(__name__)
RESTORE_DEADLINE_SECONDS = 30 * 60
@dataclass
class Restored:
manifest: dict
secret_key: str
fixed: int # rows put right between data and constraints
def all_metadata(plugin_dirs: list[Path] | None = None) -> MetaData:
"""Steward's tables, every plugin's included, without an app: the
restore runs before anything else would load them. Plugins are found in
`plugin_dirs` (the bootstrap's, bundled first) and loaded by path under
the same module names the app gives them, so a plugin the app already
loaded isn't mapped twice. Without dirs (tests, a checkout): the
`plugins` package, as the tests import it."""
import importlib
import sys
import steward
import steward.models # noqa: F401 — registers core tables
from steward.core.plugin_manager import _import_plugin, resolve_plugin_path
from steward.models.base import Base
roots = [Path(d).resolve() for d in (plugin_dirs or [Path(steward.__file__).parent.parent / "plugins"])]
by_path = bool(plugin_dirs)
names = sorted({p.name for root in roots if root.is_dir() for p in root.iterdir()
if (p / "__init__.py").exists() and (p / "models.py").exists()})
for name in names:
if f"plugins.{name}.models" in sys.modules or f"_steward_plugin_{name}.models" in sys.modules:
continue # already mapped, under one name or the other
try:
if by_path:
package = _import_plugin(name, resolve_plugin_path(roots, name)).__name__
else:
package = f"plugins.{name}"
importlib.import_module(f"{package}.models")
except Exception: # a plugin that won't load: its tables are left as pg_restore put them
logger.warning("Restore: couldn't load plugin %r's tables", name, exc_info=True)
return Base.metadata
def dangling_fixes(metadata: MetaData) -> list[str]:
"""SQL putting right the rows of kept tables that point at a table whose
rows were left out, by each foreign key's own ON DELETE."""
out: list[str] = []
for table in sorted(metadata.tables.values(), key=lambda t: t.name):
if table.name in EXCLUDED:
continue
for fk in sorted(table.foreign_keys, key=lambda f: f.parent.name):
parent = fk.column.table.name
if parent not in EXCLUDED:
continue
child, col, pcol = table.name, fk.parent.name, fk.column.name
gone = (f'"{col}" IS NOT NULL AND NOT EXISTS '
f'(SELECT 1 FROM "{parent}" p WHERE p."{pcol}" = "{child}"."{col}")')
if (fk.ondelete or "").upper() == "SET NULL":
out.append(f'UPDATE "{child}" SET "{col}" = NULL WHERE {gone}')
else:
out.append(f'DELETE FROM "{child}" WHERE {gone}')
return out
# pg_restore 17 opens every script with this; a Postgres 16 server doesn't
# know the setting and refuses it, so the line is dropped on the way through.
_NEWER_THAN_SERVER = {b"SET transaction_timeout = 0;"}
# A COPY row is one line of the script; this bounds the longest one read.
_LINE_LIMIT = 256 * 1024 * 1024
async def _pg_restore(database_url: str, dump: Path, section: str) -> None:
"""One section of the dump into the database: pg_restore writes it as a
script, psql runs it and stops at the first error."""
env = pg_env(database_url)
try:
script = await asyncio.create_subprocess_exec(
"pg_restore", "--no-owner", "--no-privileges", f"--section={section}", "--file=-", str(dump),
env=env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, limit=_LINE_LIMIT)
psql = await asyncio.create_subprocess_exec(
"psql", "-X", "-q", "-v", "ON_ERROR_STOP=1",
env=env, stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE)
except FileNotFoundError:
raise BackupError("pg_restore or psql isn't installed here.") from None
async def pump() -> None:
try:
while line := await script.stdout.readline():
if line.strip() in _NEWER_THAN_SERVER:
continue
psql.stdin.write(line)
await psql.stdin.drain()
except (BrokenPipeError, ConnectionResetError):
# psql stopped on an error (its stderr says which); pg_restore
# would block on a pipe nobody reads.
script.kill()
finally:
psql.stdin.close()
async def run() -> tuple[bytes, bytes]:
_, script_err, psql_err = await asyncio.gather(
pump(), script.stderr.read(), psql.stderr.read())
await asyncio.gather(script.wait(), psql.wait())
return script_err, psql_err
try:
script_err, psql_err = await asyncio.wait_for(run(), RESTORE_DEADLINE_SECONDS)
except TimeoutError:
for proc in (script, psql):
if proc.returncode is None:
proc.kill()
await proc.wait()
raise BackupError(f"Restoring {section} didn't finish within "
f"{RESTORE_DEADLINE_SECONDS // 60} minutes.") from None
for proc, err, name in ((psql, psql_err, "psql"), (script, script_err, "pg_restore")):
if proc.returncode != 0:
lines = [ln for ln in err.decode(errors="replace").splitlines() if ln.strip()]
raise BackupError(f"Restoring {section}: " + (lines[-1] if lines else f"{name} exited {proc.returncode}."))
async def _tables(database_url: str) -> list[str]:
engine = create_async_engine(_async_url(database_url))
try:
async with engine.connect() as conn:
return list((await conn.execute(text(
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"))).scalars())
finally:
await engine.dispose()
async def _execute(database_url: str, statements: list[str]) -> int:
engine = create_async_engine(_async_url(database_url))
changed = 0
try:
async with engine.begin() as conn:
for sql in statements:
result = await conn.execute(text(sql))
changed += max(result.rowcount or 0, 0)
finally:
await engine.dispose()
return changed
async def restore(path: Path, database_url: str, *, wipe: bool = False,
metadata: MetaData | None = None, plugin_dirs: list[Path] | None = None) -> Restored:
"""Restore a backup file into the database at `database_url`. It must be
empty unless `wipe`, which drops everything in its public schema first."""
existing = await _tables(database_url)
if existing and not wipe:
raise BackupError(f"The database already has {len(existing)} tables "
f"({', '.join(existing[:3])}{'…' if len(existing) > 3 else ''}). "
"Restore into an empty database, or pass --wipe to replace what is there.")
if existing:
logger.warning("Restore: dropping %d tables in the public schema first", len(existing))
await _execute(database_url, ["DROP SCHEMA public CASCADE", "CREATE SCHEMA public"])
with tempfile.TemporaryDirectory() as tmp:
info, dump, key = await asyncio.to_thread(read_backup, path, Path(tmp))
await _pg_restore(database_url, dump, "pre-data")
await _pg_restore(database_url, dump, "data")
fixed = await _execute(database_url, dangling_fixes(metadata or all_metadata(plugin_dirs)))
await _pg_restore(database_url, dump, "post-data")
return Restored(info, key, fixed)
# ── In place, on a running Steward (#5815) ───────────────────────────────────
# A live restore replaces what the backup carries and keeps everything else:
# the audit log, alert history and metrics stay. It runs in one transaction,
# so a failure anywhere leaves the database as it was:
# 1. keep what Steward records as it runs (contents.ACTIVITY on rows that
# come back, contents.STATE_SETTINGS) aside in temporary tables;
# 2. drop every foreign key touching a replaced table;
# 3. empty the replaced tables and COPY the backup's rows in, unparsed;
# 4. put back what was kept aside, and move each sequence past its rows;
# 5. put right the rows whose parent didn't come back, by their foreign
# key: CASCADE removes the row, SET NULL clears the link, and a RESTRICT
# or NO ACTION link is cleared when it may be empty (a run whose user is
# gone keeps its record) and removed when it may not;
# 6. add the foreign keys back, which checks every row.
# Steward's database user owns its tables, so none of this needs a superuser.
class NotSameSchema(BackupError):
"""The backup was made at another schema: in place won't do."""
def __init__(self, schema: str, backup_heads: list[str], here_heads: list[str]):
self.schema = schema
if schema == "newer":
said = "It was made by a newer Steward than this one; update Steward first."
else:
said = ("It was made by an older Steward, so it can't go back in place; restoring it "
"replaces everything, history included.")
super().__init__(said)
self.backup_heads, self.here_heads = backup_heads, here_heads
@dataclass
class RestoredInPlace:
manifest: dict
secret_key: str
tables: list[str]
rows: int
fixed: int
@dataclass
class _ForeignKey:
name: str
child: str
parent: str
columns: list[str]
parent_columns: list[str]
on_delete: str # pg_constraint.confdeltype: c, n, a, r, d
nullable: bool
definition: str
def _q(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
def fk_fix(fk: _ForeignKey) -> str | None:
"""The statement putting right `fk`'s rows whose parent is gone; None for
a key over several columns (re-adding it still checks every row)."""
if len(fk.columns) != 1:
return None
col, pcol = _q(fk.columns[0]), _q(fk.parent_columns[0])
gone = (f"x.{col} IS NOT NULL AND NOT EXISTS "
f"(SELECT 1 FROM public.{_q(fk.parent)} p WHERE p.{pcol} = x.{col})")
if fk.on_delete == "n" or (fk.on_delete in ("a", "r") and fk.nullable):
return f"UPDATE public.{_q(fk.child)} x SET {col} = NULL WHERE {gone}"
return f"DELETE FROM public.{_q(fk.child)} x WHERE {gone}"
async def _layout(conn, tables: list[str]):
"""What the transaction needs to know first: the foreign keys touching
`tables`, each table's primary key and columns, and its sequences."""
fks = []
for row in (await conn.execute(text(
"SELECT con.conname, cl.relname, ref.relname, con.confdeltype, "
"pg_get_constraintdef(con.oid), "
"ARRAY(SELECT a.attname FROM unnest(con.conkey) WITH ORDINALITY k(n, i) "
" JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.n ORDER BY k.i), "
"ARRAY(SELECT a.attname FROM unnest(con.confkey) WITH ORDINALITY k(n, i) "
" JOIN pg_attribute a ON a.attrelid = con.confrelid AND a.attnum = k.n ORDER BY k.i), "
"NOT bool_or_notnull.v "
"FROM pg_constraint con "
"JOIN pg_class cl ON cl.oid = con.conrelid "
"JOIN pg_class ref ON ref.oid = con.confrelid "
"JOIN pg_namespace ns ON ns.oid = cl.relnamespace "
"CROSS JOIN LATERAL (SELECT bool_or(a.attnotnull) AS v FROM pg_attribute a "
" WHERE a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)) bool_or_notnull "
"WHERE con.contype = 'f' AND ns.nspname = 'public' "
"AND (cl.relname = ANY(:t) OR ref.relname = ANY(:t)) ORDER BY con.conname"),
{"t": tables})).all():
fks.append(_ForeignKey(row[0], row[1], row[2], list(row[5]), list(row[6]), row[3],
bool(row[7]), row[4]))
keys: dict[str, list[str]] = {}
for t, c in (await conn.execute(text(
"SELECT cl.relname, a.attname FROM pg_index i "
"JOIN pg_class cl ON cl.oid = i.indrelid "
"JOIN pg_namespace ns ON ns.oid = cl.relnamespace "
"JOIN pg_attribute a ON a.attrelid = cl.oid AND a.attnum = ANY(i.indkey) "
"WHERE i.indisprimary AND ns.nspname = 'public' AND cl.relname = ANY(:t) "
"ORDER BY cl.relname, a.attnum"), {"t": tables})).all():
keys.setdefault(t, []).append(c)
columns: dict[str, list[str]] = {}
sequences: list[tuple[str, str, str]] = []
for t, c, default in (await conn.execute(text(
"SELECT table_name, column_name, column_default FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = ANY(:t) "
"ORDER BY table_name, ordinal_position"), {"t": tables})).all():
columns.setdefault(t, []).append(c)
if default and default.startswith("nextval("):
seq = (await conn.execute(text("SELECT pg_get_serial_sequence(:t, :c)"),
{"t": f"public.{_q(t)}", "c": c})).scalar()
if seq:
sequences.append((t, c, seq))
return fks, keys, columns, sequences
async def restore_in_place(path: Path, database_url: str, *,
plugin_dirs: list[Path] | None = None) -> RestoredInPlace:
"""Put the backup at `path` back into the running Steward's database,
replacing the tables it carries and keeping the rest (see above). Raises
NotSameSchema for a backup made at another schema, and BackupError for
one that can't be read or doesn't fit; the database is then untouched."""
from .check import known_revisions, schema_of
from .contents import ACTIVITY, STATE_SETTINGS
from .pgdata import restore_script, script_blocks
with tempfile.TemporaryDirectory() as tmp:
info, dump, key = await asyncio.to_thread(read_backup, path, Path(tmp))
blocks = script_blocks(await restore_script(dump))
engine = create_async_engine(_async_url(database_url))
try:
async with engine.connect() as conn:
heads = sorted((await conn.execute(text("SELECT version_num FROM alembic_version"))).scalars())
backup_heads = sorted(info.get("migration_heads") or [])
schema = schema_of(backup_heads, heads, known_revisions(plugin_dirs))
if schema != "same":
raise NotSameSchema(schema, backup_heads, heads)
present = set((await conn.execute(text(
"SELECT tablename FROM pg_tables WHERE schemaname = 'public'"))).scalars())
tables = sorted(t for t in blocks if t not in EXCLUDED and t in present)
fks, keys, columns, sequences = await _layout(conn, tables)
for t in tables:
extra = set(blocks[t][0]) - set(columns.get(t, []))
if extra:
raise BackupError(f"The backup's {t} has columns this Steward lacks: "
f"{', '.join(sorted(extra))}.")
rows = sum(raw.count(b"\n") for _, raw in (blocks[t] for t in tables))
try:
async with engine.begin() as conn:
# 1. What Steward records as it runs, kept aside.
if "app_settings" in tables:
await conn.execute(text(
"CREATE TEMP TABLE _keep_settings ON COMMIT DROP AS "
"SELECT * FROM public.app_settings WHERE key = ANY(:k)"),
{"k": sorted(STATE_SETTINGS)})
kept_aside: dict[str, list[str]] = {}
for t in tables:
act = [c for c in columns.get(t, []) if c in ACTIVITY.get(t, ())]
if act and keys.get(t):
kept_aside[t] = act
cols = ", ".join(_q(c) for c in keys[t] + act)
await conn.execute(text(
f"CREATE TEMP TABLE {_q('_keep_' + t)} ON COMMIT DROP AS "
f"SELECT {cols} FROM public.{_q(t)}"))
# 2. The foreign keys touching what is replaced.
for fk in fks:
await conn.execute(text(
f"ALTER TABLE public.{_q(fk.child)} DROP CONSTRAINT {_q(fk.name)}"))
# 3. The backup's rows, as the dump holds them.
await conn.execute(text("TRUNCATE " + ", ".join(f"public.{_q(t)}" for t in tables)))
raw = (await conn.get_raw_connection()).driver_connection
for t in tables:
cols, data = blocks[t]
if data:
await raw.copy_to_table(t, schema_name="public", source=io.BytesIO(data),
columns=cols, format="text")
# 4. Put back what was kept aside; sequences past their rows.
if "app_settings" in tables:
await conn.execute(text("DELETE FROM public.app_settings WHERE key = ANY(:k)"),
{"k": sorted(STATE_SETTINGS)})
await conn.execute(text("INSERT INTO public.app_settings SELECT * FROM _keep_settings"))
for t, act in kept_aside.items():
sets = ", ".join(f"{_q(c)} = k.{_q(c)}" for c in act)
match = " AND ".join(f"x.{_q(c)} = k.{_q(c)}" for c in keys[t])
await conn.execute(text(
f"UPDATE public.{_q(t)} x SET {sets} FROM {_q('_keep_' + t)} k WHERE {match}"))
for t, c, seq in sequences:
# `seq` is pg_get_serial_sequence's own answer, already quoted.
literal = "'" + seq.replace("'", "''") + "'"
await conn.execute(text(
f"SELECT setval({literal}, GREATEST(COALESCE((SELECT max({_q(c)}) FROM public.{_q(t)}), 0), "
f"(SELECT last_value FROM {seq}), 1))"))
# 5. Rows whose parent didn't come back. A removal can leave
# another row without its parent, so until nothing moves.
fixes = [sql for sql in (fk_fix(fk) for fk in fks) if sql]
fixed = 0
for _ in range(5):
moved = 0
for sql in fixes:
moved += max((await conn.execute(text(sql))).rowcount or 0, 0)
fixed += moved
if not moved:
break
# 6. The keys back on, each row checked.
for fk in fks:
await conn.execute(text(
f"ALTER TABLE public.{_q(fk.child)} ADD CONSTRAINT {_q(fk.name)} {fk.definition}"))
except BackupError:
raise
except Exception as exc:
said = getattr(exc, "orig", None) or exc
raise BackupError(f"The restore was undone, nothing changed: {said}") from exc
finally:
await engine.dispose()
logger.warning("Restore in place: replaced %d tables (%d rows) from %s; %d rows put right",
len(tables), rows, path.name, fixed)
return RestoredInPlace(info, key, tables, rows, fixed)