Files
OPNexus/backend/app/services/window_service.py
T
Julian Boldt a9f562eec6
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
Release v1.25.0
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.
2026-10-21 11:59:41 +02:00

148 lines
6.6 KiB
Python

"""Occurrences of recurring maintenance windows.
Rules (deliberately simple and written down, because a firmware rollout depends on them):
* A window is a wall-clock start time in its own IANA time zone plus a duration. "Sunday 02:00
Europe/Berlin" stays 02:00 local time in summer and in winter.
* Daylight saving: a local start time that does not exist on that day (spring forward, e.g. 02:30 on
the last Sunday of March in Europe/Berlin) moves FORWARD by the gap (03:30). A local time that
exists twice (fall back) means the FIRST of the two (fold=0, still summer time). Both follow from
Python's zoneinfo with fold=0 and are covered by tests.
* The duration is real elapsed time (a 3 h window over the fall-back night still lasts 3 h).
* Exceptions are keyed by the occurrence's local date: "skip" drops it, "move" replaces its start
with an absolute instant (same duration and cut-off).
* New host actions may only START within the first ``start_cutoff_minutes`` of an occurrence
(``startable``); actions already running finish even after the window has closed.
"""
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones
from app.models.maintenance_window import MaintenanceWindow
# How far ahead occurrences are searched. A monthly "5th ..." does not exist; the longest real gap
# between two occurrences is a month, so two years is plenty and bounds the work per call.
_SEARCH_DAYS = 2 * 366
MAX_DURATION_MINUTES = 24 * 60
@dataclass(frozen=True)
class Occurrence:
local_date: date
start: datetime # UTC
end: datetime # UTC
cutoff: datetime # UTC: no new host action after this instant
moved: bool = False
skipped: bool = False
def as_dict(self) -> dict:
return {"local_date": self.local_date.isoformat(), "start": self.start.isoformat(), "end": self.end.isoformat(),
"cutoff": self.cutoff.isoformat(), "moved": self.moved, "skipped": self.skipped}
def valid_timezone(name: str) -> bool:
if not name or name not in available_timezones():
return False
try:
ZoneInfo(name)
except (ZoneInfoNotFoundError, ValueError):
return False
return True
def _matches(window: MaintenanceWindow, day: date) -> bool:
if window.recurrence == "weekly":
return day.weekday() in (window.weekdays or [])
if window.recurrence == "monthly":
if window.month_mode == "day":
return day.day == window.month_day
if window.month_mode == "nth_weekday":
if day.weekday() != window.month_weekday:
return False
if window.month_nth == -1:
return (day + timedelta(days=7)).month != day.month
return (day.day - 1) // 7 + 1 == window.month_nth
return False
def local_start(window: MaintenanceWindow, day: date) -> datetime:
"""The UTC instant of the window's start on local date ``day`` (DST rules see module docstring)."""
tz = ZoneInfo(window.timezone)
wall = datetime.combine(day, window.start_time, tzinfo=tz).replace(fold=0)
return wall.astimezone(timezone.utc)
def _occurrence(window: MaintenanceWindow, day: date, start: datetime, *, moved: bool = False, skipped: bool = False) -> Occurrence:
duration = timedelta(minutes=window.duration_minutes)
cutoff = timedelta(minutes=min(window.start_cutoff_minutes, window.duration_minutes))
return Occurrence(local_date=day, start=start, end=start + duration, cutoff=start + cutoff, moved=moved, skipped=skipped)
def occurrences(window: MaintenanceWindow, after: datetime, n: int = 10, *, with_skipped: bool = False) -> list[Occurrence]:
"""The next ``n`` occurrences whose END lies after ``after`` (so one that is open right now is first).
``with_skipped`` lists skipped ones too (flagged, for the UI); the engine never sees them."""
if n <= 0:
return []
after = after.astimezone(timezone.utc)
tz = ZoneInfo(window.timezone)
exceptions = {e.occurrence_date: e for e in (window.exceptions or [])}
duration = timedelta(minutes=window.duration_minutes)
# Start one day early: an occurrence that began yesterday (local) may still be open.
day = (after - duration).astimezone(tz).date() - timedelta(days=1)
found: list[Occurrence] = []
# Moved occurrences can land anywhere, so they are collected separately and merged at the end.
for exc in exceptions.values():
if exc.kind == "move" and exc.moved_start is not None and _matches(window, exc.occurrence_date):
occ = _occurrence(window, exc.occurrence_date, exc.moved_start.astimezone(timezone.utc), moved=True)
if occ.end > after:
found.append(occ)
regular = 0
for _ in range(_SEARCH_DAYS):
if regular >= n:
break
if _matches(window, day):
exc = exceptions.get(day)
if exc is None or (with_skipped and exc.kind == "skip"):
occ = _occurrence(window, day, local_start(window, day), skipped=exc is not None)
if occ.end > after:
found.append(occ)
regular += 1
day += timedelta(days=1)
found.sort(key=lambda o: o.start)
return found[:n]
def current(window: MaintenanceWindow, now: datetime) -> Occurrence | None:
"""The occurrence open at ``now`` (start <= now < end), if any."""
if not window.enabled:
return None
for occ in occurrences(window, now, 3):
if occ.start <= now < occ.end:
return occ
return None
def startable(window: MaintenanceWindow | None, now: datetime) -> Occurrence | None:
"""The occurrence in which a NEW host action may start right now (start <= now < cutoff)."""
if window is None or not window.enabled:
return None
occ = current(window, now)
if occ is not None and occ.start <= now < occ.cutoff:
return occ
return None
def is_occurrence_date(window: MaintenanceWindow, day: date) -> bool:
return _matches(window, day)
def definition(window: MaintenanceWindow) -> dict:
"""What a rollout plan's fingerprint covers: changing any of this re-requires activation."""
return {
"id": str(window.id), "timezone": window.timezone, "recurrence": window.recurrence,
"weekdays": sorted(window.weekdays or []), "month_mode": window.month_mode, "month_nth": window.month_nth,
"month_weekday": window.month_weekday, "month_day": window.month_day,
"start_time": window.start_time.strftime("%H:%M"), "duration_minutes": window.duration_minutes,
"start_cutoff_minutes": window.start_cutoff_minutes, "enabled": window.enabled,
}