Files
FabledSteward/steward/templates/settings/backups.html
T
bvandeusenandClaude Opus 5.5 e8b75143f1
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m23s
CI / unit (push) Successful in 1m37s
CI / publish (push) Successful in 1m8s
feat(backup): check a backup against what is in place, changing nothing (#5823)
#5823 "Check a backup: compare what it would restore with what is in
place, without restoring".

- steward/backup/check.py: pg_restore writes the backup's rows as COPY
  text with no database involved. The rows in place go out through COPY in
  the same form. Tables are compared on their primary keys, over the columns
  both sides have, and grouped by area.
- Sealed values are opened with each side's own key and compared as
  plaintext digests, so a secret sealed twice still matches. They are never
  shown. A backup whose key can't open its own secrets fails the check.
- What Steward records as it runs (last-seen times, a schedule's last run,
  backup history, scraped UniFi fields) is left out of the comparison:
  contents.ACTIVITY, STATE_SETTINGS and HAND_SET_ONLY.
- Settings → Backups:
  - a Check button on each sent backup, which fetches it from the bucket
    (new s3.get_file);
  - "Check a file" to upload one, with the size limit raised before the
    CSRF check reads the form;
  - a results page.
- Each backup Steward sends checks itself once written, and its history
  row says how that went.
- New CLI command `steward check-backup FILE`. CI's image smoke step checks a
  real backup against its source and expects a match.

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

161 lines
8.5 KiB
HTML

{# 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. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% from "settings/_tabs.html" import settings_tabs %}
{% block title %}Settings — Backups — Steward{% endblock %}
{% block section_tabs %}{{ settings_tabs("backups") }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Backups", "")]) }}{% endblock %}
{% block content %}
{% set tone = {"ok": "tone-ok", "warn": "tone-warn", "bad": "tone-crit", "off": "tone-dim"}[verdict.tone] %}
<p class="page-verdict"><span class="{{ tone }}">{{ verdict.text }}
{%- if verdict.moment %} {{ verdict.moment|ago }}{% endif %}.
{%- if verdict.failed %} {{ verdict.error }}
{% if verdict.good %}The newest good backup was sent {{ verdict.good|ago }}.{% else %}No backup has been sent yet.{% endif %}
{%- endif %}</span></p>
{% 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 %};
border:1px solid {% if flash.ok %}var(--green){% else %}var(--red){% endif %};
color:{% if flash.ok %}var(--green){% else %}var(--red){% endif %};
font-size:0.88rem;">
{{ flash.message }}
</div>
{% endif %}
<div style="max-width:640px;">
<form method="post" action="/settings/backups/">{{ csrf_field() }}
<div class="card" style="margin-bottom:1.25rem;">
<h2 class="section-title" style="margin-bottom:0.5rem;">Bucket</h2>
<p class="field-hint" style="margin-bottom:1rem;">
Each backup is one file, <code>steward-&lt;time&gt;.tar</code>, for getting Steward running again:
its settings, users, hosts, monitors, alert rules and dashboards, and its secret key. Logs and
history are left out: the audit log, alert history, metrics and check results. Anyone holding a
copy can read every password and key Steward keeps, so keep the bucket private.
</p>
<div class="form-group">
<label for="b-endpoint">Endpoint</label>
<input type="url" id="b-endpoint" name="backup.s3_endpoint" value="{{ settings['backup.s3_endpoint'] }}"
placeholder="http://archives:8081">
<p class="field-hint">The S3 address, without the bucket. Requests are path-style: endpoint/bucket/file.</p>
</div>
<div style="display:grid;grid-template-columns:2fr 1fr;gap:1rem;">
<div class="form-group">
<label for="b-bucket">Bucket</label>
<input type="text" id="b-bucket" name="backup.s3_bucket" value="{{ settings['backup.s3_bucket'] }}"
placeholder="steward">
</div>
<div class="form-group">
<label for="b-region">Region</label>
<input type="text" id="b-region" name="backup.s3_region" value="{{ settings['backup.s3_region'] }}">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div class="form-group">
<label for="b-access">Access key</label>
<input type="text" id="b-access" name="backup.s3_access_key" autocomplete="off"
value="{{ settings['backup.s3_access_key'] }}">
</div>
<div class="form-group">
<label for="b-secret">Secret key</label>
<input type="password" id="b-secret" name="backup.s3_secret_key" autocomplete="new-password"
placeholder="{% if has_secret %}kept; type to change{% endif %}">
{% if has_secret %}
<label style="display:flex;align-items:center;gap:0.4rem;font-weight:normal;margin-top:0.35rem;">
<input type="checkbox" name="backup.clear_secret"> Remove the stored secret key
</label>
{% endif %}
</div>
</div>
<p class="field-hint">An append-only key is enough: Steward only uploads and never deletes. How long
backups are kept is the bucket's own setting.</p>
<div class="form-group" style="display:flex;align-items:center;gap:0.75rem;">
<input type="checkbox" name="backup.s3_verify_tls" id="b-tls"
{% if settings['backup.s3_verify_tls'] %}checked{% endif %}>
<label for="b-tls" style="margin:0;">Check the endpoint's TLS certificate</label>
</div>
<h2 class="section-title" style="margin:1.5rem 0 0.75rem;">Schedule</h2>
<div class="form-group" style="display:flex;align-items:center;gap:0.75rem;">
<input type="checkbox" name="backup.enabled" id="b-enabled"
{% if settings['backup.enabled'] %}checked{% endif %}>
<label for="b-enabled" style="margin:0;">Back up every day</label>
</div>
<div class="form-group" style="max-width:220px;">
<label for="b-hour">At hour ({{ tz_label() }})</label>
<select id="b-hour" name="backup.schedule_hour">
{% for h in range(24) %}
<option value="{{ h }}" {% if shown_hour == h %}selected{% endif %}>{{ "%02d:00"|format(h) }}</option>
{% endfor %}
</select>
</div>
<p class="field-hint">A day missed while Steward was down is made up when it is back; a failed backup is
tried again an hour later. Watch it with an <a href="/alerts/rules/new">alert rule</a> on
"Steward itself", resource <code>backup</code>, metric "Newest backup's age".</p>
<div style="margin-top:1rem;"><button type="submit" class="btn">Save</button></div>
</div>
</form>
<div class="card" style="margin-bottom:1.25rem;display:flex;gap:0.75rem;flex-wrap:wrap;align-items:center;">
<form method="post" action="/settings/backups/test/">{{ csrf_field() }}
<button type="submit" class="btn btn-ghost">Test the bucket</button>
</form>
<form method="post" action="/settings/backups/now/">{{ csrf_field() }}
<button type="submit" class="btn btn-ghost">Back up now</button>
</form>
<p class="field-hint" style="margin:0;">Both use the saved settings.</p>
</div>
</div>
<h2 class="section-title" style="margin:1.5rem 0 1rem;">Recent backups</h2>
{% if history %}
<div class="card-flush">
<table class="table">
<thead><tr><th>When</th><th>Outcome</th><th>File</th><th>Size</th><th>Took</th><th>Started by</th><th></th></tr></thead>
<tbody>
{% for r in history %}
<tr>
<td class="nowrap">{{ r.at|when if r.at else "—" }}</td>
<td>{% if r.ok %}<span class="tone-ok">Sent</span>
{%- if r.check %}<div class="cell-sub {{ 'tone-crit' if not r.check.ok else ('tone-warn' if r.check.changes else '') }}">
{%- if r.check.ok and not r.check.changes %}Checked: reads back whole{% else %}{{ r.check.summary }}{% endif %}</div>{% endif %}
{%- else %}<span class="tone-crit">Failed</span>
<div class="cell-sub">{{ r.error }}</div>{% endif %}</td>
<td class="cell-name"><span title="{{ r.name }}">{{ r.name or "—" }}</span></td>
<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 %}
<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>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<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>
<div class="card" style="max-width:640px;margin-top:1.25rem;">
<h2 class="section-title" style="margin-bottom:0.5rem;">Check 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>
</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>
</div>
{% endblock %}