Files
bvandeusenandClaude Opus 5.5 b875160c40
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m14s
CI / unit (push) Failing after 1m28s
CI / publish (push) Skipped
feat(backup): steward backup and steward restore; CI restores a dump and boots on it (#5672, #5674)
The backup file (#5672 "Backup file: pg_dump of config + audit + alert
history, the secret key and a manifest"): steward-<UTC>.tar holding a
pg_dump --format=custom with metrics', check results' and Docker/Traefik/
UniFi rows left out, the secret key, and a manifest. Every table is named
KEPT or EXCLUDED in steward/backup/contents.py, and a guard test fails on a
new one. The image gains postgresql-client-17 (approved, System 180).

The restore (#5674 "Restore: `steward restore` and a CI test that a dump
brings Steward back"): pg_restore in its three sections. Between data and
constraints, a kept row pointing at a left-out row gets what its foreign
key's ON DELETE says (alert_events.ansible_run_id is cleared). It refuses
a non-empty database unless --wipe, and puts the backup's key in
/data/secret.key. Migrations run on Steward's next start.

`steward` stays the server command: it is now a click group that serves
when given no subcommand.

The publish smoke step backs up the booted image's database, restores it
into a second one, checks a marker setting came back and plugin_metrics
is empty, and starts Steward on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:14:04 -04:00

148 lines
5.7 KiB
Python

"""Making one backup file: steward-<UTC time>.tar holding
steward.dump pg_dump --format=custom; excluded tables' rows left out
secret.key the key Steward's stored secrets are sealed with
manifest.json what this is: Steward version, migration heads, time,
the tables whose rows were left out
The secret key is in the file by the operator's choice (System 180), so a
restore is complete on its own: treat every copy as sensitive.
"""
from __future__ import annotations
import asyncio
import io
import json
import os
import tarfile
import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy.engine import make_url
from .contents import EXCLUDED
FORMAT = 1
DUMP_NAME = "steward.dump"
KEY_NAME = "secret.key"
MANIFEST_NAME = "manifest.json"
# A dump past this is given up: Steward's configuration is megabytes.
DUMP_DEADLINE_SECONDS = 15 * 60
class BackupError(Exception):
"""Why a backup couldn't be made, said to the person."""
@dataclass
class Made:
path: Path
name: str
size: int
manifest: dict
def file_name(at: datetime) -> str:
return f"steward-{at.astimezone(timezone.utc):%Y%m%dT%H%M%SZ}.tar"
def pg_env(database_url: str) -> dict[str, str]:
"""libpq's environment for the app's database URL, so the password is
never on a command line."""
url = make_url(database_url)
env = {k: v for k, v in os.environ.items() if not k.startswith("PG")}
pairs = {"PGHOST": url.host, "PGPORT": url.port, "PGUSER": url.username,
"PGPASSWORD": url.password, "PGDATABASE": url.database}
env.update({k: str(v) for k, v in pairs.items() if v not in (None, "")})
ssl = url.query.get("ssl") or url.query.get("sslmode")
if isinstance(ssl, str) and ssl:
env["PGSSLMODE"] = {"true": "require", "false": "disable"}.get(ssl.lower(), ssl)
return env
def dump_command(path: Path) -> list[str]:
args = ["pg_dump", "--format=custom", "--no-owner", "--no-privileges", f"--file={path}"]
args += [f"--exclude-table-data={t}" for t in sorted(EXCLUDED)]
return args
async def run_dump(database_url: str, path: Path) -> None:
try:
proc = await asyncio.create_subprocess_exec(
*dump_command(path), env=pg_env(database_url),
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE)
except FileNotFoundError:
raise BackupError("pg_dump isn't installed in this image.") from None
try:
_, err = await asyncio.wait_for(proc.communicate(), DUMP_DEADLINE_SECONDS)
except TimeoutError:
proc.kill()
await proc.wait()
raise BackupError(f"pg_dump didn't finish within {DUMP_DEADLINE_SECONDS // 60} minutes.") from None
if proc.returncode != 0:
lines = [ln for ln in err.decode(errors="replace").splitlines() if ln.strip()]
raise BackupError(lines[-1] if lines else f"pg_dump exited {proc.returncode}.")
def manifest(at: datetime, heads: list[str]) -> dict:
from steward import __version__
return {"format": FORMAT, "steward_version": __version__, "created_at": at.isoformat(),
"migration_heads": sorted(heads), "rows_left_out": sorted(EXCLUDED),
"contains_secret_key": True}
def pack(out: Path, dump: Path, secret_key: str, info: dict) -> None:
"""The tar, written next to `out` and renamed into place."""
part = out.with_name(out.name + ".part")
with tarfile.open(part, "w") as tar:
tar.add(dump, arcname=DUMP_NAME)
for name, data in ((KEY_NAME, secret_key.encode()),
(MANIFEST_NAME, json.dumps(info, indent=2).encode())):
member = tarfile.TarInfo(name)
member.size = len(data)
member.mode = 0o600
member.mtime = int(datetime.now(timezone.utc).timestamp())
tar.addfile(member, io.BytesIO(data))
part.chmod(0o600)
part.replace(out)
async def make_backup(database_url: str, secret_key: str, heads: list[str], workdir: Path,
now: datetime | None = None) -> Made:
"""Dump the database and pack the backup file into `workdir`."""
at = now or datetime.now(timezone.utc)
if not secret_key:
raise BackupError("Steward has no secret key to put in the backup.")
out = workdir / file_name(at)
with tempfile.TemporaryDirectory(dir=workdir) as tmp:
dump = Path(tmp) / DUMP_NAME
await run_dump(database_url, dump)
info = manifest(at, heads)
await asyncio.to_thread(pack, out, dump, secret_key, info)
return Made(out, out.name, out.stat().st_size, info)
def read_backup(path: Path, into: Path) -> tuple[dict, Path, str]:
"""Unpack a backup file into `into`: (manifest, dump path, secret key).
Refuses anything that isn't one."""
try:
tar = tarfile.open(path, "r")
except (tarfile.TarError, OSError) as exc:
raise BackupError(f"{path} isn't a Steward backup: {exc}.") from None
with tar:
names = set(tar.getnames())
if not {DUMP_NAME, KEY_NAME, MANIFEST_NAME} <= names:
raise BackupError(f"{path} isn't a Steward backup: it lacks "
f"{', '.join(sorted({DUMP_NAME, KEY_NAME, MANIFEST_NAME} - names))}.")
info = json.loads(tar.extractfile(MANIFEST_NAME).read())
if info.get("format") != FORMAT:
raise BackupError(f"{path} is backup format {info.get('format')}; this Steward reads {FORMAT}.")
key = tar.extractfile(KEY_NAME).read().decode().strip()
dump = into / DUMP_NAME
with tar.extractfile(DUMP_NAME) as src, open(dump, "wb") as dst:
while chunk := src.read(1 << 20):
dst.write(chunk)
return info, dump, key