feat(backup): restore from Settings → Backups, with restoring mode (#5816, #5817)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m27s
CI / unit (push) Failing after 1m37s
CI / publish (push) Skipped

#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>
This commit is contained in:
2026-10-15 11:03:01 -04:00
co-authored by Claude Opus 5.5
parent 9e5f6e183a
commit fb99f8b551
14 changed files with 858 additions and 72 deletions
+6 -1
View File
@@ -175,6 +175,10 @@ def create_app(
app.jinja_env.globals["csrf_field"] = _csrf.csrf_field
# The sign-in page offers "Forgot your password?" only while mail can send (milestone 497).
app.jinja_env.globals["reset_by_email"] = lambda: bool(app.config.get("SMTP", {}).get("host"))
# While a backup is being restored nothing else reads or writes the
# database: a page says so, and agents get a 503 to retry (#5816).
from .backup import restoring as _restoring
app.before_request(_restoring.gate)
app.before_request(_csrf.check_request)
# The session cookie is Secure, and HSTS is sent, only on requests that
# arrived over HTTPS — directly or as a trusted proxy reports it
@@ -595,5 +599,6 @@ async def _mark_interrupted_runs(app: Quart) -> None:
async def _run_scheduler(app: Quart) -> None:
from .backup.restoring import is_restoring
from .core.scheduler import start_scheduler
await start_scheduler(app._task_registry)
await start_scheduler(app._task_registry, paused=is_restoring)
+2
View File
@@ -200,6 +200,8 @@ ACTIONS = {
"prometheus.template_deleted": "deleted Prometheus widget template",
"backup.made": "sent a backup",
"backup.failed": "tried to send a backup, which failed:",
"backup.restored": "restored Steward from a backup",
"backup.restore_failed": "tried to restore Steward from a backup, which failed:",
}
# The filter's choices: an area, matched as a prefix of the action code.
+1
View File
@@ -52,6 +52,7 @@ def rotate() -> None:
# a backup file (settings only is small; an older one carries the audit log).
UPLOAD_LIMITS: dict[str, int] = {
"/settings/backups/check/file/": 512 * 1024 * 1024,
"/settings/backups/restore/file/": 512 * 1024 * 1024,
}
+272
View File
@@ -0,0 +1,272 @@
"""Restoring mode (#5816): what a running Steward does while a backup goes back
in, from Settings → Backups.
1. Read the backup and refuse it up front if it can't go in: not a backup,
or a different secret key while STEWARD_SECRET_KEY pins this one.
2. Take a safety backup of Steward as it stands: kept on /data/backups and
sent to the bucket when one is set. A restore can then be undone the same
way. Without it nothing goes further.
3. While it runs, Steward is restoring: the scheduler holds its ticks, agent
ingest answers 503 (agents keep their samples and send them again), and
every page says what is happening.
4. In place for a backup of this schema (restore.restore_in_place), or a
full replace the person agreed to for an older one.
5. The backup's key is put in place, the restore audited, and the process
exits with RESTART_EXIT_CODE. Swarm, compose or systemd starts it again,
and settings, the key and plugins load fresh. The person who restored
signs in again: users came back from the backup.
A restore that fails in place has changed nothing; Steward carries on and
says why. A full replace that fails part-way leaves an empty database, so
Steward restarts into first-run setup, where the safety backup can be
uploaded.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import secrets
import shutil
import signal
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
logger = logging.getLogger(__name__)
SAFETY_DIR = Path("/data/backups")
# Safety backups kept on /data, newest first; older ones are on the bucket.
SAFETY_KEEP = 5
# Not 0: systemd's Restart=on-failure restarts only a failed exit.
RESTART_EXIT_CODE = 3
# Long enough for the restoring page's next poll to say it is done.
EXIT_DELAY_SECONDS = 5
# A backup chosen for restore waits here between its check and the
# confirmation (#5817); one not confirmed within the hour is removed.
STAGED_SECONDS = 3600
@dataclass
class Restoring:
active: bool = False
name: str = ""
by: str = ""
since: datetime | None = None
step: str = ""
error: str = ""
restart_requested: bool = False
STATE = Restoring()
_tasks: set[asyncio.Task] = set()
def is_restoring() -> bool:
return STATE.active
def start_restore(app, path: Path, name: str, *, username: str, replace_all: bool = False,
owned_dir: Path | None = None) -> bool:
"""Start restoring `path` in the background; False if a restore is already
under way. `owned_dir`, when given, is removed once it is over."""
if STATE.active or STATE.restart_requested:
return False
STATE.active, STATE.name, STATE.by = True, name, username
STATE.since, STATE.step, STATE.error = datetime.now(timezone.utc), "Reading the backup", ""
task = asyncio.get_running_loop().create_task(
_run(app, path, name, username=username, replace_all=replace_all, owned_dir=owned_dir))
_tasks.add(task)
task.add_done_callback(_tasks.discard)
return True
async def _run(app, path: Path, name: str, *, username: str, replace_all: bool,
owned_dir: Path | None) -> None:
from steward.core.audit import log_audit
from .dump import BackupError
from .s3 import S3Error
wiped = False
try:
key = await asyncio.to_thread(_backup_key, path)
pinned = os.environ.get("STEWARD_SECRET_KEY")
if pinned and pinned != key:
raise BackupError("STEWARD_SECRET_KEY is set and differs from the backup's key; "
"set it to the backup's key first, or its passwords and keys can't be read.")
STATE.step = "Saving a safety backup of Steward as it stands"
safety = await _safety_backup(app)
STATE.step = "Restoring"
plugin_dirs = [Path(d) for d in app.config["PLUGIN_DIRS"]]
if replace_all:
from .restore import restore
wiped = True
done = await restore(path, app.config["DATABASE_URL"], wipe=True, plugin_dirs=plugin_dirs)
detail = {"mode": "replaced everything"}
else:
from .restore import restore_in_place
done = await restore_in_place(path, app.config["DATABASE_URL"], plugin_dirs=plugin_dirs)
detail = {"mode": "in place", "tables": len(done.tables), "rows": done.rows,
"put_right": done.fixed}
if done.secret_key != app.config["SECRET_KEY"] and not pinned:
_write_key(done.secret_key)
await log_audit(app, None, username, "backup.restored", entity_type="backup",
entity_id=name, detail=detail | {"safety_backup": safety})
logger.warning("Restored %s (%s); restarting", name, detail["mode"])
STATE.step = "Restored. Steward is restarting; sign in again when it is back"
_restart_soon()
except (BackupError, S3Error, OSError) as exc:
_failed(app, name, username, str(exc), wiped)
except Exception as exc:
logger.exception("Restore of %s failed", name)
_failed(app, name, username, f"Unexpected error: {exc}", wiped)
finally:
if owned_dir is not None:
shutil.rmtree(owned_dir, ignore_errors=True)
def _failed(app, name: str, username: str, error: str, wiped: bool) -> None:
from steward.core.audit import log_audit
logger.error("Restore of %s failed: %s", name, error)
STATE.error = error
if wiped:
# The database was dropped before the restore failed: start again
# into first-run setup, where the safety backup can be uploaded.
STATE.step = ("The restore failed after the database was emptied; Steward is restarting "
"into first-run setup, where the safety backup can be uploaded")
_restart_soon()
return
STATE.active = False
task = asyncio.get_running_loop().create_task(log_audit(
app, None, username, "backup.restore_failed", entity_type="backup", entity_id=name,
detail={"error": error}))
_tasks.add(task)
task.add_done_callback(_tasks.discard)
def _backup_key(path: Path) -> str:
import tarfile
from .dump import KEY_NAME, BackupError
try:
with tarfile.open(path, "r") as tar:
return tar.extractfile(KEY_NAME).read().decode().strip()
except (tarfile.TarError, OSError, KeyError, AttributeError):
raise BackupError(f"{path.name} isn't a Steward backup.") from None
async def _safety_backup(app) -> str:
"""A backup of Steward as it stands, before anything is replaced: on
/data/backups always, and on the bucket when one is set. Its run is in
Recent backups like any other."""
from steward.core.settings import get_all_settings
from .dump import make_backup
from .job import _record, migration_heads, target_from
from .s3 import put_file
SAFETY_DIR.mkdir(parents=True, exist_ok=True)
url = app.config["DATABASE_URL"]
made = await make_backup(url, app.config["SECRET_KEY"], await migration_heads(url), SAFETY_DIR)
async with app.db_sessionmaker() as db:
target = target_from(await get_all_settings(db))
entry = {"at": datetime.now(timezone.utc).isoformat(), "ok": True, "by": "before restore",
"name": made.name, "size": made.size, "secs": 0.0, "error": ""}
if target is not None:
await put_file(target, made.name, made.path)
else:
entry["error"] = "Kept on /data/backups only: no bucket is set up."
await _record(app, entry, None, "")
_prune()
return made.name
def _prune() -> None:
kept = sorted(SAFETY_DIR.glob("steward-*.tar"), reverse=True)
for old in kept[SAFETY_KEEP:]:
old.unlink(missing_ok=True)
def _write_key(key: str) -> None:
from steward.config import _SECRET_KEY_FILE
_SECRET_KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
_SECRET_KEY_FILE.write_text(key)
_SECRET_KEY_FILE.chmod(0o600)
def _restart_soon() -> None:
STATE.restart_requested = True
loop = asyncio.get_running_loop()
loop.call_later(EXIT_DELAY_SECONDS, os.kill, os.getpid(), signal.SIGTERM)
# ── Chosen, checked, waiting for a yes (#5817) ─────────────────────────────
@dataclass
class Staged:
token: str
dir: Path
name: str = ""
schema: str = ""
ok: bool = False
@property
def path(self) -> Path:
return self.dir / "backup.tar"
def save(self) -> None:
(self.dir / "staged.json").write_text(json.dumps(
{"name": self.name, "schema": self.schema, "ok": self.ok}))
def _staging() -> Path:
return SAFETY_DIR / "incoming"
def stage() -> Staged:
"""A fresh place to hold one backup until its restore is confirmed. Old
ones not confirmed are removed first."""
root = _staging()
root.mkdir(parents=True, exist_ok=True)
cutoff = time.time() - STAGED_SECONDS
for old in root.iterdir():
if old.stat().st_mtime < cutoff:
shutil.rmtree(old, ignore_errors=True)
token = secrets.token_hex(16)
(root / token).mkdir(mode=0o700)
return Staged(token, root / token)
def staged(token: str) -> Staged | None:
"""The backup held under `token`, or None when there is none (or it was
removed for its age)."""
if not re.fullmatch(r"[0-9a-f]{32}", token or ""):
return None
d = _staging() / token
try:
meta = json.loads((d / "staged.json").read_text())
except (OSError, ValueError):
return None
if not (d / "backup.tar").is_file():
return None
return Staged(token, d, meta.get("name", ""), meta.get("schema", ""), bool(meta.get("ok")))
# ── While it runs ───────────────────────────────────────────────────────────
async def gate():
"""before_request: while restoring, nothing reads or writes the database.
Agents and scripts get a 503 to retry; a page says what is happening."""
from quart import Response, g, render_template, request
if not (STATE.active or STATE.restart_requested) or request.path.startswith("/static/"):
return None
g.database_down = True # the page's own reads (the sidebar's alert count) wait
headers = {"Retry-After": "30"}
wants_page = "text/html" in request.headers.get("Accept", "")
if request.method != "GET" or request.headers.get("HX-Request") or not wants_page:
return Response("Steward is restoring a backup; try again shortly.", status=503,
headers=headers, content_type="text/plain; charset=utf-8")
page = await render_template("errors/restoring.html", state=STATE)
return Response(page, status=503, headers=headers)
+5
View File
@@ -58,6 +58,11 @@ def main(ctx: click.Context, config: str, host: str, port: int, debug: bool) ->
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")
+7 -4
View File
@@ -60,8 +60,10 @@ class _DueTracker:
self.in_flight.discard(name)
async def start_scheduler(tasks: list[ScheduledTask]) -> None:
"""Run scheduled tasks in a loop. Call with asyncio.create_task()."""
async def start_scheduler(tasks: list[ScheduledTask], paused=None) -> None:
"""Run scheduled tasks in a loop. Call with asyncio.create_task().
`paused`, when given, is asked each tick: while it says so nothing new
starts (a restore is under way, steward/backup/restoring.py)."""
tracker = _DueTracker()
def _spawn(task: ScheduledTask, now: float) -> None:
@@ -77,8 +79,9 @@ async def start_scheduler(tasks: list[ScheduledTask]) -> None:
while True:
now = asyncio.get_event_loop().time()
for task in tracker.due(tasks, now):
_spawn(task, now)
if paused is None or not paused():
for task in tracker.due(tasks, now):
_spawn(task, now)
await asyncio.sleep(1)
+105 -2
View File
@@ -695,7 +695,7 @@ async def send_report_now():
async def _backups_page(flash=None):
"""Settings → Backups (#5673): how the newest backup went, the bucket and
the daily hour (in the reader's zone), and the runs before."""
from steward.backup import job
from steward.backup import job, restoring
from steward.core.formatting import zone
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
@@ -705,7 +705,8 @@ async def _backups_page(flash=None):
"settings/backups.html", settings=settings, flash=flash, shown_hour=hour,
verdict=job.verdict(settings, job.is_running()),
history=job.history_rows(settings.get("backup.history")),
has_secret=bool(settings.get("backup.s3_secret_key")))
has_secret=bool(settings.get("backup.s3_secret_key")),
restore_failed=restoring.STATE if restoring.STATE.error else None)
@settings_bp.get("/backups/")
@@ -824,6 +825,108 @@ async def check_uploaded_backup():
return await _check_page(report=report, name=name)
# Restore (#5817): choose a backup, see what it would change (the same check as
# above), then type to confirm. The chosen file is held server-side between
# the two (restoring.stage), so the confirmation names it by a token.
RESTORE_WORD = "restore"
async def _restore_page(report=None, error: str = "", name: str = "", held=None):
return await render_template("settings/backup_restore.html", report=report, error=error,
name=name, held=held, word=RESTORE_WORD)
async def _hold_and_check(held, name: str):
from steward.backup import job
from steward.backup.dump import BackupError
try:
report = await job.check_file(current_app._get_current_object(), held.path, name)
except BackupError as exc:
import shutil
shutil.rmtree(held.dir, ignore_errors=True)
return await _restore_page(error=str(exc), name=name)
held.name, held.schema, held.ok = name, report.schema, report.ok
held.save()
return await _restore_page(report=report, name=name, held=held)
@settings_bp.post("/backups/restore/")
@require_role(UserRole.admin)
async def restore_stored_backup():
"""Fetch one of the bucket's backups, check it, and ask to confirm."""
import shutil
from steward.backup import job, restoring
from steward.backup.dump import BackupError
from steward.backup.s3 import S3Error, get_file
name = (await request.form).get("name", "")
if not job.is_backup_name(name):
return await _restore_page(error=f"{name!r} isn't the name of a Steward backup.", name=name)
async with current_app.db_sessionmaker() as db:
target = job.target_from(await get_all_settings(db))
if target is None:
return await _restore_page(error="The bucket isn't set up: fill in the endpoint, bucket and keys.",
name=name)
held = restoring.stage()
try:
await get_file(target, name, held.path)
except (BackupError, S3Error) as exc:
shutil.rmtree(held.dir, ignore_errors=True)
error = str(exc)
if "AccessDenied" in error:
error += (" The bucket's key may not read backups back; download the file from the "
"bucket and restore it with Restore a file instead.")
return await _restore_page(error=error, name=name)
return await _hold_and_check(held, name)
@settings_bp.post("/backups/restore/file/")
@require_role(UserRole.admin)
async def restore_uploaded_backup():
"""A backup file sent from the browser, checked and held for the
confirmation. Its size limit is raised in auth/csrf.py UPLOAD_LIMITS."""
from steward.backup import restoring
upload = (await request.files).get("file")
if upload is None or not upload.filename:
return await _restore_page(error="Choose a backup file to restore.")
held = restoring.stage()
await upload.save(held.path)
return await _hold_and_check(held, Path(upload.filename).name)
@settings_bp.post("/backups/restore/start/")
@require_role(UserRole.admin)
async def start_restore():
"""The confirmation: the held backup goes back in (restoring.py). Every
page shows the restore until Steward restarts."""
import shutil
from steward.backup import job, restoring
form = await request.form
held = restoring.staged(form.get("token", ""))
if held is None:
return await _restore_page(error="That backup is no longer held (it is removed an hour after "
"it was checked). Choose it again.")
if held.schema == "newer" or not held.ok:
shutil.rmtree(held.dir, ignore_errors=True)
return await _restore_page(error="This backup can't be restored here; check it again to see why.",
name=held.name)
replace_all = held.schema == "older"
if form.get("confirm", "").strip().lower() != RESTORE_WORD or (
replace_all and "replace_all" not in form):
report = await job.check_file(current_app._get_current_object(), held.path, held.name)
return await _restore_page(report=report, name=held.name, held=held,
error=f"Type {RESTORE_WORD} to confirm"
+ (", and tick Replace everything." if replace_all else "."))
started = restoring.start_restore(current_app._get_current_object(), held.path, held.name,
username=session.get("username", "") or "an admin",
replace_all=replace_all, owned_dir=held.dir)
if not started:
return await _restore_page(error="A restore is already under way.", name=held.name)
return redirect(url_for("settings.backups"))
def _build_plugin_cfg_from_form(plugin: dict, form) -> dict:
"""Extract config values for one plugin from a form submission."""
name = plugin["_dir"]
+18
View File
@@ -0,0 +1,18 @@
{# errors/restoring.html — every page while a backup is being restored (#5816):
what is going back in, by whom, the step it is on. It reloads itself, so
the sign-in page appears once Steward is back. #}
{% extends "base.html" %}
{% block title %}Restoring a backup — Steward{% endblock %}
{% block head %}<meta http-equiv="refresh" content="5">{% endblock %}
{% block content %}
<div style="text-align:center; padding: 4rem 2rem;">
<h1 class="tone-warn" style="font-size: 1.6rem; margin-bottom: 0.75rem;">Steward is restoring a backup</h1>
<p style="margin-bottom: 0.5rem;">{{ state.name }}{% if state.by %}, started by {{ state.by }}{% endif %}
{%- if state.since %} {{ state.since|ago }}{% endif %}.</p>
<p class="tone-dim" style="margin-bottom: 1rem;">{{ state.step }}…</p>
<p class="field-hint" style="max-width: 34rem; margin: 0 auto;">
Nothing else reads or writes Steward's records until it is done. Agents keep their readings
and send them when Steward is back. This page reloads by itself.
</p>
</div>
{% endblock %}
@@ -0,0 +1,61 @@
{# settings/_backup_report.html — what a check found (#5823), shown on the check
page and on the restore confirm page (#5817): the verdict, what the backup
is, each area's count, then each difference. Needs `report`. #}
{% set tone = "tone-crit" if not report.ok else ("tone-ok" if not report.changes else "tone-warn") %}
<p class="page-verdict"><span class="{{ tone }}">{{ report.summary() }}</span></p>
<div class="card" style="max-width:640px;margin-bottom:1.25rem;">
<dl class="kv">
<dt>File</dt><dd>{{ report.name }}</dd>
<dt>Made</dt><dd>{% if report.made_at %}{{ report.made_at|when }}{% else %}—{% endif %}
by Steward {{ report.manifest.steward_version or "—" }}</dd>
<dt>Schema</dt><dd>
{%- if report.schema == "same" %}The same as this Steward's.
{%- elif report.schema == "older" %}<span class="tone-warn">Older than this Steward's.</span> A restore would replace everything, and Steward would bring it up to date on start.
{%- else %}<span class="tone-crit">Newer than this Steward's.</span> This Steward can't restore it; update Steward first.{% endif %}</dd>
<dt>Secret key</dt><dd>
{%- if report.key_same %}The one in use here.{% else %}<span class="tone-warn">A different one.</span> A restore would put it in place, and passwords and keys stored since would need entering again.{% endif %}
{%- if report.sealed %} It opens {{ report.sealed - report.unopened }} of the backup's {{ report.sealed }} sealed value{{ "s" if report.sealed != 1 }}.{% endif %}</dd>
</dl>
</div>
<div class="card-flush" style="margin-bottom:1.25rem;">
<table class="table">
<thead><tr><th>Area</th><th>Same</th><th>Differ</th><th>Only in the backup</th><th>Only here</th></tr></thead>
<tbody>
{% for a in report.areas %}
{% set differ = a.tables|sum(attribute="differ", start=[])|length %}
{% set only_b = a.tables|sum(attribute="only_backup", start=[])|length %}
{% set only_h = a.tables|sum(attribute="only_here", start=[])|length %}
<tr>
<td>{{ a.name }}</td>
<td>{{ a.same }}</td>
<td>{% if differ %}<span class="tone-warn">{{ differ }}</span>{% else %}—{% endif %}</td>
<td>{% if only_b %}<span class="tone-warn">{{ only_b }}</span>{% else %}—{% endif %}</td>
<td>{% if only_h %}<span class="tone-warn">{{ only_h }}</span>{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% for a in report.areas if a.changes or a.tables|selectattr("note")|list %}
<h2 class="section-title" style="margin:1.5rem 0 0.75rem;">{{ a.name }}</h2>
<ul class="check-list">
{% for t in a.tables %}
{% for d in t.differ %}
<li><strong>{{ d.label }}</strong> differs
{%- if d.secret %} (a sealed value; not shown).
{%- elif d.backup_value is not none or d.here_value is not none %}: the backup has <code>{{ d.backup_value if d.backup_value is not none else "nothing" }}</code>, here it is <code>{{ d.here_value if d.here_value is not none else "nothing" }}</code>.
{%- else %} in {{ d.columns|join(", ") }}.{% endif %}</li>
{% endfor %}
{% for label in t.only_backup %}<li><strong>{{ label }}</strong> is only in the backup: a restore brings it back.</li>{% endfor %}
{% for label in t.only_here %}<li><strong>{{ label }}</strong> is only here: a restore removes it.</li>{% endfor %}
{% if t.note %}<li class="tone-dim">{{ t.table }}: {{ t.note }}</li>{% endif %}
{% endfor %}
</ul>
{% endfor %}
<p class="field-hint" style="margin-top:1rem;">Left out of the comparison: what Steward records as it runs
(when an agent or monitor was last seen, a schedule's last run, the backup history), since it changes by the
minute. A restore still brings those back as they were in the backup.</p>
+1 -58
View File
@@ -11,64 +11,7 @@
{% if error %}
<p class="page-verdict"><span class="tone-crit">{{ name or "The backup" }} couldn't be checked. {{ error }}</span></p>
{% else %}
{% set tone = "tone-crit" if not report.ok else ("tone-ok" if not report.changes else "tone-warn") %}
<p class="page-verdict"><span class="{{ tone }}">{{ report.summary() }}</span></p>
<div class="card" style="max-width:640px;margin-bottom:1.25rem;">
<dl class="kv">
<dt>File</dt><dd>{{ report.name }}</dd>
<dt>Made</dt><dd>{% if report.made_at %}{{ report.made_at|when }}{% else %}—{% endif %}
by Steward {{ report.manifest.steward_version or "—" }}</dd>
<dt>Schema</dt><dd>
{%- if report.schema == "same" %}The same as this Steward's.
{%- elif report.schema == "older" %}<span class="tone-warn">Older than this Steward's.</span> A restore would replace everything, and Steward would bring it up to date on start.
{%- else %}<span class="tone-crit">Newer than this Steward's.</span> This Steward can't restore it; update Steward first.{% endif %}</dd>
<dt>Secret key</dt><dd>
{%- if report.key_same %}The one in use here.{% else %}<span class="tone-warn">A different one.</span> A restore would put it in place, and passwords and keys stored since would need entering again.{% endif %}
{%- if report.sealed %} It opens {{ report.sealed - report.unopened }} of the backup's {{ report.sealed }} sealed value{{ "s" if report.sealed != 1 }}.{% endif %}</dd>
</dl>
</div>
<div class="card-flush" style="margin-bottom:1.25rem;">
<table class="table">
<thead><tr><th>Area</th><th>Same</th><th>Differ</th><th>Only in the backup</th><th>Only here</th></tr></thead>
<tbody>
{% for a in report.areas %}
{% set differ = a.tables|sum(attribute="differ", start=[])|length %}
{% set only_b = a.tables|sum(attribute="only_backup", start=[])|length %}
{% set only_h = a.tables|sum(attribute="only_here", start=[])|length %}
<tr>
<td>{{ a.name }}</td>
<td>{{ a.same }}</td>
<td>{% if differ %}<span class="tone-warn">{{ differ }}</span>{% else %}—{% endif %}</td>
<td>{% if only_b %}<span class="tone-warn">{{ only_b }}</span>{% else %}—{% endif %}</td>
<td>{% if only_h %}<span class="tone-warn">{{ only_h }}</span>{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% for a in report.areas if a.changes or a.tables|selectattr("note")|list %}
<h2 class="section-title" style="margin:1.5rem 0 0.75rem;">{{ a.name }}</h2>
<ul class="check-list">
{% for t in a.tables %}
{% for d in t.differ %}
<li><strong>{{ d.label }}</strong> differs
{%- if d.secret %} (a sealed value; not shown).
{%- elif d.backup_value is not none or d.here_value is not none %}: the backup has <code>{{ d.backup_value if d.backup_value is not none else "nothing" }}</code>, here it is <code>{{ d.here_value if d.here_value is not none else "nothing" }}</code>.
{%- else %} in {{ d.columns|join(", ") }}.{% endif %}</li>
{% endfor %}
{% for label in t.only_backup %}<li><strong>{{ label }}</strong> is only in the backup: a restore brings it back.</li>{% endfor %}
{% for label in t.only_here %}<li><strong>{{ label }}</strong> is only here: a restore removes it.</li>{% endfor %}
{% if t.note %}<li class="tone-dim">{{ t.table }}: {{ t.note }}</li>{% endif %}
{% endfor %}
</ul>
{% endfor %}
<p class="field-hint" style="margin-top:1rem;">Left out of the comparison: what Steward records as it runs
(when an agent or monitor was last seen, a schedule's last run, the backup history), since it changes by the
minute. A restore still brings those back as they were in the backup.</p>
{% include "settings/_backup_report.html" %}
{% endif %}
<p style="margin-top:1rem;"><a href="/settings/backups/">Back to Backups</a></p>
{% endblock %}
@@ -0,0 +1,58 @@
{# settings/backup_restore.html — restoring a backup, before anything changes
(#5817): what it would bring back (the same check as Check, #5823), what a
restore does, then a typed confirmation. A backup that can't go in says
why and offers no button. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% from "settings/_tabs.html" import settings_tabs %}
{% block title %}Settings — Backups — Restore — Steward{% endblock %}
{% block section_tabs %}{{ settings_tabs("backups") }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Backups", "/settings/backups/"), ("Restore " ~ (name or "a backup"), "")]) }}{% endblock %}
{% block content %}
{% if not report %}
<p class="page-verdict"><span class="tone-crit">{{ name or "The backup" }} can't be restored. {{ error }}</span></p>
{% else %}
{% include "settings/_backup_report.html" %}
<div class="card" style="max-width:640px;margin-top:1.25rem;">
<h2 class="section-title" style="margin-bottom:0.5rem;">Restore {{ name }}</h2>
{% if report.schema == "newer" %}
<p class="tone-crit">This Steward is older than the one that made the backup. Update Steward, then restore it.</p>
{% elif not report.ok %}
<p class="tone-crit">Its key doesn't open all of its secrets, so passwords and keys wouldn't come back. Restore a
different backup.</p>
{% else %}
<ul class="field-hint" style="margin:0 0 1rem 1.1rem;line-height:1.6;">
<li>A safety backup of Steward as it stands is made first, on <code>/data/backups</code> and the bucket, so
this can be undone the same way.</li>
{% if report.schema == "same" %}
<li>Settings, users, hosts, monitors, rules and dashboards become the backup's. The audit log and the history
of what is still here stay; history of anything the backup doesn't have goes with it.</li>
<li>If anything goes wrong part-way, nothing changes.</li>
{% else %}
<li><span class="tone-warn">The backup is from an older Steward, so it replaces everything</span>: the audit
log, alert history, metrics and check results go too, and Steward brings the backup up to date as it
starts. If this fails part-way, Steward starts empty, at first-run setup, where the safety backup can be
uploaded.</li>
{% endif %}
<li>While it runs, pages say Steward is restoring and agents hold their readings. Steward then restarts, and
you sign in again as a user from the backup.</li>
</ul>
{% if error %}<p class="tone-crit" style="margin-bottom:0.75rem;">{{ error }}</p>{% endif %}
<form method="post" action="/settings/backups/restore/start/">{{ csrf_field() }}
<input type="hidden" name="token" value="{{ held.token }}">
{% if report.schema == "older" %}
<label style="display:flex;align-items:center;gap:0.4rem;font-weight:normal;margin-bottom:0.75rem;">
<input type="checkbox" name="replace_all"> Replace everything, history included</label>
{% endif %}
<div class="form-group" style="max-width:260px;">
<label for="r-confirm">Type <code>{{ word }}</code> to confirm</label>
<input type="text" id="r-confirm" name="confirm" autocomplete="off" required>
</div>
<button type="submit" class="btn btn-danger">Restore</button>
</form>
{% endif %}
</div>
{% endif %}
<p style="margin-top:1rem;"><a href="/settings/backups/">Back to Backups</a></p>
{% endblock %}
+18 -7
View File
@@ -1,7 +1,7 @@
{# settings/backups.html — Steward's own backups (#5673): a verdict, the bucket
they go to (Fabled Archives over S3) and the daily hour, then the recent
runs, each with its own check and a Check button (#5823), and a file to
check. Restoring is `steward restore`; see docs/core/backups.md. #}
runs, each with its own check and Check and Restore buttons (#5823, #5817),
and a file to check or restore. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% from "settings/_tabs.html" import settings_tabs %}
@@ -16,6 +16,12 @@
{% if verdict.good %}The newest good backup was sent {{ verdict.good|ago }}.{% else %}No backup has been sent yet.{% endif %}
{%- endif %}</span></p>
{% if restore_failed %}
<p class="tone-crit" style="margin:-0.5rem 0 1.25rem;">The restore of {{ restore_failed.name }}
{%- if restore_failed.since %} {{ restore_failed.since|ago }}{% endif %} failed, and nothing changed:
{{ restore_failed.error }}</p>
{% endif %}
{% if flash %}
<div style="padding:0.75rem 1rem;border-radius:6px;margin-bottom:1.25rem;
background:{% if flash.ok %}var(--green-dim){% else %}var(--red-dim){% endif %};
@@ -130,11 +136,15 @@
<td class="nowrap">{{ r.size|bytes if r.ok else "—" }}</td>
<td class="nowrap">{{ r.secs|duration }}</td>
<td>{{ "the schedule" if r.by == "schedule" else r.by }}</td>
<td>{% if r.ok and r.name %}
<td>{% if r.ok and r.name %}<div style="display:flex;gap:0.35rem;">
<form method="post" action="/settings/backups/check/">{{ csrf_field() }}
<input type="hidden" name="name" value="{{ r.name }}">
<button type="submit" class="btn btn-ghost btn-sm" title="Compare {{ r.name }} with what is in place; nothing changes">Check</button>
</form>{% endif %}</td>
</form>
<form method="post" action="/settings/backups/restore/">{{ csrf_field() }}
<input type="hidden" name="name" value="{{ r.name }}">
<button type="submit" class="btn btn-ghost btn-sm" title="See what restoring {{ r.name }} would change, then confirm">Restore</button>
</form></div>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
@@ -144,15 +154,16 @@
<p class="tone-dim">None yet.</p>
{% endif %}
<p class="field-hint" style="margin-top:0.75rem;">Check compares a backup with what is in place: what
a restore would bring back, change or remove, area by area. It changes nothing. To bring Steward back
from one, see <code>steward restore</code> in the backups guide (docs/core/backups.md).</p>
a restore would bring back, change or remove, area by area. It changes nothing. Restore shows the same
comparison and asks you to confirm before anything changes.</p>
<div class="card" style="max-width:640px;margin-top:1.25rem;">
<h2 class="section-title" style="margin-bottom:0.5rem;">Check a file</h2>
<h2 class="section-title" style="margin-bottom:0.5rem;">Check or restore a file</h2>
<form method="post" action="/settings/backups/check/file/" enctype="multipart/form-data"
style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:center;">{{ csrf_field() }}
<input type="file" name="file" accept=".tar" required>
<button type="submit" class="btn btn-ghost">Check</button>
<button type="submit" class="btn btn-ghost" formaction="/settings/backups/restore/file/">Restore</button>
</form>
<p class="field-hint" style="margin-top:0.5rem;">A <code>steward-&lt;time&gt;.tar</code> downloaded from
the bucket, or made with <code>steward backup</code>.</p>
+145
View File
@@ -0,0 +1,145 @@
"""Restoring mode (steward/backup/restoring.py, #5816): while a backup goes back
in, pages say so and agents are told to retry; the scheduler holds; a restore
saves a safety backup first, puts the backup's key in place, audits itself
and asks for a restart; a failed one says why and changes nothing."""
import asyncio
from types import SimpleNamespace
import pytest
from steward.backup import restoring
from steward.backup.dump import BackupError
@pytest.fixture(autouse=True)
def fresh_state(monkeypatch):
monkeypatch.setattr(restoring, "STATE", restoring.Restoring())
async def test_while_restoring_pages_say_so_and_agents_are_told_to_retry(client):
restoring.STATE.active, restoring.STATE.name = True, "steward-20261015T030000Z.tar"
restoring.STATE.by, restoring.STATE.step = "admin", "Restoring"
page = await client.get("/", headers={"Accept": "text/html"})
assert page.status_code == 503 and page.headers["Retry-After"] == "30"
body = await page.get_data(as_text=True)
assert "Steward is restoring a backup" in body and "steward-20261015T030000Z.tar" in body
assert 'http-equiv="refresh"' in body
ingest = await client.post("/plugins/host_agent/ingest", json={})
assert ingest.status_code == 503 and ingest.headers["Retry-After"] == "30"
assert "restoring a backup" in await ingest.get_data(as_text=True)
async def test_when_not_restoring_the_gate_stays_out_of_the_way(app):
async with app.test_request_context("/", headers={"Accept": "text/html"}):
assert await restoring.gate() is None
async def test_the_scheduler_holds_while_paused():
from steward.core.scheduler import ScheduledTask, start_scheduler
ran = []
paused = {"now": True}
async def tick():
ran.append(1)
task = asyncio.create_task(start_scheduler(
[ScheduledTask("t", tick, interval_seconds=0, run_on_startup=False)],
paused=lambda: paused["now"]))
await asyncio.sleep(1.5)
held = len(ran)
paused["now"] = False
await asyncio.sleep(1.5)
task.cancel()
assert held == 0 and ran
def _app():
return SimpleNamespace(config={"DATABASE_URL": "postgresql+asyncpg://x/y", "SECRET_KEY": "k-here",
"PLUGIN_DIRS": ["plugins"]})
def _stub(monkeypatch, tmp_path, *, restore_error=None, key="k-backup"):
from steward import config
from steward.backup import restore
from steward.core import audit
seen = {"audits": [], "restarts": 0}
async def safety(app):
seen["safety"] = True
return "steward-20261015T140000Z.tar"
async def in_place(path, url, plugin_dirs=None):
if restore_error:
raise BackupError(restore_error)
return restore.RestoredInPlace({}, key, ["app_settings", "hosts"], 12, 1)
async def log_audit(app, user_id, username, action, **kw):
seen["audits"].append((action, kw.get("detail")))
monkeypatch.setattr(restoring, "_backup_key", lambda path: key)
monkeypatch.setattr(restoring, "_safety_backup", safety)
monkeypatch.setattr(restore, "restore_in_place", in_place)
monkeypatch.setattr(audit, "log_audit", log_audit)
monkeypatch.setattr(restoring, "_restart_soon", lambda: seen.__setitem__("restarts", seen["restarts"] + 1))
monkeypatch.setattr(config, "_SECRET_KEY_FILE", tmp_path / "secret.key")
monkeypatch.delenv("STEWARD_SECRET_KEY", raising=False)
return seen
async def _restore(tmp_path):
assert restoring.start_restore(_app(), tmp_path / "b.tar", "steward-20261015T030000Z.tar",
username="admin")
await asyncio.gather(*list(restoring._tasks))
await asyncio.gather(*list(restoring._tasks)) # the failure audit, when there is one
async def test_a_restore_saves_a_safety_backup_puts_the_key_in_place_and_restarts(monkeypatch, tmp_path):
seen = _stub(monkeypatch, tmp_path)
await _restore(tmp_path)
assert seen["safety"] and seen["restarts"] == 1
assert (tmp_path / "secret.key").read_text() == "k-backup"
[(action, detail)] = seen["audits"]
assert action == "backup.restored"
assert detail == {"mode": "in place", "tables": 2, "rows": 12, "put_right": 1,
"safety_backup": "steward-20261015T140000Z.tar"}
assert restoring.STATE.step.startswith("Restored")
async def test_a_failed_restore_says_why_and_steward_carries_on(monkeypatch, tmp_path):
seen = _stub(monkeypatch, tmp_path, restore_error="The restore was undone, nothing changed: x")
await _restore(tmp_path)
assert not restoring.STATE.active and "undone" in restoring.STATE.error
assert seen["restarts"] == 0 and not (tmp_path / "secret.key").exists()
assert [a for a, _ in seen["audits"]] == ["backup.restore_failed"]
async def test_a_pinned_key_that_differs_stops_it_before_anything_is_saved(monkeypatch, tmp_path):
seen = _stub(monkeypatch, tmp_path)
monkeypatch.setenv("STEWARD_SECRET_KEY", "k-pinned")
await _restore(tmp_path)
assert "STEWARD_SECRET_KEY" in restoring.STATE.error and "safety" not in seen
async def test_one_restore_at_a_time(monkeypatch, tmp_path):
_stub(monkeypatch, tmp_path)
restoring.STATE.active = True
assert not restoring.start_restore(_app(), tmp_path / "b.tar", "x", username="admin")
def test_a_chosen_backup_is_held_by_token_until_it_is_too_old(monkeypatch, tmp_path):
import os
import time
monkeypatch.setattr(restoring, "SAFETY_DIR", tmp_path)
held = restoring.stage()
held.path.write_bytes(b"x")
held.name, held.schema, held.ok = "steward-20261015T030000Z.tar", "same", True
held.save()
again = restoring.staged(held.token)
assert (again.name, again.schema, again.ok, again.path) == (held.name, "same", True, held.path)
assert restoring.staged("../../etc") is None and restoring.staged("0" * 32) is None
old = time.time() - restoring.STAGED_SECONDS - 1
os.utime(held.dir, (old, old))
restoring.stage()
assert restoring.staged(held.token) is None
@@ -0,0 +1,159 @@
"""Integration: Restore on Settings → Backups (#5817). A chosen backup is
checked and held; nothing starts until the person types the word, an older
backup also needs Replace everything ticked, a newer one has no button, and
only an admin gets this far. The restore itself is restoring.py's (stubbed
here; tests/core/test_restoring.py covers it)."""
from __future__ import annotations
import asyncio
import io
import os
import time
import uuid
import pytest
pytestmark = pytest.mark.integration
CSRF = {"X-CSRF-Token": "test-token"}
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
async def _client(app, role):
from steward.models.users import User, UserRole
uid, tag = str(uuid.uuid4()), uuid.uuid4().hex[:8]
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"rs-{tag}", email=f"rs-{tag}@test",
password_hash="x", role=UserRole(role), is_active=True))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role=role, username=f"rs-{tag}",
_csrf="test-token", login_at=time.time())
return client
def _report(schema="same", unopened=0):
from steward.backup.check import AreaCheck, CheckReport, TableCheck
return CheckReport(name="steward-20261015T030000Z.tar",
manifest={"created_at": "2026-10-15T03:00:00+00:00"}, schema=schema,
backup_heads=["a"], here_heads=["a"], key_same=True, sealed=1,
unopened=unopened,
areas=[AreaCheck("Settings", [TableCheck("app_settings", same=4)])])
@pytest.fixture
def stubs(monkeypatch, tmp_path):
from steward.backup import job, restoring
seen = {"started": [], "schema": "same", "unopened": 0}
async def check_file(app, path, name=""):
seen["checked"] = path.read_bytes()
return _report(seen["schema"], seen["unopened"])
def start_restore(app, path, name, *, username, replace_all=False, owned_dir=None):
seen["started"].append({"path": path, "name": name, "replace_all": replace_all,
"owned_dir": owned_dir})
return True
monkeypatch.setattr(restoring, "SAFETY_DIR", tmp_path)
monkeypatch.setattr(job, "check_file", check_file)
monkeypatch.setattr(restoring, "start_restore", start_restore)
return seen
async def _upload(client, name="steward-20261015T030000Z.tar"):
from quart.datastructures import FileStorage
return await client.post("/settings/backups/restore/file/", headers=CSRF, files={
"file": FileStorage(io.BytesIO(b"a backup"), filename=name)})
def _token(page: str) -> str:
import re
return re.search(r'name="token" value="([0-9a-f]{32})"', page).group(1)
def test_nothing_starts_until_the_word_is_typed(app, stubs):
async def _go():
admin = await _client(app, "admin")
page = await (await _upload(admin)).get_data(as_text=True)
assert stubs["checked"] == b"a backup"
assert "Matches what is in place" in page and "Type <code>restore</code> to confirm" in page
token = _token(page)
wrong = await admin.post("/settings/backups/restore/start/", headers=CSRF,
form={"token": token, "confirm": "yes"})
assert wrong.status_code == 200 and not stubs["started"]
assert "Type restore to confirm." in await wrong.get_data(as_text=True)
go = await admin.post("/settings/backups/restore/start/", headers=CSRF,
form={"token": token, "confirm": " Restore "})
assert go.status_code == 302 and go.headers["Location"].endswith("/settings/backups/")
[started] = stubs["started"]
assert started["name"] == "steward-20261015T030000Z.tar" and not started["replace_all"]
assert started["path"].read_bytes() == b"a backup" and started["owned_dir"].name == token
gone = await admin.post("/settings/backups/restore/start/", headers=CSRF,
form={"token": "0" * 32, "confirm": "restore"})
assert "no longer held" in await gone.get_data(as_text=True)
asyncio.run(_go())
def test_an_older_backup_also_needs_replace_everything(app, stubs):
stubs["schema"] = "older"
async def _go():
admin = await _client(app, "admin")
page = await (await _upload(admin)).get_data(as_text=True)
assert 'name="replace_all"' in page and "replaces everything" in page
token = _token(page)
await admin.post("/settings/backups/restore/start/", headers=CSRF,
form={"token": token, "confirm": "restore"})
assert not stubs["started"]
await admin.post("/settings/backups/restore/start/", headers=CSRF,
form={"token": token, "confirm": "restore", "replace_all": "on"})
assert [s["replace_all"] for s in stubs["started"]] == [True]
asyncio.run(_go())
def test_a_backup_that_cant_go_in_has_no_button(app, stubs):
async def _go():
admin = await _client(app, "admin")
stubs["schema"] = "newer"
newer = await (await _upload(admin)).get_data(as_text=True)
stubs["schema"], stubs["unopened"] = "same", 1
unopened = await (await _upload(admin)).get_data(as_text=True)
return newer, unopened
newer, unopened = asyncio.run(_go())
assert "Update Steward, then restore it." in newer and 'name="token"' not in newer
assert "wouldn&#39;t come back" in unopened and 'name="token"' not in unopened
def test_only_an_admin_restores(app, stubs):
async def _go():
operator = await _client(app, "operator")
return (await _upload(operator)).status_code, (await operator.post(
"/settings/backups/restore/", headers=CSRF, form={"name": "steward-20261015T030000Z.tar"})
).status_code
assert asyncio.run(_go()) == (403, 403)
assert not stubs["started"]
def test_the_backups_page_offers_restore_and_says_when_one_failed(app, stubs, monkeypatch):
from steward.backup import restoring
monkeypatch.setattr(restoring, "STATE", restoring.Restoring(
name="steward-20261014T030000Z.tar", error="The restore was undone, nothing changed: x"))
async def _go():
admin = await _client(app, "admin")
return await (await admin.get("/settings/backups/")).get_data(as_text=True)
page = asyncio.run(_go())
assert 'formaction="/settings/backups/restore/file/"' in page
assert "The restore of steward-20261014T030000Z.tar" in page and "nothing changed: x" in page