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.option("--in-place", "in_place", is_flag=True, default=False, help="Replace what the backup carries in a database of the same schema, keeping " "the audit log and history (what Settings → Backups does).") @click.pass_context def restore_command(ctx: click.Context, backup_file: Path, wipe: bool, in_place: 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. With --in-place, the database keeps its history and only what the backup carries is replaced.""" from .backup.dump import BackupError from .backup.restore import restore, restore_in_place from .config import _SECRET_KEY_FILE, _env, load_bootstrap if wipe and in_place: raise click.UsageError("--wipe and --in-place don't go together.") boot = load_bootstrap(Path(ctx.obj["config"])) plugin_dirs = [Path(d) for d in boot["plugin_dirs"]] try: if in_place: done = asyncio.run(restore_in_place(backup_file, boot["database_url"], plugin_dirs=plugin_dirs)) else: done = asyncio.run(restore(backup_file, boot["database_url"], wipe=wipe, plugin_dirs=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 in_place: click.echo(f"Replaced {len(done.tables)} tables ({done.rows} rows); the audit log and history stayed.") if done.fixed: click.echo(f"Put right {done.fixed} rows that pointed at something the backup doesn't have.") elif 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("Restart Steward now so it loads them." if in_place else "Start Steward now; it brings the database up to date on start.")