diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index bb2c553..4166a1e 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -228,11 +228,12 @@ jobs: fi # A backup made by the image restores into an empty database, and - # Steward starts on it (milestone 507). It carries configuration - # (the marker setting) and leaves metrics' rows out. + # Steward starts on it (milestone 507). It carries settings (the + # marker setting) and leaves metrics and the audit log out. sql() { docker exec "$SMOKE-pg" psql -U steward -v ON_ERROR_STOP=1 -tA "$@"; } sql -d steward -c "INSERT INTO app_settings (key, value_json, updated_at) VALUES ('smoke.backup', '\"kept\"', now())" sql -d steward -c "INSERT INTO plugin_metrics (id, source_module, resource_name, metric_name, value, recorded_at) VALUES ('smoke-metric', 'smoke', 'smoke', 'smoke', 1, now())" + sql -d steward -c "INSERT INTO audit_events (id, timestamp, username, action) VALUES ('smoke-audit', now(), 'smoke', 'smoke.backup')" docker exec -u app "$SMOKE-app" steward backup --to /tmp/backups sql -d steward -c "CREATE DATABASE restored" docker exec -u app \ @@ -240,6 +241,7 @@ jobs: "$SMOKE-app" sh -c 'steward restore /tmp/backups/steward-*.tar' test "$(sql -d restored -c "SELECT value_json FROM app_settings WHERE key = 'smoke.backup'")" = '"kept"' test "$(sql -d restored -c "SELECT count(*) FROM plugin_metrics")" = "0" + test "$(sql -d restored -c "SELECT count(*) FROM audit_events")" = "0" test "$(sql -d restored -c "SELECT count(*) FROM alembic_version")" -ge 1 docker run -d --name "$SMOKE-restored" --network "$SMOKE-net" \ -e "STEWARD_DATABASE_URL=postgresql+asyncpg://steward:steward@$SMOKE-pg:5432/restored" \ diff --git a/docs/core/backups.md b/docs/core/backups.md index 223f810..a94174a 100644 --- a/docs/core/backups.md +++ b/docs/core/backups.md @@ -14,18 +14,28 @@ Steward back from it into an empty 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 | +A backup is for **system recovery**: it holds what you would otherwise have +to set up again, and no logs or history. + **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. +- settings, with the passwords and keys they hold; +- users and API tokens; +- hosts and their agent enrolments, so agents don't need enrolling again; +- monitors, alert rules, maintenance windows and maintenance cadences; +- Ansible inventory and schedules; +- dashboards and share links; +- SNMP devices, Prometheus targets and widget templates, UniFi client names. **Left out** (the tables come back empty and fill again as Steward runs): -- metrics and their rollups; +- the audit log; +- alert history and alert state. Each rule starts inactive and is watched + again within a minute; +- metric anomalies and their reviews; +- metrics, their rollups and learned normals; - monitor check results; - Docker, Traefik and UniFi history; -- Ansible run records; -- the email queue; -- password-reset links. +- Ansible run records and maintenance job history; +- the email queue, pending invites and 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. @@ -105,7 +115,7 @@ everything in the database's public schema first. 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 SET NULL link is cleared and the row kept; - 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 diff --git a/steward/alerts/service.py b/steward/alerts/service.py index 3349912..33f476d 100644 --- a/steward/alerts/service.py +++ b/steward/alerts/service.py @@ -744,6 +744,25 @@ async def reconcile_covering_rules(app) -> None: ", ".join(added) or "none", ", ".join(removed) or "none") +async def ensure_rule_states(app) -> int: + """A state row, inactive, for every rule watching one resource that has + none. Evaluation skips a rule without one, and a backup leaves alert state + out (System 180, 2026-10-15), so a restored rule comes back without it. + Run each minute by the core scheduler (alert_reconcile): a restored rule is + watched again within a minute. Returns how many rows were added.""" + async with app.db_sessionmaker() as db: + async with db.begin(): + has_state = select(AlertState.id).where(AlertState.rule_id == AlertRule.id).exists() + missing = (await db.execute(select(AlertRule.id).where( + AlertRule.covers == "resource", ~has_state))).scalars().all() + now = datetime.now(timezone.utc) + db.add_all([AlertState(rule_id=rule_id, state=AlertStateEnum.inactive, + last_transition_at=now) for rule_id in missing]) + if missing: + logger.info("Alert rules without a state row: added %d", len(missing)) + return len(missing) + + def part_of(rule: AlertRule, rules) -> AlertRule | None: """The covering rule a child belongs to, from a list of rules; None for a rule that stands alone.""" diff --git a/steward/app.py b/steward/app.py index ed897b2..19dc84e 100644 --- a/steward/app.py +++ b/steward/app.py @@ -429,9 +429,10 @@ def _register_core_tasks(app: Quart) -> None: await record_cleanup_secs(app, done["secs"]) async def run_alert_reconcile(): - from .alerts.service import reconcile_covering_rules + from .alerts.service import ensure_rule_states, reconcile_covering_rules from .core.alerts import check_silence, notify_after_maintenance - for step in (reconcile_covering_rules, check_silence, notify_after_maintenance): + for step in (reconcile_covering_rules, ensure_rule_states, check_silence, + notify_after_maintenance): try: await step(app) except Exception: diff --git a/steward/backup/contents.py b/steward/backup/contents.py index 870ef2c..087a0f1 100644 --- a/steward/backup/contents.py +++ b/steward/backup/contents.py @@ -2,38 +2,43 @@ or left out, and tests/core/test_backup_contents.py fails on one that is neither, so a new table is a decision someone makes in this file. -The operator's ruling (System 180 "Backup & Restore", 2026-10-12): a backup -carries configuration, the audit log and alert history. Metrics, check -results and the Docker, Traefik and UniFi history are left out: their tables -come back with their structure and no rows, and fill again as Steward runs. +The operator's ruling (System 180 "Backup & Restore", 2026-10-15): a backup +is for system recovery. It carries settings and keys, what would otherwise +have to be set up again, and no logs or history: not the audit log, not +alert history, not metrics. Left-out tables come back with their structure +and no rows, and fill again as Steward runs. A table that is in neither list (an external plugin's) is backed up whole: a table nobody thought about is kept, never lost. """ from __future__ import annotations -# Configuration, and the history the operator chose to keep. +# What would otherwise have to be set up again. KEPT: frozenset[str] = frozenset({ - "app_settings", "users", "user_invites", "api_tokens", + "app_settings", "users", "api_tokens", "hosts", "host_agent_registrations", # A group's maintenance cadence (#5768): configuration. "host_maintenance_plans", "monitors", - "alert_rules", "alert_states", "maintenance_windows", + "alert_rules", "maintenance_windows", "ansible_targets", "ansible_groups", "ansible_target_groups", "ansible_schedules", "dashboards", "dashboard_widgets", "dashboard_share_tokens", "snmp_devices", "prometheus_targets", "prometheus_widget_templates", # Names given to network clients by hand (UniFi's alias column). "unifi_known_clients", - # History kept by the ruling. - "audit_events", "alert_events", - # Metric anomalies and their reviews (#5740): what someone marked normal - # or real is a decision, kept like alert history. - "metric_anomalies", "metric_anomaly_spans", }) -# Rows that are measurements, logs or in-flight work: left out. +# Rows that are measurements, logs, history or in-flight work: left out. EXCLUDED: frozenset[str] = frozenset({ + # Logs and history (the 2026-10-15 ruling): a recovery doesn't need them. + "audit_events", "alert_events", + # Live state: each rule starts inactive, and alert_reconcile gives a + # restored rule its state row within a minute (ensure_rule_states). + "alert_states", + # Metric anomalies and their reviews (#5740): history of the metrics. + "metric_anomalies", "metric_anomaly_spans", + # A pending invite is in flight, not setup; it can be sent again. + "user_invites", "plugin_metrics", "plugin_metric_latest", "plugin_metrics_hourly", # Learned from the metrics above, so relearned once they fill again. "metric_norms", diff --git a/steward/cli.py b/steward/cli.py index 92458db..9f261d6 100644 --- a/steward/cli.py +++ b/steward/cli.py @@ -65,8 +65,8 @@ def main(ctx: click.Context, config: str, host: str, port: int, debug: bool) -> help="The folder to write the backup file into.") @click.pass_context def backup_command(ctx: click.Context, out_dir: Path) -> None: - """Write a backup file of this Steward (configuration, audit log, alert - history and the secret key) into a folder, without sending it anywhere.""" + """Write a backup file of this Steward (settings and the secret key: what + recovery needs, no logs or history) into a folder, without sending it anywhere.""" from .backup.dump import BackupError, make_backup from .backup.job import migration_heads from .config import load_bootstrap diff --git a/steward/core/alerts.py b/steward/core/alerts.py index 078747e..716db40 100644 --- a/steward/core/alerts.py +++ b/steward/core/alerts.py @@ -352,7 +352,8 @@ async def record_metrics(session: AsyncSession, rows: list[PluginMetric], phases for rule in rules: alert_state = states.get(rule.id) if alert_state is None: - logger.warning("No alert_state row for rule %s, skipping", rule.id) + # A restored rule (backups leave state out); alert_reconcile adds it. + logger.warning("No alert_state row for rule %s yet, skipping", rule.id) continue samples = sorted(by_key[(rule.source_module, rule.resource_name, rule.metric_name)], key=lambda r: r.recorded_at) diff --git a/steward/templates/settings/backups.html b/steward/templates/settings/backups.html index 1a6720c..13f1176 100644 --- a/steward/templates/settings/backups.html +++ b/steward/templates/settings/backups.html @@ -30,8 +30,9 @@
- Each backup is one file, steward-<time>.tar: Steward's configuration, its audit log
- and alert history, and its secret key. Metrics and check results are left out. Anyone holding a
+ Each backup is one file, steward-<time>.tar, 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.