Files
OPNexus/backend/app/routers/scheduled_maintenance.py
T
Julian Boldt 79783d6d62
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.17.0
Geplante Wartung: Fehler zeigen sich sofort statt erst um 3 Uhr nachts, und der Master lässt sich
unbeaufsichtigt aktualisieren. Auf einem Test-Cluster live geprüft (Neustart und ein echtes Update
mit Umschaltung), siehe Handbuch „Geplante Wartung“.

- **Rollenprüfung beim Einplanen:** Ein Host, der in einem Cluster gerade CARP-MASTER ist,
  lässt sich nicht mehr einplanen (Neustart/Update/Major-Upgrade). Bisher wurde der Plan
  angenommen und scheiterte erst zur Uhrzeit. Der Planungsdialog zeigt den Grund an und
  sperrt den Button. Ist die Rolle nicht feststellbar (Host gerade nicht erreichbar), wird
  weiter angenommen; die volle Prüfung läuft wie bisher zum Zündzeitpunkt.
- **Benachrichtigung „Geplante Wartung nicht ausgeführt“:** Eine fehlgeschlagene geplante
  Wartung meldet sich per Webhook/E-Mail (einzeln schaltbar in den Einstellungen, standardmäßig
  an). Offen für drei Tage nach dem geplanten Zeitpunkt, dann ohne „behoben“-Meldung geschlossen.
  Neue Spalte `notification_settings.scheduled_maintenance_failed_enabled` (Migration `0006`).
- **Automatische CARP-Umschaltung (Option pro Plan, standardmäßig aus):** Ein geplanter Neustart,
  Update oder Major-Upgrade auf dem MASTER schaltet vorher auf den Partner um, prüft die
  Übernahme, führt die Aktion aus, wartet auf die Rückkehr des Hosts und schaltet zurück. Fehler
  vor dem Start der Aktion stellen den Normalbetrieb wieder her; danach bleibt der Host bewusst im
  Wartungsmodus (Partner trägt den Verkehr) und es kommt eine Benachrichtigung. Der Ablauf steht in
  der Datenbank und überlebt einen Neustart des Backends. Neue Spalten an `scheduled_maintenance`
  (Migration `0007`).
- **Backup vor geplantem Update:** Ein geplantes Update oder Major-Upgrade zieht unmittelbar vor
  dem Start selbst ein Konfigurationsbackup (nur lesend auf der Firewall). Bisher scheiterte jeder
  Plan, der mehr als 24 Stunden in der Zukunft lag, an der Backup-Aktualität. Schlägt das Backup
  fehl, wird nicht aktualisiert.
2026-10-19 16:45:07 +02:00

101 lines
4.1 KiB
Python

import uuid
from datetime import datetime, timezone
from typing import Literal
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.errors import ErrorCode, api_error
from app.database import get_db
from app.models import OpnsenseHost
from app.models.scheduled_maintenance import ScheduledMaintenance
from app.services.host_maintenance_service import peers
from app.services.host_status_service import get_statuses
from app.services.policy_service import policy_lock
router = APIRouter(prefix="/hosts/{host_id}/maintenance/schedule", tags=["host-maintenance"])
class Schedule(BaseModel):
action: Literal["reboot", "update", "major_update"]
run_at: datetime
acknowledge_unattended: Literal[True]
# Hand the traffic over to the CARP partner first and switch back afterwards (cluster nodes only).
carp_handover: bool = False
def _read(sched: ScheduledMaintenance) -> dict:
return {
"id": sched.id, "host_id": sched.host_id, "action": sched.action, "run_at": sched.run_at,
"status": sched.status, "actor": sched.actor, "created_at": sched.created_at,
"result": sched.result, "host_operation_id": sched.host_operation_id,
"carp_handover": sched.carp_handover, "phase": sched.phase,
}
async def _host_or_404(db: AsyncSession, host_id: uuid.UUID) -> OpnsenseHost:
host = await db.get(OpnsenseHost, host_id)
if not host:
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
return host
@router.get("")
async def list_scheduled(host_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _host_or_404(db, host_id)
rows = (
await db.scalars(
select(ScheduledMaintenance)
.where(ScheduledMaintenance.host_id == host_id)
.order_by(ScheduledMaintenance.created_at.desc())
.limit(50)
)
).all()
return [_read(r) for r in rows]
@router.post("")
async def create_scheduled(host_id: uuid.UUID, payload: Schedule, request: Request, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
host = await _host_or_404(db, host_id)
if not host.enabled:
raise api_error(409, ErrorCode.HOST_IST_DEAKTIVIERT)
now = datetime.now(timezone.utc)
run_at = payload.run_at if payload.run_at.tzinfo else payload.run_at.replace(tzinfo=timezone.utc)
if run_at <= now:
raise api_error(422, ErrorCode.ZEITPUNKT_MUSS_IN_DER_ZUKUNFT_LIEGEN)
if payload.carp_handover:
if not host.cluster_name or len(await peers(db, host)) < 2:
raise api_error(409, ErrorCode.CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER)
elif host.cluster_name:
# Without a handover, fire time refuses every cluster node that is not the CARP BACKUP, so a
# plan for the current MASTER can only ever end as "failed" at 3 a.m. Say so now.
# Only a *known* MASTER is rejected here: an unreachable node or an unclear role
# is still judged by the full check when the plan fires.
statuses = await get_statuses(await peers(db, host))
if statuses.get(host.id, {}).get("carp_status") == "MASTER":
raise api_error(409, ErrorCode.GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH)
sched = ScheduledMaintenance(
host_id=host.id, host_name=host.name, action=payload.action, run_at=run_at,
actor=request.state.user.username, created_at=now, carp_handover=payload.carp_handover,
)
db.add(sched)
await db.commit()
return _read(sched)
@router.delete("/{schedule_id}")
async def cancel_scheduled(host_id: uuid.UUID, schedule_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await policy_lock(db)
await _host_or_404(db, host_id)
sched = await db.get(ScheduledMaintenance, schedule_id)
if not sched or sched.host_id != host_id:
raise api_error(404, ErrorCode.GEPLANTE_WARTUNG_NICHT_GEFUNDEN)
if sched.status != "pending":
raise api_error(409, ErrorCode.NUR_AUSSTEHENDE_GEPLANTE_WARTUNGEN_KOENNEN_ABGEBROCHEN_WERDEN)
sched.status = "cancelled"
await db.commit()
return _read(sched)