#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:
+6
-1
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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-<time>.tar</code> downloaded from
|
||||
the bucket, or made with <code>steward backup</code>.</p>
|
||||
|
||||
@@ -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'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
|
||||
Reference in New Issue
Block a user