Files
FabledSteward/steward/cli.py
T
bvandeusenandClaude Opus 5.5 fb99f8b551
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m27s
CI / unit (push) Failing after 1m37s
CI / publish (push) Skipped
feat(backup): restore from Settings → Backups, with restoring mode (#5816, #5817)
#5816 "Restoring mode": while a backup goes back in, the scheduler holds,
ingest and HTMX answer 503 with Retry-After (agents keep their samples),
and every page says what is happening. A safety backup of Steward as it
stands is made first, to /data/backups and the bucket. The backup's key
is put in place, the restore audited, and Steward exits with code 3 so
Swarm, compose or systemd starts it fresh. A failed in-place restore
changes nothing and says why on Backups; a full replace that fails after
the wipe restarts into first-run setup.

#5817 "Settings → Backups: Restore": a Restore button on each sent backup
and on the file card. The chosen backup is checked (the same comparison
as Check, now a shared partial) and held server-side for an hour under a
token; it starts only once the admin types "restore", and an older
backup also needs "Replace everything" ticked. A newer backup, or one
whose key can't open its secrets, gets no button.

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

159 lines
7.0 KiB
Python

import asyncio
import logging
import signal
from pathlib import Path
import click
from .core.logging_setup import configure_logging
# How long a stop waits for requests in flight (and SSE streams) to finish.
GRACEFUL_TIMEOUT_SECONDS = 10.0
def hypercorn_config(host: str, port: int):
"""Hypercorn as the production server (#5159): one worker, because Steward
keeps run, stream and scheduler state in-process; its access and error
lines go through Steward's logging, so the log.level setting governs them."""
from hypercorn.config import Config
config = Config()
config.bind = [f"{host}:{port}"]
config.workers = 1
config.graceful_timeout = GRACEFUL_TIMEOUT_SECONDS
config.accesslog = logging.getLogger("hypercorn.access")
config.errorlog = logging.getLogger("hypercorn.error")
config.access_log_format = "%(h)s %(r)s %(s)s %(b)s %(D)s"
return config
async def _serve(app, config) -> None:
from hypercorn.asyncio import serve
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, stop.set)
await serve(app, config, shutdown_trigger=stop.wait)
@click.group(invoke_without_command=True)
@click.option("--config", default="config.yaml", help="Path to config.yaml")
@click.option("--host", default="0.0.0.0")
@click.option("--port", default=5000, type=int)
@click.option("--debug", is_flag=True, default=False,
help="Quart's development server with the reloader, not Hypercorn.")
@click.pass_context
def main(ctx: click.Context, config: str, host: str, port: int, debug: bool) -> None:
"""Start the Steward server, or run one of the commands below."""
# Before anything logs; create_app applies the log.level setting once
# settings load.
configure_logging()
ctx.obj = {"config": config}
if ctx.invoked_subcommand is not None:
return
from .app import create_app
app = create_app(config_path=Path(config))
if debug:
app.run(host=host, port=port, debug=True)
return
asyncio.run(_serve(app, hypercorn_config(host, port)))
from .backup.restoring import RESTART_EXIT_CODE, STATE as restoring
if restoring.restart_requested:
# A restore finished: exit as failed so every supervisor starts
# Steward again (systemd's Restart=on-failure ignores a clean exit).
raise SystemExit(RESTART_EXIT_CODE)
@main.command("backup")
@click.option("--to", "out_dir", default=".", type=click.Path(file_okay=False, path_type=Path),
help="The folder to write the backup file into.")
@click.pass_context
def backup_command(ctx: click.Context, out_dir: Path) -> None:
"""Write a backup file of this Steward (settings and the secret key: what
recovery needs, no logs or history) into a folder, without sending it anywhere."""
from .backup.dump import BackupError, make_backup
from .backup.job import migration_heads
from .config import load_bootstrap
boot = load_bootstrap(Path(ctx.obj["config"]))
out_dir.mkdir(parents=True, exist_ok=True)
try:
heads = asyncio.run(migration_heads(boot["database_url"]))
made = asyncio.run(make_backup(boot["database_url"], boot["secret_key"], heads, out_dir))
except BackupError as exc:
raise click.ClickException(str(exc)) from None
click.echo(f"Wrote {made.path} ({made.size} bytes). It holds the secret key: keep it safe.")
@main.command("check-backup")
@click.argument("backup_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.pass_context
def check_backup_command(ctx: click.Context, backup_file: Path) -> None:
"""Compare a backup file with this Steward's database, changing nothing:
what a restore would bring back, area by area. Exits 1 when the backup
can't be read or its key doesn't open its secrets."""
from .backup.check import check_backup
from .backup.dump import BackupError
from .config import load_bootstrap
boot = load_bootstrap(Path(ctx.obj["config"]))
try:
report = asyncio.run(check_backup(backup_file, boot["database_url"], boot["secret_key"],
plugin_dirs=[Path(d) for d in boot["plugin_dirs"]]))
except BackupError as exc:
raise click.ClickException(str(exc)) from None
click.echo(report.summary())
click.echo(f"Made {report.manifest.get('created_at', '?')} by Steward "
f"{report.manifest.get('steward_version', '?')}; schema: {report.schema}; "
f"secret key: {'the one in use' if report.key_same else 'a different one'}.")
for area in report.areas:
for t in area.tables:
for d in t.differ:
click.echo(f" {area.name}: {d.label} differs ({', '.join(d.columns)})")
for label in t.only_backup:
click.echo(f" {area.name}: {label} is only in the backup")
for label in t.only_here:
click.echo(f" {area.name}: {label} is only here")
if t.note:
click.echo(f" {area.name} ({t.table}): {t.note}")
if not report.ok:
raise SystemExit(1)
@main.command("restore")
@click.argument("backup_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--wipe", is_flag=True, default=False,
help="Drop everything in the database first. Without it the database must be empty.")
@click.pass_context
def restore_command(ctx: click.Context, backup_file: Path, wipe: bool) -> None:
"""Bring Steward back from a backup file into its (empty) database, and
put the backup's secret key in place. Start Steward afterwards: it runs
any migrations the backup is behind on."""
from .backup.dump import BackupError
from .backup.restore import restore
from .config import _SECRET_KEY_FILE, _env, load_bootstrap
boot = load_bootstrap(Path(ctx.obj["config"]))
try:
done = asyncio.run(restore(backup_file, boot["database_url"], wipe=wipe,
plugin_dirs=[Path(d) for d in boot["plugin_dirs"]]))
except BackupError as exc:
raise click.ClickException(str(exc)) from None
made = done.manifest.get("created_at", "?")
click.echo(f"Restored the backup made {made} (Steward {done.manifest.get('steward_version', '?')}).")
if done.fixed:
click.echo(f"Cleared {done.fixed} references to rows a backup leaves out (e.g. Ansible runs).")
env_key = _env("SECRET_KEY")
if env_key:
if env_key != done.secret_key:
click.echo("STEWARD_SECRET_KEY is set and differs from the backup's key: set it to the "
"backup's key, or the restored passwords and keys can't be read.", err=True)
raise SystemExit(2)
else:
_SECRET_KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
_SECRET_KEY_FILE.write_text(done.secret_key)
_SECRET_KEY_FILE.chmod(0o600)
click.echo(f"Wrote the backup's secret key to {_SECRET_KEY_FILE}.")
click.echo("Start Steward now; it brings the database up to date on start.")