Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Geplant als MINOR-Release **1.25.0** (Flotten-Rollout). - **Rollouts in Wellen (System › Rollouts):** Firmware-Updates für die ganze Flotte in Ringen, z. B. erst `ring-canary`, dann `ring-pilot`, dann `ring-prod` (Wellen aus Gerätegruppen). Jeder Host läuft über die bestehende geplante Wartung (Backup vor dem Update, CARP-Übergabe für den MASTER, Audit), immer nur ein Host gleichzeitig und nur ein Rollout zur selben Zeit; im Cluster zuerst der BACKUP, dann der MASTER. Nach jeder Welle folgen eine Beobachtungszeit und eine **Gesundheitsprüfung** (erreichbar, Zielversion installiert, CARP je VHID, Gateways und Dienste wie vorher, keine offenen Sperren, optional Sicherheits-Score). Rot hält den Rollout an (nach einer Nachprüfung zwei Minuten später) und meldet sich. Anhalten, Fortsetzen (prüft zuerst alles bisher Aktualisierte erneut), Welle freigeben, Host überspringen und Abbrechen auf der Lauf-Seite; die Seite zeigt Wellen, Hosts (Version vorher → nachher, aktueller Schritt) und die Prüfmatrix. - **Sicherheitsnetz:** Weicht die angebotene Version in einem späteren Ring von der des ersten Rings ab, hält der Rollout an (OPNsense installiert immer die neueste). Ein Host ohne Update wird übersprungen, ein fehlgeschlagener nie von selbst wiederholt. Angehalten wird auch, wenn die verantwortliche Person das Recht zum Ausrollen verliert, eine fremde Wartung im Cluster länger als 15 Minuten offen ist, der MASTER ohne Übergabe dran wäre oder ein Lauf älter als 14 Tage ist. Höchstens 10 Wellen und 100 Hosts je Plan. - **Wiederkehrende Wartungsfenster:** wöchentlich oder monatlich (n-ter Wochentag, letzter, fester Tag) in einer Zeitzone, mit Sommerzeit-Regel, Abschneidezeit („neue Hosts nur in den ersten N Minuten“) und einzelnen Terminen zum Auslassen oder Verschieben. Ein Plan mit Fenster startet neue Hosts nur darin; mit **automatischem Start** (nur stabile Updates) beginnt er zu jedem Termin von selbst, aber nur, solange Plan, Gruppen und Fenster seit der Aktivierung unverändert sind. Jede Änderung deaktiviert ihn. - **Geplante Wartung prüft sich selbst:** Neue Option „Ergebnis automatisch prüfen und abschließen“ (im Dialog vorausgewählt, bei Rollouts immer an). Nach dem Auftrag prüft OPNexus, ob die Firmware wieder bereit ist, der erwartete Neustart stattfand und die Version lesbar ist, und schließt die Wartung dann selbst ab. Bisher blieb sie auf „übergeben“ und sperrte Host und Cluster, bis jemand sie von Hand abschloss. - **Benachrichtigungen:** „Rollout angehalten“ und „Rollout nicht automatisch gestartet“ (ein Schalter, standardmäßig an). - **Rechte:** Rollout starten/aktivieren/fortsetzen/freigeben/überspringen/abbrechen und Wartungsfenster ändern gelten als Ausrollen (Aufgabentrennung); **Anhalten** darf jeder mit Schreibrecht. Update Center: Hosts im laufenden Rollout sind für manuelle Firmware-Aktionen gesperrt. Ein OPNexus-Update während eines Rollouts ist sicher, wird aber angekündigt. - **Getestet:** live auf den Test-Firewalls (OPNsense 26.7): automatischer Start im Fenster, Canary-Update 26.7.1_1 → 26.7.7, absichtlich gestoppter Dienst → Gate rot → angehalten (zweiter Ring unberührt, Benachrichtigung), Fortsetzen, Pilot-Update 26.7.6 → 26.7.7 auf dem damaligen MASTER mit CARP-Übergabe und einem Neustart des OPNexus-Backends mitten im Auftrag (genau ein Update-Auftrag), Freigabe, Host ohne Update übersprungen, Anhalten/Fortsetzen/Abbrechen von Hand, Termin auslassen. **Nur gemockt:** Major-Upgrade als Rollout, ein Cluster in *einer* Welle, Cluster-Prüfung vor dem zweiten Knoten, abweichende Zielversion, Zeitüberschreitungen, Rechteverlust, Fenster schließt mitten im Lauf; die Sommerzeit-Regeln sind Rechentests.
219 lines
11 KiB
Python
219 lines
11 KiB
Python
"""Health gate of a firmware rollout (1.25): is a host, after its update, as healthy as before?
|
|
|
|
``baseline(db, host)`` is taken right before a wave starts, ``evaluate(...)`` after the soak time
|
|
(and before each node of an already touched cluster, and on resume). Every check of every host
|
|
yields ``{"result": ok|warn|fail|skip, "code": <machine code>, "params": {...}}``; the UI translates
|
|
the code (rollouts.gate.code.*), so no German prose travels through here.
|
|
|
|
What the checks look at (all read-only on the firewall, except that the compliance check downloads
|
|
the configuration like the 6-hourly security check does):
|
|
|
|
* online — enabled, the API answers, firmware job idle (``running == ready``).
|
|
* version — the installed version is the rollout's target version.
|
|
* carp — per cluster: every node online, exactly one MASTER per VHID, no node in CARP
|
|
maintenance mode, no CARP auto-revert timer running.
|
|
* gateways — gateways that were up in the baseline are up again; a gateway that was already bad
|
|
before only warns (1.24 ``get_gateway_health``).
|
|
* services — services that ran in the baseline run again.
|
|
* locks — no open maintenance operation, restore or unconfirmed deployment in the host/cluster.
|
|
* compliance — security score not lower than before: warn (default) or fail. A weak signal: an
|
|
update may legitimately change defaults.
|
|
|
|
A reading that cannot be taken is never "ok": unreadable now but readable in the baseline fails,
|
|
unreadable in both is "skip" (nothing to compare; e.g. an API key without that permission).
|
|
"""
|
|
import asyncio
|
|
from collections import defaultdict
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.carp_maintenance_window import CarpMaintenanceWindow
|
|
from app.models.compliance_result import ComplianceResult
|
|
from app.models.host import OpnsenseHost
|
|
from app.services import compliance_service, gateway_health_service, opnsense_client as client
|
|
from app.services.host_maintenance_service import _shared_change_blockers, peers
|
|
from app.services.host_status_service import get_statuses
|
|
from app.services.resource_monitor_service import _services_compact
|
|
|
|
CHECKS = ("online", "version", "carp", "gateways", "services", "locks", "compliance")
|
|
DEFAULT_CHECKS = {"online": True, "version": True, "carp": True, "gateways": True, "services": True, "locks": True,
|
|
"compliance": False}
|
|
ALWAYS_ON = ("online", "version", "locks") # a rollout without these would be blind
|
|
_RANK = {"skip": 0, "ok": 0, "warn": 1, "fail": 2}
|
|
|
|
|
|
def _r(result: str, code: str, **params) -> dict:
|
|
return {"result": result, "code": code, "params": params}
|
|
|
|
|
|
async def _optional(coro):
|
|
try:
|
|
return await coro
|
|
except client.OpnsenseAPIError:
|
|
return None
|
|
|
|
|
|
async def _gateways(host) -> dict | None:
|
|
gws = await _optional(gateway_health_service.get_gateway_health(host))
|
|
return None if gws is None else {g.name: g.state for g in gws}
|
|
|
|
|
|
async def _services(host) -> dict | None:
|
|
listing = await _optional(client.search_services(host))
|
|
return None if listing is None else _services_compact(listing)
|
|
|
|
|
|
async def baseline(db: AsyncSession, host: OpnsenseHost) -> dict:
|
|
info, gateways, services = await asyncio.gather(
|
|
_optional(client.get_firmware_info(host)), _gateways(host), _services(host))
|
|
score = await db.scalar(select(ComplianceResult.score).where(ComplianceResult.host_id == host.id))
|
|
return {
|
|
"version": (info or {}).get("product_version"),
|
|
"gateways": gateways,
|
|
"services": services,
|
|
"compliance_score": score,
|
|
}
|
|
|
|
|
|
def _check_gateways(before: dict | None, now: dict | None) -> dict:
|
|
if now is None:
|
|
return _r("skip", "unreadable_both") if before is None else _r("fail", "unreadable")
|
|
if before is None:
|
|
bad = sorted(n for n, s in now.items() if s == "offline")
|
|
return _r("warn", "gateway_down", names=", ".join(bad)) if bad else _r("ok", "no_baseline")
|
|
down = sorted(n for n, s in before.items() if s in ("online", "unknown") and now.get(n) == "offline")
|
|
gone = sorted(n for n in before if n not in now)
|
|
degraded = sorted(n for n, s in now.items() if s == "degraded" and before.get(n) != "degraded")
|
|
still_bad = sorted(n for n, s in before.items() if s in ("offline", "degraded") and now.get(n) in ("offline", "degraded"))
|
|
# "unknown" (not monitored) going to "offline" is a real change too: monitoring just started and reports down.
|
|
if down:
|
|
return _r("fail", "gateway_down", names=", ".join(down))
|
|
if gone:
|
|
return _r("warn", "gateway_missing", names=", ".join(gone))
|
|
if degraded:
|
|
return _r("warn", "gateway_degraded", names=", ".join(degraded))
|
|
if still_bad:
|
|
return _r("warn", "gateway_bad_before", names=", ".join(still_bad))
|
|
return _r("ok", "ok")
|
|
|
|
|
|
def _check_services(before: dict | None, now: dict | None) -> dict:
|
|
if now is None:
|
|
return _r("skip", "unreadable_both") if before is None else _r("fail", "unreadable")
|
|
if before is None:
|
|
return _r("ok", "no_baseline")
|
|
stopped = sorted(name.split("/")[0] for name, running in before.items() if running == 1 and now.get(name) != 1)
|
|
if stopped:
|
|
return _r("fail", "service_stopped", names=", ".join(dict.fromkeys(stopped)))
|
|
return _r("ok", "ok")
|
|
|
|
|
|
async def _check_carp(db: AsyncSession, fleet: list[OpnsenseHost], statuses: dict) -> dict:
|
|
offline = sorted(h.name for h in fleet if h.enabled and statuses.get(h.id, {}).get("status") != "online")
|
|
if offline:
|
|
return _r("fail", "node_offline", names=", ".join(offline))
|
|
online = [h for h in fleet if statuses.get(h.id, {}).get("status") == "online"]
|
|
demoted = sorted(h.name for h in online if statuses[h.id].get("carp_maintenance_mode"))
|
|
if demoted:
|
|
return _r("fail", "maintenance_mode", names=", ".join(demoted))
|
|
timers = (await db.scalars(select(CarpMaintenanceWindow.host_name).where(
|
|
CarpMaintenanceWindow.host_id.in_([h.id for h in fleet]), CarpMaintenanceWindow.status == "active"))).all()
|
|
if timers:
|
|
return _r("fail", "auto_revert_active", names=", ".join(sorted(timers)))
|
|
masters: dict[str, int] = defaultdict(int)
|
|
broken: set[str] = set()
|
|
for h in online:
|
|
for vhid, state in (statuses[h.id].get("carp_vips") or {}).items():
|
|
masters[vhid] += state == "MASTER"
|
|
if state not in ("MASTER", "BACKUP"):
|
|
broken.add(vhid)
|
|
wrong = sorted(set(v for v, n in masters.items() if n != 1) | broken, key=lambda v: (len(v), v))
|
|
if wrong:
|
|
return _r("fail", "vhid_roles", vhids=", ".join(wrong))
|
|
if not masters and not any(statuses[h.id].get("carp_status") == "MASTER" for h in online):
|
|
return _r("fail", "no_master")
|
|
return _r("ok", "ok")
|
|
|
|
|
|
async def evaluate(db: AsyncSession, hosts: list[OpnsenseHost], baselines: dict, *, target_version: str | None,
|
|
checks: dict | None = None, compliance_mode: str = "warn") -> dict:
|
|
"""{"hosts": {host_id: {check: result}}, "verdict": ok|warn|fail}. ``baselines`` is keyed by str(host.id)."""
|
|
enabled = {**DEFAULT_CHECKS, **(checks or {})}
|
|
for name in ALWAYS_ON:
|
|
enabled[name] = True
|
|
results: dict[str, dict] = {}
|
|
fleets: dict[str, list[OpnsenseHost]] = {}
|
|
for host in hosts:
|
|
fleets[str(host.id)] = await peers(db, host) if host.cluster_name else [host]
|
|
every = {h.id: h for fleet in fleets.values() for h in fleet}
|
|
statuses = await get_statuses(list(every.values()))
|
|
carp_cache: dict[str, dict] = {}
|
|
for host in hosts:
|
|
hid = str(host.id)
|
|
before = baselines.get(hid) or {}
|
|
row: dict[str, dict] = {}
|
|
st = statuses.get(host.id, {})
|
|
running = await _optional(client.get_firmware_running(host)) if st.get("status") == "online" else None
|
|
if not host.enabled:
|
|
row["online"] = _r("fail", "disabled")
|
|
elif st.get("status") != "online":
|
|
row["online"] = _r("fail", "offline")
|
|
elif (running or {}).get("status") != "ready":
|
|
row["online"] = _r("fail", "firmware_busy")
|
|
else:
|
|
row["online"] = _r("ok", "ok")
|
|
reachable = row["online"]["result"] == "ok"
|
|
|
|
if enabled["version"]:
|
|
info = await _optional(client.get_firmware_info(host)) if reachable else None
|
|
version = (info or {}).get("product_version")
|
|
if not target_version:
|
|
row["version"] = _r("skip", "no_target")
|
|
elif version is None:
|
|
row["version"] = _r("fail", "unreadable")
|
|
elif version != target_version:
|
|
row["version"] = _r("fail", "version_mismatch", expected=target_version, actual=version)
|
|
else:
|
|
row["version"] = _r("ok", "ok", actual=version)
|
|
|
|
if enabled["carp"]:
|
|
if not host.cluster_name:
|
|
row["carp"] = _r("skip", "no_cluster")
|
|
else:
|
|
if host.cluster_name not in carp_cache:
|
|
carp_cache[host.cluster_name] = await _check_carp(db, fleets[hid], statuses)
|
|
row["carp"] = carp_cache[host.cluster_name]
|
|
|
|
if enabled["gateways"]:
|
|
row["gateways"] = _check_gateways(before.get("gateways"), await _gateways(host) if reachable else None)
|
|
if enabled["services"]:
|
|
row["services"] = _check_services(before.get("services"), await _services(host) if reachable else None)
|
|
|
|
blockers = await _shared_change_blockers(db, fleets[hid])
|
|
row["locks"] = _r("fail", "open_locks", count=len(blockers)) if blockers else _r("ok", "ok")
|
|
|
|
if enabled["compliance"]:
|
|
base_score = before.get("compliance_score")
|
|
if base_score is None:
|
|
row["compliance"] = _r("skip", "no_baseline")
|
|
elif not reachable:
|
|
row["compliance"] = _r("fail", "unreadable")
|
|
else:
|
|
fresh = await compliance_service.check_host(db, host)
|
|
if fresh.score is None:
|
|
row["compliance"] = _r("warn", "unreadable")
|
|
elif fresh.score < base_score:
|
|
row["compliance"] = _r("fail" if compliance_mode == "fail" else "warn", "score_lower",
|
|
before=base_score, after=fresh.score)
|
|
else:
|
|
row["compliance"] = _r("ok", "ok", before=base_score, after=fresh.score)
|
|
results[hid] = row
|
|
worst = max((_RANK[c["result"]] for row in results.values() for c in row.values()), default=0)
|
|
return {"hosts": results, "verdict": ("ok", "warn", "fail")[worst]}
|
|
|
|
|
|
def failed_checks(gate: dict) -> list[str]:
|
|
"""Flat "host-id:check" list of everything that failed (for notifications and the audit trail)."""
|
|
return [f"{hid}:{name}" for hid, row in (gate.get("hosts") or {}).items() for name, c in row.items() if c["result"] == "fail"]
|