#5673 "S3 client and the backup job: settings, schedule, history, metrics": - steward/backup/s3.py: SigV4 over httpx, path-style; put and list only (an append-only key is enough; retention is the bucket's). Signing is held to AWS's published examples. - steward/backup/job.py: a 15-minute tick. The daily backup goes at the hour chosen on the reader's clock; a missed day is made up, and a failure is retried an hour later. History of the last 30 runs, audit (backup.made / backup.failed), and backup_age_secs / backup_bytes under "Steward itself", resource backup, for alert rules. - Settings → Backups: verdict first, then the bucket (secret sealed; a blank box keeps it), the schedule, Test the bucket, Back up now, and the recent runs. #5675 "Docs: backing up to Archives, and restoring": docs/core/backups.md. Also: the audit page now words the Prometheus target actions from #5668, which showed as codes, and a guard fails on any logged action without wording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -119,6 +119,7 @@ See `docs/plugins/` for a full plugin development guide.
|
||||
| `docs/core/monitors.md` | Ping and DNS monitors: probe logic, metrics emitted, data models |
|
||||
| `docs/core/alerting.md` | Alert rules, state machine, notification channels, template variables |
|
||||
| `docs/core/ansible.md` | Playbook sources, run lifecycle, SSE streaming |
|
||||
| `docs/core/backups.md` | Steward's daily backup to an S3 bucket (Fabled Archives), what it holds, and `steward restore` |
|
||||
| `docs/plugins/overview.md` | Plugin system: how loading works, plugin.yaml schema, required exports |
|
||||
| `docs/plugins/writing-a-plugin.md` | Step-by-step plugin development guide |
|
||||
| `docs/plugins/traefik.md` | Traefik plugin: config, metrics, alert rule examples |
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Backups: Steward backing itself up, and coming back
|
||||
|
||||
Steward sends a backup of itself to an S3 bucket once a day. Fabled Archives
|
||||
is the intended target. A backup is one file, and `steward restore` brings
|
||||
Steward back from it into an empty database.
|
||||
|
||||
## What a backup holds
|
||||
|
||||
`steward-<UTC time>.tar`, containing:
|
||||
|
||||
| Member | What it is |
|
||||
|---|---|
|
||||
| `steward.dump` | `pg_dump --format=custom` of Steward's database |
|
||||
| `secret.key` | The key Steward's stored passwords and tokens are sealed with |
|
||||
| `manifest.json` | Format, Steward version, migration heads, time, and the tables whose rows were left out |
|
||||
|
||||
**Kept:**
|
||||
- configuration: settings, users, API tokens, hosts, monitors, alert rules, maintenance windows, Ansible inventory and schedules, dashboards, SNMP devices, Prometheus targets, UniFi client names;
|
||||
- the audit log;
|
||||
- alert history.
|
||||
|
||||
**Left out** (the tables come back empty and fill again as Steward runs):
|
||||
- metrics and their rollups;
|
||||
- monitor check results;
|
||||
- Docker, Traefik and UniFi history;
|
||||
- Ansible run records;
|
||||
- the email queue;
|
||||
- password-reset links.
|
||||
|
||||
The list is `steward/backup/contents.py`. A test fails when a new table
|
||||
appears in neither list, so whoever adds a table decides.
|
||||
|
||||
**The secret key is in the file.** That makes a restore complete on its own.
|
||||
It also means anyone holding a copy can read every password and key Steward
|
||||
keeps. Keep the bucket private.
|
||||
|
||||
## Setting it up with Fabled Archives
|
||||
|
||||
1. In Archives, make a backup target for Steward with an S3 key. The default
|
||||
append-only key is enough: Steward only uploads and never deletes.
|
||||
How long backups are kept is set on the Archives side.
|
||||
2. In Steward, open **Settings → Backups** and fill in:
|
||||
- **Endpoint**: the S3 address Archives shows for the target, without the
|
||||
bucket (for example `http://archives:8081`). Requests are path-style:
|
||||
`endpoint/bucket/file`.
|
||||
- **Bucket**: the target's bucket name.
|
||||
- **Access key** and **Secret key**: from Archives. The secret is sealed at
|
||||
rest; a blank box on save keeps the stored one.
|
||||
3. Press **Test the bucket**. A key that may not list ("AccessDenied") still
|
||||
counts as reached: an append-only key can't list, and uploads work anyway.
|
||||
4. Tick **Back up every day**, pick the hour (on your clock), and save.
|
||||
**Back up now** makes one straight away.
|
||||
|
||||
A day missed while Steward was down is made up when it comes back. A failed
|
||||
backup is tried again an hour later, never sooner. The last 30 runs are
|
||||
listed on the page, and each one is in the audit log (`backup.made` /
|
||||
`backup.failed`).
|
||||
|
||||
### Alert when backups stop
|
||||
|
||||
While backups are on, Steward records two metrics under the source
|
||||
**Steward itself**, resource `backup`:
|
||||
|
||||
| Metric | Meaning |
|
||||
|---|---|
|
||||
| `backup_age_secs` ("Newest backup's age") | Seconds since the last backup was sent, updated every 15 minutes |
|
||||
| `backup_bytes` ("Backup size") | The size of the last backup sent |
|
||||
|
||||
A rule such as `backup_age_secs > 93600` (26 hours) fires when a daily backup
|
||||
has been missed.
|
||||
|
||||
## Making a backup by hand
|
||||
|
||||
```sh
|
||||
docker exec -u app <steward container> steward backup --to /tmp/backups
|
||||
```
|
||||
|
||||
This writes the file and sends it nowhere. It needs the same database access
|
||||
as Steward itself.
|
||||
|
||||
## Restoring
|
||||
|
||||
`steward restore` writes into an **empty** database. Pass `--wipe` to drop
|
||||
everything in the database's public schema first.
|
||||
|
||||
1. Stop Steward (scale its service to 0) so nothing writes during the restore.
|
||||
2. Download the backup file from Archives.
|
||||
3. Run the restore with the image, pointed at the database:
|
||||
|
||||
```sh
|
||||
docker run --rm \
|
||||
-e STEWARD_DATABASE_URL=postgresql+asyncpg://steward:…@db:5432/steward \
|
||||
-v steward_data:/data -v "$PWD":/restore \
|
||||
git.fabledsword.com/bvandeusen/steward:latest \
|
||||
steward restore /restore/steward-20261012T030000Z.tar
|
||||
```
|
||||
|
||||
- The backup's secret key is written to `/data/secret.key`.
|
||||
- If `STEWARD_SECRET_KEY` is set instead, it must equal the backup's key,
|
||||
or the restore stops and says so. Read the key with
|
||||
`tar -xOf <file> secret.key`.
|
||||
4. Start Steward. On start it runs any migrations the backup is behind on.
|
||||
|
||||
**How the restore works.** pg_restore runs in its three sections: schema,
|
||||
then data, then constraints and indexes. Between the data and the
|
||||
constraints, a kept row that points at a left-out row is put right by its
|
||||
foreign key's ON DELETE:
|
||||
- an alert event's link to an Ansible run is cleared;
|
||||
- a row whose foreign key would cascade or block is removed.
|
||||
|
||||
pg_restore 17 writes each section as a script for psql. It runs against
|
||||
Postgres 16 with one setting line dropped (`transaction_timeout`, which 16
|
||||
doesn't know).
|
||||
|
||||
CI checks the whole path on every build. The built image backs up its own
|
||||
database, restores into a second one, checks a setting came back and the
|
||||
metrics table is empty, then starts Steward on it.
|
||||
@@ -28,7 +28,8 @@ CORE_METRICS: dict[str, list[str]] = {
|
||||
"dns": ["is_up", "response_ms"],
|
||||
"http": ["is_up", "response_ms"],
|
||||
"steward": ["ingest_posts", "ingest_failed", "ingest_slowest_ms", "db_roundtrip_ms",
|
||||
"unwatched_secs", "cleanup_secs", "open_fds", "open_fds_pct"],
|
||||
"unwatched_secs", "cleanup_secs", "open_fds", "open_fds_pct",
|
||||
"backup_age_secs", "backup_bytes"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -441,12 +441,25 @@ def _register_core_tasks(app: Quart) -> None:
|
||||
from .core.self_monitor import record_self_metrics
|
||||
await record_self_metrics(app)
|
||||
|
||||
async def run_backup_tick():
|
||||
from .backup.job import tick
|
||||
await tick(app)
|
||||
|
||||
async def run_email_outbox():
|
||||
from .core.outbox import send_due
|
||||
await send_due(app)
|
||||
|
||||
from .backup.job import TICK_SECONDS as BACKUP_TICK_S
|
||||
from .core.outbox import SENDER_TICK_S
|
||||
app._task_registry.extend([
|
||||
# Steward's own daily backup to its bucket (#5673): a tick that
|
||||
# records the newest backup's age and makes one when it is due.
|
||||
ScheduledTask(
|
||||
name="backup",
|
||||
coro_factory=run_backup_tick,
|
||||
interval_seconds=BACKUP_TICK_S,
|
||||
run_on_startup=False,
|
||||
),
|
||||
# Every email is a row until the mail server accepts it (#5506); this
|
||||
# sends what is due and retries what failed. On startup too, so what a
|
||||
# restart left unsent goes out at once.
|
||||
|
||||
@@ -187,6 +187,11 @@ ACTIONS = {
|
||||
"snmp.device_created": "added SNMP device",
|
||||
"snmp.device_updated": "changed SNMP device",
|
||||
"snmp.device_deleted": "deleted SNMP device",
|
||||
"prometheus.target_created": "added Prometheus target",
|
||||
"prometheus.target_updated": "changed Prometheus target",
|
||||
"prometheus.target_deleted": "deleted Prometheus target",
|
||||
"backup.made": "sent a backup",
|
||||
"backup.failed": "tried to send a backup, which failed:",
|
||||
}
|
||||
|
||||
# The filter's choices: an area, matched as a prefix of the action code.
|
||||
@@ -194,7 +199,8 @@ AREAS = (("auth.", "Sign-ins"), ("account.", "Accounts"), ("user.", "Users"), ("
|
||||
("alert_rule.", "Alert rules"), ("maintenance_window.", "Maintenance"),
|
||||
("ansible.run", "Playbook runs"), ("ansible.", "Ansible (all)"),
|
||||
("settings.", "Settings"), ("plugin.", "Plugins"), ("api_token.", "AI access"),
|
||||
("docker.", "Docker"), ("snmp.", "SNMP"))
|
||||
("docker.", "Docker"), ("snmp.", "SNMP"), ("prometheus.", "Prometheus"),
|
||||
("backup.", "Backups"))
|
||||
|
||||
|
||||
def action_text(action: str) -> str:
|
||||
@@ -211,7 +217,8 @@ _LABELS = {"check": "dry run", "interval_seconds": "every", "interval_s": "every
|
||||
_METHODS = {"local": "password", "ldap": "LDAP", "oidc": "single sign-on"}
|
||||
_SECTIONS = {"general": "General", "thresholds": "Thresholds", "notifications": "Notifications",
|
||||
"ansible.managed_key": "Ansible: managed SSH key",
|
||||
"ansible.credentials": "Ansible: credentials", "auth": "Authentication"}
|
||||
"ansible.credentials": "Ansible: credentials", "auth": "Authentication",
|
||||
"backups": "Backups"}
|
||||
|
||||
|
||||
def _plain(value) -> str:
|
||||
|
||||
+237
-2
@@ -1,10 +1,40 @@
|
||||
"""Steward's own backups as a scheduled job: make the file, send it, record
|
||||
how it went (milestone 507)."""
|
||||
"""Steward's own backups as a scheduled job: make the file, send it to the
|
||||
bucket, record how it went (#5673).
|
||||
|
||||
Once a day at the hour chosen in Settings → Backups, in the zone of whoever
|
||||
saved it. A day missed while Steward was down is made up on the next tick;
|
||||
a failed run is tried again an hour later, never sooner, so a target that is
|
||||
down isn't pressed. Each run is kept in `backup.history` (newest first, the
|
||||
last HISTORY_KEEP), audited, and leaves two metrics under source "steward",
|
||||
resource "backup": backup_age_secs (since the last success, every tick while
|
||||
backups are on, so an alert rule can watch it) and backup_bytes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from .dump import BackupError, make_backup
|
||||
from .s3 import S3Error, S3Target, list_objects, put_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TICK_SECONDS = 15 * 60
|
||||
RETRY_AFTER = timedelta(hours=1)
|
||||
HISTORY_KEEP = 30
|
||||
PREFIX = "steward-"
|
||||
|
||||
# One backup at a time, whether the schedule or a person started it.
|
||||
_running = asyncio.Lock()
|
||||
_background: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
async def migration_heads(database_url: str) -> list[str]:
|
||||
"""The migration revisions the database is at, for the manifest."""
|
||||
@@ -14,3 +44,208 @@ async def migration_heads(database_url: str) -> list[str]:
|
||||
return list((await conn.execute(text("SELECT version_num FROM alembic_version"))).scalars())
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def target_from(settings: dict) -> S3Target | None:
|
||||
"""The bucket the settings name, or None while they're incomplete."""
|
||||
endpoint = (settings.get("backup.s3_endpoint") or "").strip()
|
||||
bucket = (settings.get("backup.s3_bucket") or "").strip()
|
||||
access = (settings.get("backup.s3_access_key") or "").strip()
|
||||
secret = settings.get("backup.s3_secret_key") or ""
|
||||
if not (endpoint and bucket and access and secret):
|
||||
return None
|
||||
return S3Target(endpoint, bucket, access, secret,
|
||||
region=(settings.get("backup.s3_region") or "us-east-1").strip(),
|
||||
verify_tls=bool(settings.get("backup.s3_verify_tls", True)))
|
||||
|
||||
|
||||
def _moment(raw) -> datetime | None:
|
||||
try:
|
||||
return datetime.fromisoformat(raw) if raw else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def last_slot(now: datetime, hour: int, tz) -> datetime:
|
||||
"""The latest scheduled moment at or before `now`: today's hour in `tz`,
|
||||
or yesterday's when today's hasn't come yet."""
|
||||
local = now.astimezone(tz)
|
||||
slot = local.replace(hour=hour, minute=0, second=0, microsecond=0)
|
||||
if slot > local:
|
||||
slot -= timedelta(days=1)
|
||||
return slot.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def is_due(now: datetime, hour: int, tz, last_success: datetime | None,
|
||||
last_attempt: datetime | None) -> bool:
|
||||
"""Due when no backup has succeeded since the latest slot, and the last
|
||||
try (if it failed) was at least RETRY_AFTER ago."""
|
||||
slot = last_slot(now, hour, tz)
|
||||
if last_success is not None and last_success >= slot:
|
||||
return False
|
||||
return last_attempt is None or now - last_attempt >= RETRY_AFTER
|
||||
|
||||
|
||||
def history_with(history: list, entry: dict) -> list:
|
||||
return ([entry] + list(history or []))[:HISTORY_KEEP]
|
||||
|
||||
|
||||
async def _record(app, entry: dict, user_id: str | None, username: str) -> None:
|
||||
from steward.core.alerts import MetricBatch
|
||||
from steward.core.audit import log_audit
|
||||
from steward.core.settings import get_setting, set_setting
|
||||
|
||||
async with app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
history = await get_setting(db, "backup.history") or []
|
||||
await set_setting(db, "backup.history", history_with(history, entry))
|
||||
await set_setting(db, "backup.last_attempt_at", entry["at"])
|
||||
if entry["ok"]:
|
||||
await set_setting(db, "backup.last_success_at", entry["at"])
|
||||
batch = MetricBatch()
|
||||
batch.add(source_module="steward", resource_name="backup",
|
||||
metric_name="backup_bytes", value=float(entry["size"]))
|
||||
batch.add(source_module="steward", resource_name="backup",
|
||||
metric_name="backup_age_secs", value=0.0)
|
||||
await batch.record(db)
|
||||
await log_audit(app, user_id, username, "backup.made" if entry["ok"] else "backup.failed",
|
||||
entity_type="backup", entity_id=entry.get("name") or None,
|
||||
detail={k: entry[k] for k in ("name", "size", "secs", "error", "by") if entry.get(k)})
|
||||
|
||||
|
||||
async def run_backup(app, by: str = "schedule", user_id: str | None = None) -> dict:
|
||||
"""Make one backup and send it. Returns the history entry it recorded."""
|
||||
from steward.core.settings import get_all_settings
|
||||
|
||||
async with _running:
|
||||
started = time.monotonic()
|
||||
at = datetime.now(timezone.utc)
|
||||
entry = {"at": at.isoformat(), "ok": False, "by": by, "name": "", "size": 0,
|
||||
"secs": 0.0, "error": ""}
|
||||
async with app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
target = target_from(settings)
|
||||
try:
|
||||
if target is None:
|
||||
raise BackupError("The bucket isn't set up: fill in the endpoint, bucket and keys.")
|
||||
db_url = app.config["DATABASE_URL"]
|
||||
heads = await migration_heads(db_url)
|
||||
with tempfile.TemporaryDirectory(prefix="steward-backup-") as tmp:
|
||||
made = await make_backup(db_url, app.config["SECRET_KEY"], heads, Path(tmp), now=at)
|
||||
await put_file(target, made.name, made.path)
|
||||
entry.update(ok=True, name=made.name, size=made.size)
|
||||
except (BackupError, S3Error) as exc:
|
||||
entry["error"] = str(exc)
|
||||
except Exception as exc: # recorded and logged: a run never takes the scheduler down
|
||||
logger.exception("Backup failed")
|
||||
entry["error"] = f"Unexpected error: {exc}"
|
||||
entry["secs"] = round(time.monotonic() - started, 1)
|
||||
if entry["ok"]:
|
||||
logger.info("Backup %s sent (%d bytes, %.1fs)", entry["name"], entry["size"], entry["secs"])
|
||||
else:
|
||||
logger.warning("Backup failed: %s", entry["error"])
|
||||
await _record(app, entry, user_id, by if by != "schedule" else "")
|
||||
return entry
|
||||
|
||||
|
||||
def start_backup(app, by: str, user_id: str | None) -> bool:
|
||||
"""Start a backup in the background for a person; False if one is running."""
|
||||
if _running.locked():
|
||||
return False
|
||||
task = asyncio.get_running_loop().create_task(run_backup(app, by=by, user_id=user_id))
|
||||
_background.add(task)
|
||||
task.add_done_callback(_background.discard)
|
||||
return True
|
||||
|
||||
|
||||
def is_running() -> bool:
|
||||
return _running.locked()
|
||||
|
||||
|
||||
async def tick(app, now: datetime | None = None) -> None:
|
||||
"""The scheduled task: record how old the newest backup is, and make one
|
||||
when it is due."""
|
||||
from steward.core.alerts import MetricBatch
|
||||
from steward.core.formatting import zone
|
||||
from steward.core.settings import get_all_settings
|
||||
|
||||
now = now or datetime.now(timezone.utc)
|
||||
async with app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
if not settings.get("backup.enabled"):
|
||||
return
|
||||
last_success = _moment(settings.get("backup.last_success_at"))
|
||||
if last_success is not None:
|
||||
async with app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
batch = MetricBatch()
|
||||
batch.add(source_module="steward", resource_name="backup",
|
||||
metric_name="backup_age_secs", value=(now - last_success).total_seconds())
|
||||
await batch.record(db)
|
||||
if _running.locked():
|
||||
return
|
||||
if is_due(now, int(settings.get("backup.schedule_hour", 3)), zone(settings.get("backup.schedule_tz")),
|
||||
last_success, _moment(settings.get("backup.last_attempt_at"))):
|
||||
await run_backup(app)
|
||||
|
||||
|
||||
async def test_target(target: S3Target) -> tuple[bool, str]:
|
||||
"""Settings → Backups' Test button: can Steward reach the bucket with
|
||||
these keys? A listing refused as AccessDenied still proves the keys:
|
||||
an append-only key may not list, and uploads anyway."""
|
||||
try:
|
||||
found = await list_objects(target, PREFIX, limit=1000)
|
||||
except S3Error as exc:
|
||||
said = str(exc)
|
||||
if "AccessDenied" in said:
|
||||
return True, ("Reached the bucket and the keys were accepted. They may not list "
|
||||
"what is there, which an append-only key can't; uploads still work.")
|
||||
return False, said
|
||||
if not found:
|
||||
return True, f"Reached {target.bucket}; no Steward backups there yet."
|
||||
newest = max(found, key=lambda o: o.key)
|
||||
return True, f"Reached {target.bucket}: {len(found)} Steward backups, the newest {newest.key}."
|
||||
|
||||
|
||||
# ── What Settings → Backups shows ────────────────────────────────────────────
|
||||
|
||||
def shown_hour(hour: int, saved_tz, view_tz, now: datetime | None = None) -> int:
|
||||
"""The schedule's hour as a reader in `view_tz` sees it today."""
|
||||
local = (now or datetime.now(timezone.utc)).astimezone(saved_tz)
|
||||
return local.replace(hour=hour, minute=0, second=0, microsecond=0).astimezone(view_tz).hour
|
||||
|
||||
|
||||
def history_rows(history: list) -> list[dict]:
|
||||
"""The stored history with its times read back as datetimes."""
|
||||
return [entry | {"at": _moment(entry.get("at"))} for entry in history or []]
|
||||
|
||||
|
||||
def verdict(settings: dict, running: bool) -> dict:
|
||||
"""One line for the top of the page: {"tone": ok|warn|bad|off, "text", "moment"}.
|
||||
`moment` is the time the line is about, shown as "ago" by the template,
|
||||
which ends the sentence; a failure also carries `failed`, its `error` and
|
||||
`good` (the newest success, or None)."""
|
||||
rows = history_rows(settings.get("backup.history") or [])
|
||||
last_ok = _moment(settings.get("backup.last_success_at"))
|
||||
if running:
|
||||
return {"tone": "ok", "text": "A backup is being made now", "moment": None}
|
||||
if not settings.get("backup.enabled"):
|
||||
if target_from(settings) is None:
|
||||
return {"tone": "off", "text": "Backups are off, and no bucket is set up", "moment": None}
|
||||
return {"tone": "off", "text": "Scheduled backups are off; Back up now still works", "moment": None}
|
||||
if target_from(settings) is None:
|
||||
return {"tone": "bad", "text": "Backups are on, but the bucket isn't set up", "moment": None}
|
||||
if rows and not rows[0]["ok"]:
|
||||
# `good`: when the newest backup that did go was sent (None: never).
|
||||
return {"tone": "bad", "text": "The last backup failed", "moment": rows[0]["at"],
|
||||
"error": rows[0]["error"], "good": last_ok, "failed": True}
|
||||
if last_ok is None:
|
||||
return {"tone": "warn", "text": "No backup has been sent yet; the first goes within 15 minutes",
|
||||
"moment": None}
|
||||
return {"tone": "ok", "text": f"The newest backup is {rows[0]['name']} ({_size(rows[0]['size'])}), sent",
|
||||
"moment": last_ok}
|
||||
|
||||
|
||||
def _size(n) -> str:
|
||||
from steward.core.formatting import human_bytes
|
||||
return human_bytes(n)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""A small S3 client for sending backups: AWS Signature Version 4 over httpx,
|
||||
path-style addressing (endpoint/bucket/key), the three calls a backup needs.
|
||||
|
||||
Fabled Archives speaks S3 with path-style buckets and append-only keys by
|
||||
default (System 180 ruling: backups go there with an append-only key), so
|
||||
Steward only ever puts and lists; it never deletes. Retention is the
|
||||
target's business.
|
||||
|
||||
Written by hand rather than with boto3: three calls don't justify a large
|
||||
dependency, and signing is a pure function tests can hold to AWS's own
|
||||
published examples.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
EMPTY_SHA256 = hashlib.sha256(b"").hexdigest()
|
||||
TIMEOUT_SECONDS = 30.0
|
||||
# A backup is megabytes; a slow link still gets this long to send one.
|
||||
UPLOAD_TIMEOUT_SECONDS = 15 * 60
|
||||
CHUNK = 1 << 20
|
||||
|
||||
|
||||
class S3Error(Exception):
|
||||
"""Why a call to the bucket failed, said to the person."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class S3Target:
|
||||
endpoint: str # http(s)://host[:port], no bucket
|
||||
bucket: str
|
||||
access_key: str
|
||||
secret_key: str
|
||||
region: str = "us-east-1"
|
||||
verify_tls: bool = True
|
||||
|
||||
def url(self, key: str = "") -> str:
|
||||
base = f"{self.endpoint.rstrip('/')}/{self.bucket}"
|
||||
return f"{base}/{key}" if key else f"{base}/"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StoredObject:
|
||||
key: str
|
||||
size: int
|
||||
last_modified: str
|
||||
|
||||
|
||||
# ── Signing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _encode(text: str, keep_slash: bool = False) -> str:
|
||||
return quote(text, safe="-_.~" + ("/" if keep_slash else ""))
|
||||
|
||||
|
||||
def _hmac(key: bytes, msg: str) -> bytes:
|
||||
return hmac.new(key, msg.encode(), hashlib.sha256).digest()
|
||||
|
||||
|
||||
def sign(method: str, url: str, headers: dict[str, str], payload_sha256: str,
|
||||
access_key: str, secret_key: str, region: str, at: datetime,
|
||||
service: str = "s3") -> dict[str, str]:
|
||||
"""The request's headers with x-amz-date, x-amz-content-sha256 and
|
||||
Authorization added. Every header given is signed, Host always."""
|
||||
u = httpx.URL(url)
|
||||
stamp = at.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
day = stamp[:8]
|
||||
out = dict(headers)
|
||||
out["x-amz-date"] = stamp
|
||||
out["x-amz-content-sha256"] = payload_sha256
|
||||
signed = {k.lower(): " ".join(str(v).split()) for k, v in out.items()}
|
||||
signed["host"] = u.netloc.decode()
|
||||
names = sorted(signed)
|
||||
|
||||
query = sorted((_encode(k), _encode(v)) for k, v in u.params.multi_items())
|
||||
canonical = "\n".join([
|
||||
method.upper(),
|
||||
_encode(u.path or "/", keep_slash=True),
|
||||
"&".join(f"{k}={v}" for k, v in query),
|
||||
"".join(f"{n}:{signed[n]}\n" for n in names),
|
||||
";".join(names),
|
||||
payload_sha256,
|
||||
])
|
||||
scope = f"{day}/{region}/{service}/aws4_request"
|
||||
to_sign = "\n".join(["AWS4-HMAC-SHA256", stamp, scope,
|
||||
hashlib.sha256(canonical.encode()).hexdigest()])
|
||||
key = _hmac(_hmac(_hmac(_hmac(f"AWS4{secret_key}".encode(), day), region), service), "aws4_request")
|
||||
signature = hmac.new(key, to_sign.encode(), hashlib.sha256).hexdigest()
|
||||
out["Authorization"] = (f"AWS4-HMAC-SHA256 Credential={access_key}/{scope}, "
|
||||
f"SignedHeaders={';'.join(names)}, Signature={signature}")
|
||||
return out
|
||||
|
||||
|
||||
# ── Calls ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _says(response: httpx.Response) -> str:
|
||||
"""An S3 error response in words: its Code and Message when it has them."""
|
||||
try:
|
||||
root = ET.fromstring(response.content)
|
||||
code = root.findtext("Code") or ""
|
||||
message = root.findtext("Message") or ""
|
||||
except ET.ParseError:
|
||||
code, message = "", ""
|
||||
hints = {
|
||||
"SignatureDoesNotMatch": "the secret key is wrong",
|
||||
"InvalidAccessKeyId": "the access key isn't known there",
|
||||
"NoSuchBucket": "there is no such bucket",
|
||||
"AccessDenied": "the key isn't allowed to do that",
|
||||
}
|
||||
said = hints.get(code) or message or response.reason_phrase
|
||||
return f"HTTP {response.status_code}{f' {code}' if code else ''}: {said}."
|
||||
|
||||
|
||||
def _reach_error(target: S3Target, exc: httpx.HTTPError) -> S3Error:
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
return S3Error(f"{target.endpoint} didn't answer in time.")
|
||||
return S3Error(f"Couldn't reach {target.endpoint}: {exc}.")
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
while chunk := f.read(CHUNK):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
async def put_file(target: S3Target, key: str, path: Path, *,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
now: datetime | None = None) -> None:
|
||||
"""Upload one file as one object."""
|
||||
import asyncio
|
||||
digest = await asyncio.to_thread(file_sha256, path)
|
||||
size = path.stat().st_size
|
||||
url = target.url(key)
|
||||
headers = sign("PUT", url, {"Content-Length": str(size), "Content-Type": "application/x-tar"},
|
||||
digest, target.access_key, target.secret_key, target.region,
|
||||
now or datetime.now(timezone.utc))
|
||||
|
||||
async def body():
|
||||
with open(path, "rb") as f:
|
||||
while chunk := f.read(CHUNK):
|
||||
yield chunk
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(verify=target.verify_tls, timeout=UPLOAD_TIMEOUT_SECONDS,
|
||||
transport=transport) as client:
|
||||
response = await client.put(url, headers=headers, content=body())
|
||||
except httpx.HTTPError as exc:
|
||||
raise _reach_error(target, exc) from None
|
||||
if response.status_code // 100 != 2:
|
||||
raise S3Error(f"Upload of {key} refused: {_says(response)}")
|
||||
|
||||
|
||||
async def list_objects(target: S3Target, prefix: str = "", limit: int = 1000, *,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
now: datetime | None = None) -> list[StoredObject]:
|
||||
"""Up to `limit` objects under `prefix` (ListObjectsV2, one page)."""
|
||||
params = {"list-type": "2", "max-keys": str(limit)}
|
||||
if prefix:
|
||||
params["prefix"] = prefix
|
||||
url = str(httpx.URL(target.url(), params=params))
|
||||
headers = sign("GET", url, {}, EMPTY_SHA256, target.access_key, target.secret_key,
|
||||
target.region, now or datetime.now(timezone.utc))
|
||||
try:
|
||||
async with httpx.AsyncClient(verify=target.verify_tls, timeout=TIMEOUT_SECONDS,
|
||||
transport=transport) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
except httpx.HTTPError as exc:
|
||||
raise _reach_error(target, exc) from None
|
||||
if response.status_code != 200:
|
||||
raise S3Error(f"Listing {target.bucket} refused: {_says(response)}")
|
||||
try:
|
||||
root = ET.fromstring(response.content)
|
||||
except ET.ParseError:
|
||||
raise S3Error(f"{target.endpoint} answered a listing with something that isn't S3's XML.") from None
|
||||
out = []
|
||||
for item in root.iter():
|
||||
if item.tag.rsplit("}", 1)[-1] != "Contents":
|
||||
continue
|
||||
fields = {child.tag.rsplit("}", 1)[-1]: (child.text or "") for child in item}
|
||||
out.append(StoredObject(fields.get("Key", ""), int(fields.get("Size") or 0),
|
||||
fields.get("LastModified", "")))
|
||||
return out
|
||||
@@ -286,6 +286,8 @@ METRICS: dict[str, tuple[str, str]] = {
|
||||
"db_roundtrip_ms": ("Database round trip", "a trivial query to Steward's database, connection included"),
|
||||
"unwatched_secs": ("Time not watching", "how long Steward wasn't recording before this minute; 0 when unbroken"),
|
||||
"cleanup_secs": ("Hourly cleanup took", "how long the last retention and rollup run took"),
|
||||
"backup_age_secs": ("Newest backup's age", "time since Steward last sent a backup to its bucket"),
|
||||
"backup_bytes": ("Backup size", "the size of the last backup sent"),
|
||||
"open_fds": ("Open files", "files Steward itself has open"),
|
||||
"open_fds_pct": ("Open files (% of limit)", "files Steward has open, against its limit"),
|
||||
"scrape_ms": ("Scrape time", "how long the target took to hand over its metrics"),
|
||||
|
||||
@@ -176,6 +176,20 @@ DEFAULTS: dict[str, Any] = {
|
||||
# so "Sunday 08:00" means 08:00 where they are (#5378).
|
||||
"reports.schedule_tz": "",
|
||||
"reports.last_sent_at": "",
|
||||
# Steward's own backups (#5673), sent to an S3 bucket (Fabled Archives).
|
||||
"backup.enabled": False,
|
||||
"backup.s3_endpoint": "",
|
||||
"backup.s3_bucket": "",
|
||||
"backup.s3_region": "us-east-1",
|
||||
"backup.s3_access_key": "",
|
||||
"backup.s3_secret_key": "",
|
||||
"backup.s3_verify_tls": True,
|
||||
"backup.schedule_hour": 3, # an hour in backup.schedule_tz
|
||||
"backup.schedule_tz": "",
|
||||
"backup.last_success_at": "",
|
||||
"backup.last_attempt_at": "",
|
||||
# Newest first: {at, ok, by, name, size, secs, error}.
|
||||
"backup.history": [],
|
||||
# What the last hourly cleanup did (#5469), written by core/cleanup.py for
|
||||
# Settings → System: {"at": ISO time, "secs": float, "removed": [[what, n]]}.
|
||||
"cleanup.last_run": {},
|
||||
@@ -194,6 +208,7 @@ SECRET_KEYS: set[str] = {
|
||||
# A webhook URL carries its own credential (Discord, Slack and ntfy put the
|
||||
# token in the path).
|
||||
"webhook.url",
|
||||
"backup.s3_secret_key",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -687,6 +687,92 @@ async def send_report_now():
|
||||
return await _reports_page(settings, {"ok": ok, "message": message})
|
||||
|
||||
|
||||
# ── Backups ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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.core.formatting import zone
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
hour = job.shown_hour(int(settings.get("backup.schedule_hour", 3)),
|
||||
zone(settings.get("backup.schedule_tz")), zone(session.get("tz")))
|
||||
return await render_template(
|
||||
"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")))
|
||||
|
||||
|
||||
@settings_bp.get("/backups/")
|
||||
@require_role(UserRole.admin)
|
||||
async def backups():
|
||||
return await _backups_page()
|
||||
|
||||
|
||||
@settings_bp.post("/backups/")
|
||||
@require_role(UserRole.admin)
|
||||
async def save_backups():
|
||||
"""The hour is what the reader picked on their own clock, so it is kept in
|
||||
their zone. A blank secret key keeps the stored one."""
|
||||
form = await request.form
|
||||
endpoint = form.get("backup.s3_endpoint", "").strip()
|
||||
if endpoint and not endpoint.startswith(("http://", "https://")):
|
||||
return await _backups_page({"ok": False, "message":
|
||||
"The endpoint starts with http:// or https://, e.g. http://archives:8081."})
|
||||
try:
|
||||
hour = int(form.get("backup.schedule_hour", 3))
|
||||
except ValueError:
|
||||
hour = 3
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
await set_setting(db, "backup.enabled", "backup.enabled" in form)
|
||||
await set_setting(db, "backup.s3_endpoint", endpoint)
|
||||
await set_setting(db, "backup.s3_bucket", form.get("backup.s3_bucket", "").strip())
|
||||
await set_setting(db, "backup.s3_region", form.get("backup.s3_region", "").strip() or "us-east-1")
|
||||
await set_setting(db, "backup.s3_access_key", form.get("backup.s3_access_key", "").strip())
|
||||
secret = form.get("backup.s3_secret_key", "")
|
||||
if secret.strip():
|
||||
await set_setting(db, "backup.s3_secret_key", secret.strip())
|
||||
elif "backup.clear_secret" in form:
|
||||
await set_setting(db, "backup.s3_secret_key", "")
|
||||
await set_setting(db, "backup.s3_verify_tls", "backup.s3_verify_tls" in form)
|
||||
await set_setting(db, "backup.schedule_hour", min(max(hour, 0), 23))
|
||||
await set_setting(db, "backup.schedule_tz", session.get("tz") or "")
|
||||
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
||||
"settings.saved", detail={"section": "backups"})
|
||||
return redirect(url_for("settings.backups"))
|
||||
|
||||
|
||||
@settings_bp.post("/backups/test/")
|
||||
@require_role(UserRole.admin)
|
||||
async def test_backups():
|
||||
"""Reach the saved bucket with the saved keys (the operator asked, so the
|
||||
target is contacted)."""
|
||||
from steward.backup import job
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
target = job.target_from(settings)
|
||||
if target is None:
|
||||
return await _backups_page({"ok": False, "message":
|
||||
"Save the endpoint, bucket and both keys first."})
|
||||
ok, message = await job.test_target(target)
|
||||
return await _backups_page({"ok": ok, "message": message})
|
||||
|
||||
|
||||
@settings_bp.post("/backups/now/")
|
||||
@require_role(UserRole.admin)
|
||||
async def backup_now():
|
||||
from steward.backup import job
|
||||
started = job.start_backup(current_app._get_current_object(),
|
||||
by=session.get("username", "") or "an admin",
|
||||
user_id=session.get("user_id"))
|
||||
message = ("Making a backup now; it shows under Recent backups when it is sent."
|
||||
if started else "A backup is already being made.")
|
||||
return await _backups_page({"ok": started, "message": message})
|
||||
|
||||
|
||||
def _build_plugin_cfg_from_form(plugin: dict, form) -> dict:
|
||||
"""Extract config values for one plugin from a form submission."""
|
||||
name = plugin["_dir"]
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
("thresholds", "Thresholds & Retention", "/settings/thresholds/"),
|
||||
("notifications", "Notifications", "/settings/notifications/"),
|
||||
("reports", "Reports", "/settings/reports/"),
|
||||
("backups", "Backups", "/settings/backups/"),
|
||||
("auth", "Authentication", "/settings/auth/"),
|
||||
("users", "Users", "/settings/users/"),
|
||||
("ansible", "Ansible", "/settings/ansible/"),
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
{# 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. 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-<time>.tar</code>: Steward's configuration, its audit log
|
||||
and alert history, and its secret key. Metrics and check results are left out. 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></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>{% 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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="tone-dim">None yet.</p>
|
||||
{% endif %}
|
||||
<p class="field-hint" style="margin-top:0.75rem;">To bring Steward back from one, see
|
||||
<code>steward restore</code> in the backups guide (docs/core/backups.md).</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Every action Steward writes to the audit log reads as words on the audit
|
||||
page (steward/audit/service.py ACTIONS), not as its code."""
|
||||
import ast
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
import steward
|
||||
from steward.audit.service import ACTIONS
|
||||
|
||||
_REPO = pathlib.Path(steward.__file__).parent.parent
|
||||
_CODE = re.compile(r"[a-z_]+(\.[a-z_]+)+")
|
||||
# The writers: log_audit, and audit_request (imported as _audit in places).
|
||||
_WRITERS = {"log_audit", "audit_request", "_audit"}
|
||||
|
||||
|
||||
def logged_actions(source: str) -> set[str]:
|
||||
"""Literal action codes handed to a writer (positionally or as action=)."""
|
||||
found = set()
|
||||
for call in ast.walk(ast.parse(source)):
|
||||
if not (isinstance(call, ast.Call) and getattr(call.func, "id", getattr(call.func, "attr", "")) in _WRITERS):
|
||||
continue
|
||||
args = list(call.args) + [k.value for k in call.keywords if k.arg == "action"]
|
||||
found |= {a.value for a in args
|
||||
if isinstance(a, ast.Constant) and isinstance(a.value, str) and _CODE.fullmatch(a.value)}
|
||||
return found
|
||||
|
||||
|
||||
def test_every_logged_action_is_worded():
|
||||
files = [p for root in ("steward", "plugins") for p in (_REPO / root).rglob("*.py")
|
||||
if any(w in p.read_text() for w in _WRITERS)]
|
||||
logged = set().union(*(logged_actions(p.read_text()) for p in files))
|
||||
assert "settings.saved" in logged and "backup.made" in logged # the walk finds them
|
||||
assert logged - set(ACTIONS) == set(), "Word these in ACTIONS in steward/audit/service.py"
|
||||
|
||||
|
||||
def test_the_guard_can_fail():
|
||||
code = 'async def f(app):\n await log_audit(app, None, "", "widget.exploded", entity_type="widget")\n'
|
||||
assert logged_actions(code) - set(ACTIONS) == {"widget.exploded"}
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Steward's backup job (steward/backup/job.py): when a backup is due, what
|
||||
the Backups page says, and the Test button's words."""
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from quart import render_template, session
|
||||
|
||||
from steward.backup import job
|
||||
from steward.backup.s3 import S3Error, StoredObject
|
||||
from steward.core.settings import DEFAULTS
|
||||
|
||||
UTC = timezone.utc
|
||||
NY = ZoneInfo("America/New_York")
|
||||
|
||||
|
||||
def at(h, m=0, day=12):
|
||||
return datetime(2026, 10, day, h, m, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_the_slot_is_todays_hour_once_it_has_come_else_yesterdays():
|
||||
assert job.last_slot(at(2), 3, UTC) == at(3, day=11)
|
||||
assert job.last_slot(at(4), 3, UTC) == at(3)
|
||||
# 03:00 in New York in October is 07:00 UTC.
|
||||
assert job.last_slot(at(8), 3, NY) == at(7)
|
||||
|
||||
|
||||
def test_due_once_per_slot_and_retried_after_an_hour():
|
||||
assert job.is_due(at(4), 3, UTC, last_success=None, last_attempt=None)
|
||||
assert not job.is_due(at(4), 3, UTC, last_success=at(3, 1), last_attempt=at(3, 1))
|
||||
# Yesterday's success doesn't cover today's slot: a missed day is made up.
|
||||
assert job.is_due(at(9), 3, UTC, last_success=at(3, 1, day=11), last_attempt=at(3, 1, day=11))
|
||||
# A failure at 03:01 waits an hour before the next try.
|
||||
assert not job.is_due(at(3, 30), 3, UTC, last_success=None, last_attempt=at(3, 1))
|
||||
assert job.is_due(at(4, 2), 3, UTC, last_success=None, last_attempt=at(3, 1))
|
||||
|
||||
|
||||
def test_history_keeps_the_newest_thirty():
|
||||
history = [{"n": i} for i in range(30)]
|
||||
kept = job.history_with(history, {"n": "new"})
|
||||
assert len(kept) == job.HISTORY_KEEP and kept[0] == {"n": "new"} and kept[-1] == {"n": 28}
|
||||
|
||||
|
||||
def _settings(**over):
|
||||
return {**DEFAULTS, "backup.s3_endpoint": "http://archives:8081", "backup.s3_bucket": "steward",
|
||||
"backup.s3_access_key": "AK", "backup.s3_secret_key": "SK", **over}
|
||||
|
||||
|
||||
def test_a_target_needs_every_field():
|
||||
t = job.target_from(_settings())
|
||||
assert t.endpoint == "http://archives:8081" and t.bucket == "steward" and t.region == "us-east-1"
|
||||
assert job.target_from(_settings(**{"backup.s3_secret_key": ""})) is None
|
||||
|
||||
|
||||
def test_the_hour_is_shown_on_the_readers_clock():
|
||||
assert job.shown_hour(3, NY, UTC, now=at(12)) == 7
|
||||
assert job.shown_hour(3, UTC, UTC, now=at(12)) == 3
|
||||
|
||||
|
||||
def test_the_verdict_names_what_matters_first():
|
||||
ok = {"at": at(3).isoformat(), "ok": True, "name": "steward-x.tar", "size": 2048, "error": ""}
|
||||
bad = {"at": at(4).isoformat(), "ok": False, "name": "", "size": 0, "error": "HTTP 403: the key is wrong."}
|
||||
assert job.verdict(DEFAULTS, False)["tone"] == "off"
|
||||
assert job.verdict(_settings(**{"backup.enabled": True}), False)["tone"] == "warn"
|
||||
assert job.verdict(_settings(**{"backup.enabled": True, "backup.s3_bucket": ""}), False)["tone"] == "bad"
|
||||
good = job.verdict(_settings(**{"backup.enabled": True, "backup.history": [ok],
|
||||
"backup.last_success_at": ok["at"]}), False)
|
||||
assert good["tone"] == "ok" and "steward-x.tar (2.0 KiB)" in good["text"] and good["moment"] == at(3)
|
||||
failed = job.verdict(_settings(**{"backup.enabled": True, "backup.history": [bad, ok],
|
||||
"backup.last_success_at": ok["at"]}), False)
|
||||
assert failed["tone"] == "bad" and failed["error"] == bad["error"] and failed["good"] == at(3)
|
||||
assert job.verdict(DEFAULTS, True)["text"] == "A backup is being made now"
|
||||
|
||||
|
||||
def test_test_button_reads_an_append_only_refusal_as_reached(monkeypatch):
|
||||
target = job.target_from(_settings())
|
||||
|
||||
async def denied(*a, **k):
|
||||
raise S3Error("Listing steward refused: HTTP 403 AccessDenied: the key isn't allowed to do that.")
|
||||
|
||||
async def wrong(*a, **k):
|
||||
raise S3Error("Listing steward refused: HTTP 403 SignatureDoesNotMatch: the secret key is wrong.")
|
||||
|
||||
async def two(*a, **k):
|
||||
return [StoredObject("steward-20261011T030000Z.tar", 1, ""), StoredObject("steward-20261012T030000Z.tar", 1, "")]
|
||||
|
||||
monkeypatch.setattr(job, "list_objects", denied)
|
||||
ok, said = asyncio.run(job.test_target(target))
|
||||
assert ok and "append-only" in said
|
||||
monkeypatch.setattr(job, "list_objects", wrong)
|
||||
ok, said = asyncio.run(job.test_target(target))
|
||||
assert not ok and "secret key is wrong" in said
|
||||
monkeypatch.setattr(job, "list_objects", two)
|
||||
ok, said = asyncio.run(job.test_target(target))
|
||||
assert ok and "2 Steward backups, the newest steward-20261012T030000Z.tar" in said
|
||||
|
||||
|
||||
async def _render(app, **ctx):
|
||||
async with app.test_request_context("/settings/backups/"):
|
||||
session["user_role"] = "admin"
|
||||
session["tz"] = "America/New_York"
|
||||
return await render_template("settings/backups.html", **ctx)
|
||||
|
||||
|
||||
async def test_the_page_leads_with_the_verdict_and_lists_runs_in_the_readers_zone(app):
|
||||
rows = job.history_rows([
|
||||
{"at": at(4).isoformat(), "ok": False, "by": "schedule", "name": "", "size": 0, "secs": 1.2,
|
||||
"error": "HTTP 403 SignatureDoesNotMatch: the secret key is wrong."},
|
||||
{"at": at(3).isoformat(), "ok": True, "by": "admin", "name": "steward-20261012T030000Z.tar",
|
||||
"size": 2048, "secs": 3.4, "error": ""},
|
||||
])
|
||||
settings = _settings(**{"backup.enabled": True})
|
||||
v = {"tone": "bad", "text": "The last backup failed", "moment": at(4),
|
||||
"error": rows[0]["error"], "good": at(3), "failed": True}
|
||||
page = await _render(app, settings=settings, flash=None, shown_hour=3, verdict=v,
|
||||
history=rows, has_secret=True)
|
||||
main = page[page.index("<main"):]
|
||||
assert main.index("The last backup failed") < main.index("Bucket") < main.index("Recent backups")
|
||||
assert "the secret key is wrong" in main and "The newest good backup was sent" in main
|
||||
assert "kept; type to change" in main and 'value="SK"' not in main
|
||||
assert "the schedule" in main and "steward-20261012T030000Z.tar" in main and "2.0 KiB" in main
|
||||
assert "UTC" not in main
|
||||
assert '<a href="/settings/backups/" aria-current="page">' in page
|
||||
@@ -0,0 +1,112 @@
|
||||
"""The backup's S3 client (steward/backup/s3.py): signatures match AWS's
|
||||
published Signature Version 4 examples, and the calls read S3's answers."""
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from steward.backup import s3
|
||||
|
||||
# AWS's example credentials and moment ("Signature Calculations for the
|
||||
# Authorization Header", Amazon S3 API reference).
|
||||
KEY_ID = "AKIAIOSFODNN7EXAMPLE"
|
||||
SECRET = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
AT = datetime(2013, 5, 24, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _signature(headers):
|
||||
return headers["Authorization"].rsplit("Signature=", 1)[1]
|
||||
|
||||
|
||||
def test_aws_example_get_object():
|
||||
h = s3.sign("GET", "https://examplebucket.s3.amazonaws.com/test.txt", {"Range": "bytes=0-9"},
|
||||
s3.EMPTY_SHA256, KEY_ID, SECRET, "us-east-1", AT)
|
||||
assert "SignedHeaders=host;range;x-amz-content-sha256;x-amz-date," in h["Authorization"]
|
||||
assert "Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request" in h["Authorization"]
|
||||
assert _signature(h) == "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
|
||||
|
||||
|
||||
def test_aws_example_list_objects():
|
||||
h = s3.sign("GET", "https://examplebucket.s3.amazonaws.com/?max-keys=2&prefix=J", {},
|
||||
s3.EMPTY_SHA256, KEY_ID, SECRET, "us-east-1", AT)
|
||||
assert _signature(h) == "34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7"
|
||||
|
||||
|
||||
def test_a_changed_secret_changes_the_signature():
|
||||
a = s3.sign("GET", "https://examplebucket.s3.amazonaws.com/test.txt", {},
|
||||
s3.EMPTY_SHA256, KEY_ID, SECRET, "us-east-1", AT)
|
||||
b = s3.sign("GET", "https://examplebucket.s3.amazonaws.com/test.txt", {},
|
||||
s3.EMPTY_SHA256, KEY_ID, SECRET + "x", "us-east-1", AT)
|
||||
assert _signature(a) != _signature(b)
|
||||
|
||||
|
||||
TARGET = s3.S3Target("http://archives:8081/s3", "steward", KEY_ID, SECRET)
|
||||
|
||||
|
||||
def test_paths_are_path_style():
|
||||
assert TARGET.url("steward-20261012T000000Z.tar") == "http://archives:8081/s3/steward/steward-20261012T000000Z.tar"
|
||||
assert TARGET.url() == "http://archives:8081/s3/steward/"
|
||||
|
||||
|
||||
def test_put_sends_the_file_signed(tmp_path):
|
||||
f = tmp_path / "b.tar"
|
||||
f.write_bytes(b"x" * 5000)
|
||||
seen = {}
|
||||
|
||||
def handler(request: httpx.Request):
|
||||
seen["method"], seen["url"] = request.method, str(request.url)
|
||||
seen["body"] = request.read()
|
||||
seen["auth"] = request.headers["authorization"]
|
||||
seen["sha"] = request.headers["x-amz-content-sha256"]
|
||||
return httpx.Response(200)
|
||||
|
||||
asyncio.run(s3.put_file(TARGET, "b.tar", f, transport=httpx.MockTransport(handler)))
|
||||
assert seen["method"] == "PUT"
|
||||
assert seen["url"] == "http://archives:8081/s3/steward/b.tar"
|
||||
assert seen["body"] == b"x" * 5000
|
||||
assert seen["sha"] == s3.file_sha256(f)
|
||||
assert seen["auth"].startswith("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/")
|
||||
|
||||
|
||||
def test_a_refusal_says_why(tmp_path):
|
||||
f = tmp_path / "b.tar"
|
||||
f.write_bytes(b"x")
|
||||
body = b"<Error><Code>SignatureDoesNotMatch</Code><Message>no</Message></Error>"
|
||||
transport = httpx.MockTransport(lambda r: httpx.Response(403, content=body))
|
||||
with pytest.raises(s3.S3Error, match="HTTP 403 SignatureDoesNotMatch: the secret key is wrong"):
|
||||
asyncio.run(s3.put_file(TARGET, "b.tar", f, transport=transport))
|
||||
|
||||
|
||||
def test_unreachable_says_so(tmp_path):
|
||||
f = tmp_path / "b.tar"
|
||||
f.write_bytes(b"x")
|
||||
|
||||
def handler(request):
|
||||
raise httpx.ConnectError("refused", request=request)
|
||||
|
||||
with pytest.raises(s3.S3Error, match="Couldn't reach http://archives:8081/s3"):
|
||||
asyncio.run(s3.put_file(TARGET, "b.tar", f, transport=httpx.MockTransport(handler)))
|
||||
|
||||
|
||||
def test_list_reads_contents():
|
||||
xml = b"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Name>steward</Name><KeyCount>2</KeyCount>
|
||||
<Contents><Key>steward-20261011T000000Z.tar</Key><Size>1200</Size>
|
||||
<LastModified>2026-10-11T00:00:01.000Z</LastModified></Contents>
|
||||
<Contents><Key>steward-20261012T000000Z.tar</Key><Size>1300</Size>
|
||||
<LastModified>2026-10-12T00:00:01.000Z</LastModified></Contents>
|
||||
</ListBucketResult>"""
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["params"] = dict(request.url.params)
|
||||
return httpx.Response(200, content=xml)
|
||||
|
||||
got = asyncio.run(s3.list_objects(TARGET, "steward-", transport=httpx.MockTransport(handler)))
|
||||
assert seen["params"] == {"list-type": "2", "max-keys": "1000", "prefix": "steward-"}
|
||||
assert got == [
|
||||
s3.StoredObject("steward-20261011T000000Z.tar", 1200, "2026-10-11T00:00:01.000Z"),
|
||||
s3.StoredObject("steward-20261012T000000Z.tar", 1300, "2026-10-12T00:00:01.000Z"),
|
||||
]
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Integration: the backup job records each run against Postgres (#5673).
|
||||
pg_dump isn't in this lane (the publish smoke step runs the real one), so the
|
||||
dump and the upload are stubbed; what is real is everything Steward records:
|
||||
history, the last-success time, metrics, audit, and the sealed secret key.
|
||||
Requires STEWARD_DATABASE_URL."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@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 _configure(app, **over):
|
||||
from steward.core.settings import set_setting
|
||||
values = {"backup.enabled": True, "backup.s3_endpoint": "http://archives:8081",
|
||||
"backup.s3_bucket": "steward", "backup.s3_access_key": "AK",
|
||||
"backup.s3_secret_key": "SK", "backup.history": [],
|
||||
"backup.last_success_at": "", "backup.last_attempt_at": "", **over}
|
||||
async with app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
for k, v in values.items():
|
||||
await set_setting(db, k, v)
|
||||
|
||||
|
||||
def _stub(monkeypatch, *, fail_upload: str = ""):
|
||||
from steward.backup import job
|
||||
from steward.backup.dump import Made
|
||||
from steward.backup.s3 import S3Error
|
||||
sent = []
|
||||
|
||||
async def make_backup(database_url, secret_key, heads, workdir, now=None):
|
||||
path = workdir / "steward-stub.tar"
|
||||
path.write_bytes(b"x" * 4096)
|
||||
return Made(path, path.name, 4096, {"migration_heads": heads})
|
||||
|
||||
async def put_file(target, key, path, **kw):
|
||||
if fail_upload:
|
||||
raise S3Error(fail_upload)
|
||||
sent.append((target.bucket, key, path.read_bytes()))
|
||||
|
||||
monkeypatch.setattr(job, "make_backup", make_backup)
|
||||
monkeypatch.setattr(job, "put_file", put_file)
|
||||
return sent
|
||||
|
||||
|
||||
async def _state(app):
|
||||
from sqlalchemy import select
|
||||
from steward.core.settings import get_all_settings
|
||||
from steward.models.audit import AuditEvent
|
||||
from steward.models.metrics import PluginMetricLatest
|
||||
async with app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
metrics = dict((await db.execute(select(PluginMetricLatest.metric_name, PluginMetricLatest.value).where(
|
||||
PluginMetricLatest.source_module == "steward",
|
||||
PluginMetricLatest.resource_name == "backup"))).all())
|
||||
audits = list((await db.execute(select(AuditEvent.action).where(
|
||||
AuditEvent.action.like("backup.%")).order_by(AuditEvent.timestamp.desc(), AuditEvent.id.desc()).limit(1))).scalars())
|
||||
return settings, metrics, audits
|
||||
|
||||
|
||||
def test_a_sent_backup_is_recorded_and_a_failed_one_says_why(app, monkeypatch):
|
||||
from steward.backup import job
|
||||
|
||||
async def _go():
|
||||
await _configure(app)
|
||||
sent = _stub(monkeypatch)
|
||||
entry = await job.run_backup(app, by="admin-x")
|
||||
ok_state = await _state(app)
|
||||
|
||||
_stub(monkeypatch, fail_upload="Upload of steward-stub.tar refused: HTTP 403 AccessDenied.")
|
||||
failed = await job.run_backup(app)
|
||||
bad_state = await _state(app)
|
||||
return sent, entry, ok_state, failed, bad_state
|
||||
|
||||
sent, entry, (settings, metrics, audits), failed, (settings2, _, audits2) = asyncio.run(_go())
|
||||
assert sent == [("steward", "steward-stub.tar", b"x" * 4096)]
|
||||
assert entry["ok"] and entry["by"] == "admin-x" and entry["size"] == 4096
|
||||
assert settings["backup.history"][0]["name"] == "steward-stub.tar"
|
||||
assert settings["backup.last_success_at"] == entry["at"]
|
||||
assert metrics["backup_bytes"] == 4096 and metrics["backup_age_secs"] == 0
|
||||
assert audits == ["backup.made"]
|
||||
|
||||
assert not failed["ok"] and "AccessDenied" in failed["error"]
|
||||
assert [h["ok"] for h in settings2["backup.history"]] == [False, True]
|
||||
assert settings2["backup.last_success_at"] == entry["at"] # the good one still stands
|
||||
assert settings2["backup.last_attempt_at"] == failed["at"]
|
||||
assert audits2 == ["backup.failed"]
|
||||
|
||||
|
||||
def test_the_tick_records_the_age_and_backs_up_when_due(app, monkeypatch):
|
||||
from steward.backup import job
|
||||
|
||||
async def _go():
|
||||
long_ago = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat()
|
||||
await _configure(app, **{"backup.last_success_at": long_ago, "backup.last_attempt_at": long_ago})
|
||||
sent = _stub(monkeypatch)
|
||||
await job.tick(app)
|
||||
settings, metrics, _ = await _state(app)
|
||||
await job.tick(app) # not due again until the next slot
|
||||
return sent, settings
|
||||
|
||||
sent, settings = asyncio.run(_go())
|
||||
assert len(sent) == 1
|
||||
assert settings["backup.history"][0]["by"] == "schedule"
|
||||
|
||||
|
||||
def test_the_tick_does_nothing_while_backups_are_off(app, monkeypatch):
|
||||
from steward.backup import job
|
||||
|
||||
async def _go():
|
||||
await _configure(app, **{"backup.enabled": False})
|
||||
sent = _stub(monkeypatch)
|
||||
await job.tick(app)
|
||||
return sent
|
||||
|
||||
assert asyncio.run(_go()) == []
|
||||
|
||||
|
||||
def test_the_secret_key_is_sealed_and_a_blank_box_keeps_it(app):
|
||||
from sqlalchemy import select
|
||||
from steward.models.settings import AppSetting
|
||||
from steward.models.users import User, UserRole
|
||||
|
||||
async def _go():
|
||||
await _configure(app)
|
||||
uid = str(uuid.uuid4())
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
s.add(User(id=uid, username=f"bk-{uid[:8]}", email=f"bk-{uid[:8]}@test",
|
||||
password_hash="x", role=UserRole.admin, is_active=True))
|
||||
client = app.test_client()
|
||||
async with client.session_transaction() as sess:
|
||||
sess.update(user_id=uid, user_role="admin", username=f"bk-{uid[:8]}", _csrf="test-token",
|
||||
login_at=time.time(), tz="UTC")
|
||||
form = {"csrf_token": "test-token", "backup.enabled": "on", "backup.s3_endpoint": "http://archives:8081",
|
||||
"backup.s3_bucket": "steward", "backup.s3_region": "", "backup.s3_access_key": "AK2",
|
||||
"backup.s3_secret_key": "new-secret", "backup.schedule_hour": "4"}
|
||||
r1 = await client.post("/settings/backups/", form=form)
|
||||
async with app.db_sessionmaker() as db:
|
||||
stored = (await db.execute(select(AppSetting.value_json).where(
|
||||
AppSetting.key == "backup.s3_secret_key"))).scalar()
|
||||
r2 = await client.post("/settings/backups/", form=form | {"backup.s3_secret_key": ""})
|
||||
page = await client.get("/settings/backups/")
|
||||
from steward.core.settings import get_all_settings
|
||||
async with app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
return r1.status_code, stored, r2.status_code, page.status_code, await page.get_data(as_text=True), settings
|
||||
|
||||
s1, stored, s2, s3, page, settings = asyncio.run(_go())
|
||||
assert s1 == 302 and s2 == 302 and s3 == 200
|
||||
assert "enc:v1:" in stored and "new-secret" not in stored
|
||||
assert settings["backup.s3_secret_key"] == "new-secret"
|
||||
assert settings["backup.s3_access_key"] == "AK2" and settings["backup.s3_region"] == "us-east-1"
|
||||
assert settings["backup.schedule_hour"] == 4 and settings["backup.schedule_tz"] == "UTC"
|
||||
assert "new-secret" not in page and "kept; type to change" in page
|
||||
Reference in New Issue
Block a user