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.
189 lines
8.4 KiB
Python
189 lines
8.4 KiB
Python
import httpx
|
|
import pytest
|
|
|
|
from tests.test_users import _login_as
|
|
|
|
from app.config import settings
|
|
from app.services import update_check_service
|
|
from app.version import APP_VERSION
|
|
|
|
FEED = "https://feed.test/api/v1/repos/o/r/releases/latest"
|
|
UPDATER = "http://updater.test:8080"
|
|
|
|
|
|
def _bump(minor=0, patch=0, major=0):
|
|
a, b, c = (int(x) for x in APP_VERSION.split("."))
|
|
return f"{a + major}.{b + minor}.{c + patch}"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup(monkeypatch, tmp_path):
|
|
token = tmp_path / "token"
|
|
token.write_text("t" * 40)
|
|
monkeypatch.setattr(settings, "update_check_url", FEED)
|
|
monkeypatch.setattr(settings, "updater_url", UPDATER)
|
|
monkeypatch.setattr(settings, "updater_token_file", str(token))
|
|
update_check_service.reset_cache()
|
|
yield
|
|
update_check_service.reset_cache()
|
|
|
|
|
|
def _feed(respx_mock, version):
|
|
respx_mock.get(FEED).mock(return_value=httpx.Response(200, json={"tag_name": f"v{version}"}))
|
|
|
|
|
|
def _updater_status(respx_mock, **over):
|
|
body = {"mode": "release", "running_version": APP_VERSION, "job": None, "phases": [], "log": [],
|
|
"services": {"backend": "healthy"}, "rollback_available": False, "rollback_loses_data": False,
|
|
"last_update": None}
|
|
body.update(over)
|
|
return respx_mock.get(f"{UPDATER}/status").mock(return_value=httpx.Response(200, json=body))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_reports_update_and_can_update(client, respx_mock):
|
|
_feed(respx_mock, _bump(minor=1))
|
|
_updater_status(respx_mock)
|
|
body = (await client.get("/api/system/updater")).json()
|
|
assert body["available"] is True and body["can_update"] is True
|
|
assert body["latest"] == _bump(minor=1) and body["major_change"] is False
|
|
assert body["active_rollouts"] == 0 # the UI warns before restarting OPNexus in the middle of a rollout
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_without_token_is_unavailable_not_an_error(client, respx_mock, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(settings, "updater_token_file", str(tmp_path / "missing"))
|
|
_feed(respx_mock, _bump(minor=1))
|
|
body = (await client.get("/api/system/updater")).json()
|
|
assert body["available"] is False and body["reason"] == "not_enabled" and body["can_update"] is False
|
|
assert body["update_available"] is True # der Hinweis bleibt trotzdem
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("repo,mode", [("", "local"), ("git.example.test/opnexus", "release")])
|
|
async def test_install_mode_is_known_without_the_updater(client, respx_mock, monkeypatch, tmp_path, repo, mode):
|
|
# The panel must not tell a source-built install to enable an updater that could not update it anyway.
|
|
monkeypatch.setattr(settings, "updater_token_file", str(tmp_path / "missing"))
|
|
monkeypatch.setattr(settings, "opnexus_image_repo", repo)
|
|
_feed(respx_mock, _bump(minor=1))
|
|
body = (await client.get("/api/system/updater")).json()
|
|
assert body["available"] is False and body["reason"] == "not_enabled" and body["mode"] == mode
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_unreachable_updater(client, respx_mock):
|
|
_feed(respx_mock, _bump(minor=1))
|
|
respx_mock.get(f"{UPDATER}/status").mock(side_effect=httpx.ConnectError("down"))
|
|
body = (await client.get("/api/system/updater")).json()
|
|
assert body["available"] is False and body["reason"] == "unreachable"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_major_change_is_not_offered_for_one_click(client, respx_mock):
|
|
_feed(respx_mock, _bump(major=1))
|
|
_updater_status(respx_mock)
|
|
body = (await client.get("/api/system/updater")).json()
|
|
assert body["major_change"] is True and body["can_update"] is False
|
|
resp = await client.post("/api/system/updater/update", json={"version": _bump(major=1)})
|
|
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "UPDATE_MAJOR_CHANGE"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_build_install_cannot_update(client, respx_mock):
|
|
_feed(respx_mock, _bump(minor=1))
|
|
_updater_status(respx_mock, mode="local")
|
|
body = (await client.get("/api/system/updater")).json()
|
|
assert body["can_update"] is False and body["mode"] == "local"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_forwards_only_the_feed_version(client, respx_mock):
|
|
newer = _bump(patch=1)
|
|
_feed(respx_mock, newer)
|
|
route = respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={"job": {"id": "x"}}))
|
|
resp = await client.post("/api/system/updater/update", json={"version": newer})
|
|
assert resp.status_code == 202
|
|
sent = route.calls.last.request
|
|
assert sent.headers["authorization"] == "Bearer " + "t" * 40
|
|
assert b'"version": "%s"' % newer.encode() in sent.content or newer.encode() in sent.content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("version", ["0.0.1", "99.99.99", "1.0.0-rc1", "latest", "../../x"])
|
|
async def test_update_rejects_any_other_version(client, respx_mock, version):
|
|
_feed(respx_mock, _bump(patch=1))
|
|
route = respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={}))
|
|
resp = await client.post("/api/system/updater/update", json={"version": version})
|
|
assert resp.status_code in (409, 422)
|
|
assert route.call_count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_when_no_update_available(client, respx_mock):
|
|
_feed(respx_mock, APP_VERSION)
|
|
resp = await client.post("/api/system/updater/update", json={"version": APP_VERSION})
|
|
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "UPDATE_NOT_OFFERED"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_busy_and_rejected_map_to_error_codes(client, respx_mock):
|
|
newer = _bump(patch=1)
|
|
_feed(respx_mock, newer)
|
|
route = respx_mock.post(f"{UPDATER}/update")
|
|
route.mock(return_value=httpx.Response(409, json={"error": "busy"}))
|
|
resp = await client.post("/api/system/updater/update", json={"version": newer})
|
|
assert resp.json()["detail"]["code"] == "UPDATER_BUSY"
|
|
route.mock(return_value=httpx.Response(409, json={"error": "not_release_mode"}))
|
|
resp = await client.post("/api/system/updater/update", json={"version": newer})
|
|
assert resp.json()["detail"] == {"code": "UPDATER_REJECTED", "params": {"reason": "not_release_mode"}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_without_updater_is_503(client, respx_mock, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(settings, "updater_token_file", str(tmp_path / "missing"))
|
|
newer = _bump(patch=1)
|
|
_feed(respx_mock, newer)
|
|
resp = await client.post("/api/system/updater/update", json={"version": newer})
|
|
assert resp.status_code == 503 and resp.json()["detail"]["code"] == "UPDATER_UNAVAILABLE"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rollback_is_forwarded(client, respx_mock):
|
|
route = respx_mock.post(f"{UPDATER}/rollback").mock(return_value=httpx.Response(202, json={"job": {"id": "r"}}))
|
|
resp = await client.post("/api/system/updater/rollback")
|
|
assert resp.status_code == 202 and route.call_count == 1
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_only_user_cannot_read_or_start_updates(client, respx_mock):
|
|
newer = _bump(patch=1)
|
|
_feed(respx_mock, newer)
|
|
status = _updater_status(respx_mock)
|
|
route = respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={}))
|
|
await client.post("/api/users", json={"username": "viewer-upd", "password": "password-123456", "role": "read_only"})
|
|
viewer = await _login_as("viewer-upd", "password-123456")
|
|
try:
|
|
assert (await viewer.get("/api/system/updater")).status_code == 403
|
|
assert (await viewer.post("/api/system/updater/update", json={"version": newer})).status_code == 403
|
|
assert (await viewer.post("/api/system/updater/rollback")).status_code == 403
|
|
finally:
|
|
await viewer.aclose()
|
|
assert status.call_count == 0 and route.call_count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_anonymous_is_rejected(anon_client):
|
|
assert (await anon_client.get("/api/system/updater")).status_code == 401
|
|
assert (await anon_client.post("/api/system/updater/rollback")).status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_request_is_audited(client, respx_mock):
|
|
newer = _bump(patch=1)
|
|
_feed(respx_mock, newer)
|
|
respx_mock.post(f"{UPDATER}/update").mock(return_value=httpx.Response(202, json={"job": {"id": "x"}}))
|
|
assert (await client.post("/api/system/updater/update", json={"version": newer})).status_code == 202
|
|
events = (await client.get("/api/audit?kind=system")).json()["items"]
|
|
assert {(e["action"], e["status"]) for e in events} >= {("update", "requested"), ("update", "accepted")}
|