Files
FabledSteward/steward/backup/contents.py
T
bvandeusenandClaude Opus 5.5 f7a856cb90
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Failing after 1m54s
CI / publish (push) Skipped
feat(homeassistant): it shows system problems and installs updates on a weekly slot, and leaves device readings alone (#6748, #6749, #6747)
The connection's job is keeping Home Assistant running. Each poll now reads open
repairs, Supervisor health, disk, recovery mode and add-ons that should be running
over its websocket, for the page, the overview and the problems_open, disk_free_pct
and update_failed metrics. That needs a websocket client, so websockets joins the
dependencies.

A weekly slot, set on the page or through MCP and off until then, installs add-ons,
then Core, then the OS, each after its own confirmed backup, ending at the first
failure. The slot arithmetic moves from the host agent's cadence to steward.core.weekly
so both share it.

The device scrape goes: the page's Metrics section and a target's token_from from
4c9ed02 are removed, and a migration deletes targets using it, as they'd otherwise ask
with no token. A refused token still stops a scrape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:16:42 -05:00

133 lines
7.2 KiB
Python

"""What a backup carries. Every table Steward defines is named here as kept
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-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
# What would otherwise have to be set up again.
KEPT: frozenset[str] = frozenset({
"app_settings", "users", "api_tokens",
"hosts", "host_agent_registrations",
# A group's maintenance cadence (#5768): configuration.
"host_maintenance_plans",
"monitors",
"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",
# Syslog senders: which were added, under what name, and which ignored.
"syslog_sources",
"syslog_patterns",
"homeassistant_connection",
"homeassistant_schedule",
})
# 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",
# The timeline of what happened to the infrastructure: history.
"timeline_entries",
# Tickets: a record of known problems and how they were worked, not
# setup. Left out like the timeline, by the 2026-10-15 ruling.
"tickets", "ticket_links", "ticket_notes",
# 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",
"monitor_results",
# A host's downtime as its agent reported it: history, like the metrics.
"host_downtime",
# Maintenance jobs (#5767): run history, like ansible_runs.
"host_maintenance_jobs", "host_maintenance_runs",
"email_outbox", "email_outbox_events", "password_resets",
# Run history; its logs live on disk, not in the database.
"ansible_runs",
"docker_containers", "docker_disk_usage", "docker_events", "docker_host_status",
"docker_images", "docker_logs",
"docker_swarm_nodes", "docker_swarm_services",
"traefik_access_summaries", "traefik_certs", "traefik_global_stats", "traefik_metrics",
"traefik_status_codes",
"unifi_alarms", "unifi_client_snapshots", "unifi_devices", "unifi_dpi_snapshots",
"unifi_events", "unifi_fw_rules", "unifi_networks", "unifi_port_forwards",
"unifi_speedtest_results", "unifi_wan_stats", "unifi_wlan_stats",
"syslog_lines",
"homeassistant_updates",
})
# ── What a check compares (#5823) ────────────────────────────────────────────
# Checking a backup compares its kept rows with the ones in place. Some columns
# of kept tables are Steward's own record of what it last saw, not something
# anyone set up: they change by the minute, so comparing them would call every
# backup out of date. They are left out of the comparison (a restore still
# carries them). `updated_at` is bookkeeping wherever it appears.
ACTIVITY: dict[str, frozenset[str]] = {
"api_tokens": frozenset({"last_used_at"}),
# What the agent reports about its host; the enrolment (host_id,
# token_hash, token_created_at) is what is compared.
"host_agent_registrations": frozenset({
"agent_version", "kernel", "distro", "arch", "host_ip", "last_seen_at",
"clock_offset_s", "clock_checked_at", "updates_manager", "updates_pending",
"updates_security", "updates_checked_at", "updates_error", "reboot_required",
"reboot_required_since", "reboot_packages", "maint_helper", "drive_health",
"drive_health_at"}),
"host_maintenance_plans": frozenset({"last_slot_at"}),
"monitors": frozenset({"last_checked_at"}),
"ansible_schedules": frozenset({"last_run_at", "last_run_id", "last_error"}),
"snmp_devices": frozenset({"last_polled_at", "last_ok_at", "last_error", "missing"}),
"prometheus_targets": frozenset({"refused", "last_scraped_at", "last_ok_at", "last_error", "last_kept",
"last_dropped", "last_capped"}),
"homeassistant_connection": frozenset({"refused", "last_polled_at", "last_ok_at", "last_error",
"updates", "backup", "health"}),
"homeassistant_schedule": frozenset({"last_slot_at", "stopped"}),
# What the sender last sent; its address, name, state and host are setup.
"syslog_sources": frozenset({"hostname_seen", "first_seen_at", "last_seen_at", "lines_total",
"last_severity", "last_line"}),
# Scraped from the controller; the name and note are what someone set.
"unifi_known_clients": frozenset({"hostname", "ip", "mac_vendor", "is_blocked", "first_seen",
"last_seen", "scraped_at"}),
}
ACTIVITY_EVERYWHERE: frozenset[str] = frozenset({"updated_at"})
# Settings Steward writes as it runs: a record of its own work, not setup.
STATE_SETTINGS: frozenset[str] = frozenset({
"backup.history", "backup.last_attempt_at", "backup.last_success_at",
"reports.last_sent_at", "cleanup.last_run",
})
# A network client only counts once someone has named it or noted it; the
# rest are the controller's, and come and go as devices do.
HAND_SET_ONLY: dict[str, tuple[str, ...]] = {"unifi_known_clients": ("name", "note")}
# How a check groups what it found, in the order it shows them. A table
# not named here is shown under "Other".
AREAS: tuple[tuple[str, tuple[str, ...]], ...] = (
("Settings", ("app_settings",)),
("Users and API tokens", ("users", "api_tokens")),
("Hosts and agents", ("hosts", "host_agent_registrations", "host_maintenance_plans")),
("Monitors", ("monitors",)),
("Alert rules and maintenance windows", ("alert_rules", "maintenance_windows")),
("Ansible", ("ansible_targets", "ansible_groups", "ansible_target_groups", "ansible_schedules")),
("Dashboards", ("dashboards", "dashboard_widgets", "dashboard_share_tokens")),
("Plugins", ("snmp_devices", "prometheus_targets", "prometheus_widget_templates",
"unifi_known_clients", "syslog_sources", "syslog_patterns",
"homeassistant_connection", "homeassistant_schedule")),
)