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.
307 lines
13 KiB
Python
307 lines
13 KiB
Python
"""auto_verify (1.25): an unattended action checks its own result and closes the operation,
|
|
so the host/cluster is not left locked until a human clicks "Wartung abschließen"."""
|
|
import asyncio
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import httpx
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from app.database import SessionLocal
|
|
from app.models import HostOperation
|
|
from app.models.audit import AuditEvent
|
|
from app.models.scheduled_maintenance import ScheduledMaintenance
|
|
from app.services import scheduled_maintenance_service as svc
|
|
from tests.test_rules import _create_host
|
|
from tests.test_scheduled_handover import FakeCluster, _setup
|
|
|
|
H = "https://fw-solo.test"
|
|
|
|
|
|
class FakeHost:
|
|
"""One stand-alone firewall: job = ready | running | down; boot = what systemTime reports."""
|
|
|
|
def __init__(self, respx_mock, *, firmware="update", reboot_flag="1", version="26.7.5"):
|
|
self.job, self.boot, self.version = "ready", "Mon Oct 5 10:00:00 UTC 2026", version
|
|
self.update = respx_mock.post(f"{H}/api/core/firmware/update").respond(200, json={"status": "ok"})
|
|
self.reboot = respx_mock.post(f"{H}/api/core/system/reboot").respond(200, json={"status": "ok"})
|
|
respx_mock.get(f"{H}/api/core/backup/download/this").respond(200, content=b"<opnsense/>")
|
|
respx_mock.get(f"{H}/api/diagnostics/interface/getVipStatus").mock(side_effect=self._vip)
|
|
respx_mock.get(f"{H}/api/core/firmware/info").mock(side_effect=lambda r: self._up(json={"product_version": self.version}))
|
|
respx_mock.get(f"{H}/api/core/firmware/status").respond(
|
|
200, json={"status": firmware, "all_packages": {}, "status_msg": "x", "status_reboot": reboot_flag})
|
|
respx_mock.get(f"{H}/api/core/firmware/running").mock(side_effect=lambda r: self._up(json={"status": self.job}))
|
|
respx_mock.get(f"{H}/api/diagnostics/system/systemTime").mock(side_effect=lambda r: self._up(json={"boottime": self.boot}))
|
|
|
|
def _up(self, **kw):
|
|
if self.job == "down":
|
|
raise httpx.ConnectError("rebooting")
|
|
return httpx.Response(200, **kw)
|
|
|
|
def _vip(self, request):
|
|
return self._up(json={"rows": [], "carp": {"allow": "1", "maintenancemode": False, "demotion": "0", "status_msg": ""}})
|
|
|
|
def reboot_now(self, new_boot="Wed Oct 21 03:00:00 UTC 2026", version=None):
|
|
self.boot = new_boot
|
|
if version:
|
|
self.version = version
|
|
|
|
|
|
async def _plan(host, action="update", *, auto_verify=True, handover=False):
|
|
async with SessionLocal() as db:
|
|
s = ScheduledMaintenance(
|
|
host_id=uuid.UUID(host["id"]), host_name=host["name"], action=action, carp_handover=handover,
|
|
auto_verify=auto_verify, run_at=datetime.now(timezone.utc) - timedelta(minutes=1), actor="admin",
|
|
created_at=datetime.now(timezone.utc))
|
|
db.add(s)
|
|
await db.commit()
|
|
return s.id
|
|
|
|
|
|
async def _fire():
|
|
async with SessionLocal() as db:
|
|
return await svc.fire_due(db)
|
|
|
|
|
|
async def _advance():
|
|
async with SessionLocal() as db:
|
|
return await svc.advance_running(db)
|
|
|
|
|
|
async def _get(sid):
|
|
async with SessionLocal() as db:
|
|
s = await db.get(ScheduledMaintenance, sid)
|
|
op = await db.get(HostOperation, s.host_operation_id) if s.host_operation_id else None
|
|
return s, op
|
|
|
|
|
|
async def _shift(sid, *, phase=None, ready=None):
|
|
"""Pretend the phase began / the firmware turned ready that long ago."""
|
|
async with SessionLocal() as db:
|
|
s = await db.get(ScheduledMaintenance, sid)
|
|
if phase is not None:
|
|
s.phase_since = datetime.now(timezone.utc) - phase
|
|
if ready is not None:
|
|
s.progress = {**s.progress, "ready_since": (datetime.now(timezone.utc) - ready).isoformat()}
|
|
await db.commit()
|
|
|
|
|
|
async def _settle(sid):
|
|
"""verify needs the firmware idle for VERIFY_SETTLE: one tick to notice, then pretend it has been a while."""
|
|
await _advance()
|
|
await _shift(sid, ready=svc.VERIFY_SETTLE + timedelta(seconds=1))
|
|
await _advance()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_is_verified_and_the_operation_closed(client, respx_mock):
|
|
fw = FakeHost(respx_mock)
|
|
host = await _create_host(client, "fw-solo", H)
|
|
sid = await _plan(host)
|
|
await _fire()
|
|
s, op = await _get(sid)
|
|
assert (s.status, s.phase, op.status, fw.update.call_count) == ("running", "job_wait", "submitted", 1)
|
|
assert s.progress["reboot_expected"] is True and s.progress["boot_before"] == fw.boot
|
|
|
|
fw.job = "running"
|
|
await _advance()
|
|
fw.job = "down"
|
|
await _advance()
|
|
fw.job = "ready"
|
|
fw.reboot_now(version="26.7.7")
|
|
await _advance()
|
|
assert (await _get(sid))[0].phase == "verify"
|
|
await _settle(sid)
|
|
|
|
s, op = await _get(sid)
|
|
assert s.status == "succeeded" and s.phase is None
|
|
assert s.result["auto_verified"] is True and s.result["version_after"] == "26.7.7" and s.result["version_before"] == "26.7.5"
|
|
assert op.status == "acknowledged" and "Automatisch geprüft" in op.message
|
|
assert fw.update.call_count == 1
|
|
# the host is free again: a new preview is not blocked by a forgotten operation
|
|
resp = await client.get(f"/api/hosts/{host['id']}/maintenance")
|
|
assert not any("frühere Wartungsaktion" in b for b in resp.json()["state"]["blocked"])
|
|
async with SessionLocal() as db:
|
|
events = (await db.scalars(select(AuditEvent).where(AuditEvent.kind == "host"))).all()
|
|
assert {e.actor for e in events if (e.details or {}).get("scheduled")} == {"admin (geplant)"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_boot_time_proves_a_reboot_the_poll_never_saw(client, respx_mock):
|
|
fw = FakeHost(respx_mock)
|
|
host = await _create_host(client, "fw-solo", H)
|
|
sid = await _plan(host)
|
|
await _fire()
|
|
await _shift(sid, phase=timedelta(0))
|
|
fw.reboot_now() # rebooted between two polls: never seen offline
|
|
async with SessionLocal() as db: # the job grace has passed
|
|
s = await db.get(ScheduledMaintenance, sid)
|
|
s.progress = {**s.progress, "job_started_at": (datetime.now(timezone.utc) - svc.JOB_GRACE).isoformat()}
|
|
await db.commit()
|
|
await _advance()
|
|
assert (await _get(sid))[0].phase == "verify"
|
|
await _settle(sid)
|
|
s, op = await _get(sid)
|
|
assert s.status == "succeeded" and s.result["rebooted"] is True and op.status == "acknowledged"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_expected_reboot_never_happened_ends_unknown_after_timeout(client, respx_mock):
|
|
FakeHost(respx_mock)
|
|
host = await _create_host(client, "fw-solo", H)
|
|
sid = await _plan(host)
|
|
await _fire()
|
|
async with SessionLocal() as db:
|
|
s = await db.get(ScheduledMaintenance, sid)
|
|
s.progress = {**s.progress, "job_started_at": (datetime.now(timezone.utc) - svc.JOB_GRACE).isoformat()}
|
|
await db.commit()
|
|
await _advance()
|
|
await _settle(sid)
|
|
s, op = await _get(sid)
|
|
assert (s.status, s.phase, op.status) == ("running", "verify", "submitted") # waiting, not done
|
|
assert "Neustart" in s.progress["waiting_for"]
|
|
await _shift(sid, phase=svc.VERIFY_TIMEOUT)
|
|
await _advance()
|
|
s, op = await _get(sid)
|
|
assert s.status == "failed" and op.status == "unknown" and "nicht bestanden" in s.result["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ready_must_hold_a_while_a_short_ready_before_the_reboot_does_not_count(client, respx_mock):
|
|
fw = FakeHost(respx_mock, reboot_flag="0")
|
|
host = await _create_host(client, "fw-solo", H)
|
|
sid = await _plan(host)
|
|
await _fire()
|
|
fw.job = "running"
|
|
await _advance()
|
|
fw.job = "ready"
|
|
await _advance() # job_wait -> verify
|
|
await _advance() # ready noticed
|
|
s, _ = await _get(sid)
|
|
assert s.phase == "verify" and s.progress["ready_since"]
|
|
fw.job = "running" # busy again (e.g. the reboot at the end of the update)
|
|
await _advance()
|
|
assert (await _get(sid))[0].progress["ready_since"] is None
|
|
fw.job = "ready"
|
|
await _settle(sid)
|
|
assert (await _get(sid))[0].status == "succeeded"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_without_auto_verify_nothing_changes(client, respx_mock):
|
|
fw = FakeHost(respx_mock)
|
|
host = await _create_host(client, "fw-solo", H)
|
|
sid = await _plan(host, auto_verify=False)
|
|
await _fire()
|
|
s, op = await _get(sid)
|
|
assert (s.status, s.phase, op.status, fw.update.call_count) == ("succeeded", None, "submitted", 1)
|
|
assert await _advance() == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_human_closing_the_operation_stops_the_check(client, respx_mock):
|
|
FakeHost(respx_mock)
|
|
host = await _create_host(client, "fw-solo", H)
|
|
sid = await _plan(host)
|
|
await _fire()
|
|
s, op = await _get(sid)
|
|
async with SessionLocal() as db:
|
|
(await db.get(HostOperation, op.id)).status = "cancelled"
|
|
await db.commit()
|
|
await _advance()
|
|
s, op = await _get(sid)
|
|
assert s.status == "failed" and "von Hand beendet" in s.result["error"] and op.status == "cancelled"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handover_verifies_before_taking_the_traffic_back(client, respx_mock):
|
|
cluster = FakeCluster(respx_mock)
|
|
cluster.boot = "before"
|
|
|
|
def system_time(request):
|
|
if cluster.job == "down":
|
|
raise httpx.ConnectError("down")
|
|
return httpx.Response(200, json={"boottime": cluster.boot})
|
|
respx_mock.get("https://fw-a.test/api/diagnostics/system/systemTime").mock(side_effect=system_time)
|
|
a, _b = await _setup(client)
|
|
async with SessionLocal() as db:
|
|
s = ScheduledMaintenance(host_id=uuid.UUID(a["id"]), host_name="fw-a", action="update", carp_handover=True, auto_verify=True,
|
|
run_at=datetime.now(timezone.utc) - timedelta(minutes=1), actor="admin", created_at=datetime.now(timezone.utc))
|
|
db.add(s)
|
|
await db.commit()
|
|
sid = s.id
|
|
await _fire()
|
|
await _advance() # partner MASTER -> job submitted
|
|
cluster.job = "down"
|
|
await _advance()
|
|
cluster.job = "ready"
|
|
cluster.boot = "after"
|
|
await _advance()
|
|
s, _ = await _get(sid)
|
|
assert s.phase == "verify" and cluster.toggles.call_count == 1 # still demoted while checking
|
|
await _settle(sid)
|
|
assert (await _get(sid))[0].phase == "leave" and cluster.toggles.call_count == 1
|
|
await _advance() # leave
|
|
await _advance() # leave_wait -> done
|
|
s, op = await _get(sid)
|
|
assert s.status == "succeeded" and s.result["handover"] is True and s.result["auto_verified"] is True
|
|
assert s.result["rebooted"] is True # found live: the handover path dropped what verify had seen
|
|
assert op.status == "acknowledged" and cluster.toggles.call_count == 2 and cluster.update.call_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_due_row_locked_by_another_backend_is_not_fired_twice(client, respx_mock):
|
|
fw = FakeHost(respx_mock)
|
|
host = await _create_host(client, "fw-solo", H)
|
|
sid = await _plan(host)
|
|
async with SessionLocal() as other: # another backend is firing this very row right now
|
|
await other.scalar(select(ScheduledMaintenance).where(ScheduledMaintenance.id == sid).with_for_update())
|
|
# Without the row lock this would try to update the locked row and hang: bounded, so it fails instead.
|
|
result = await asyncio.wait_for(_fire(), timeout=10)
|
|
assert result["due"] == 0 and fw.update.call_count == 0
|
|
await other.rollback()
|
|
await _fire()
|
|
assert fw.update.call_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_running_row_locked_by_another_backend_is_not_stepped_twice(client, respx_mock):
|
|
cluster = FakeCluster(respx_mock)
|
|
a, _b = await _setup(client)
|
|
async with SessionLocal() as db:
|
|
s = ScheduledMaintenance(host_id=uuid.UUID(a["id"]), host_name="fw-a", action="update", carp_handover=True,
|
|
run_at=datetime.now(timezone.utc) - timedelta(minutes=1), actor="admin", created_at=datetime.now(timezone.utc))
|
|
db.add(s)
|
|
await db.commit()
|
|
sid = s.id
|
|
await _fire()
|
|
async with SessionLocal() as other:
|
|
await other.scalar(select(ScheduledMaintenance).where(ScheduledMaintenance.id == sid).with_for_update())
|
|
assert await asyncio.wait_for(_advance(), timeout=10) == 1 # still counted as running, but not touched
|
|
assert cluster.update.call_count == 0
|
|
await other.rollback()
|
|
await _advance()
|
|
assert cluster.update.call_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_waits_for_a_firing_row_and_never_overwrites_running(client, respx_mock):
|
|
FakeHost(respx_mock)
|
|
host = await _create_host(client, "fw-solo", H)
|
|
async with SessionLocal() as db:
|
|
s = ScheduledMaintenance(host_id=uuid.UUID(host["id"]), host_name="fw-solo", action="update", auto_verify=True,
|
|
run_at=datetime.now(timezone.utc) + timedelta(hours=1), actor="admin", created_at=datetime.now(timezone.utc))
|
|
db.add(s)
|
|
await db.commit()
|
|
sid = s.id
|
|
async with SessionLocal() as firing: # the poll loop has the row and is about to mark it running
|
|
row = await firing.scalar(select(ScheduledMaintenance).where(ScheduledMaintenance.id == sid).with_for_update())
|
|
cancel = asyncio.create_task(client.delete(f"/api/hosts/{host['id']}/maintenance/schedule/{sid}"))
|
|
await asyncio.sleep(0.5)
|
|
row.status = "running"
|
|
await firing.commit()
|
|
resp = await asyncio.wait_for(cancel, timeout=10)
|
|
assert resp.status_code == 409
|
|
assert (await _get(sid))[0].status == "running"
|