Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79783d6d62 | ||
|
|
c15417869b |
No files matched your search
@@ -36,7 +36,7 @@ PROXY_BIND_ADDRESS=0.0.0.0
|
|||||||
|
|
||||||
# Host interface the backend/frontend ports are published on. Defaults
|
# Host interface the backend/frontend ports are published on. Defaults
|
||||||
# to 127.0.0.1 (this host only) if unset. Set to 0.0.0.0 for access from
|
# to 127.0.0.1 (this host only) if unset. Set to 0.0.0.0 for access from
|
||||||
# other machines without a reverse proxy (see README's "Produktivbetrieb"
|
# other machines without a reverse proxy (see the handbook, "Zugriff aus dem Netz und TLS"
|
||||||
# for the safer TLS-proxy alternative), or to one specific interface IP.
|
# for the safer TLS-proxy alternative), or to one specific interface IP.
|
||||||
BIND_ADDRESS=
|
BIND_ADDRESS=
|
||||||
# Host-Ports von Backend und Frontend (nur nötig, wenn 8100/3100 schon belegt
|
# Host-Ports von Backend und Frontend (nur nötig, wenn 8100/3100 schon belegt
|
||||||
@@ -44,15 +44,15 @@ BIND_ADDRESS=
|
|||||||
# BACKEND_PORT=8100
|
# BACKEND_PORT=8100
|
||||||
# FRONTEND_PORT=3100
|
# FRONTEND_PORT=3100
|
||||||
|
|
||||||
# Set to true once this is running behind TLS (see README's
|
# Set to true once this is running behind TLS (see the handbook,
|
||||||
# "Produktivbetrieb" section) — otherwise the session cookie is sent
|
# "Zugriff aus dem Netz und TLS") — otherwise the session cookie is sent
|
||||||
# unencrypted and browsers won't even attach a Secure cookie without
|
# unencrypted and browsers won't even attach a Secure cookie without
|
||||||
# HTTPS in the first place.
|
# HTTPS in the first place.
|
||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=false
|
||||||
|
|
||||||
# Set to true ONLY when running behind a reverse proxy you trust to
|
# Set to true ONLY when running behind a reverse proxy you trust to
|
||||||
# always set/overwrite X-Forwarded-For itself (every example in README's
|
# always set/overwrite X-Forwarded-For itself (every example in the handbook's
|
||||||
# "Produktivbetrieb" does). Used to attribute failed logins to the real
|
# "Zugriff aus dem Netz und TLS" does). Used to attribute failed logins to the real
|
||||||
# client IP for the login lockout instead of the proxy's own IP. Leave
|
# client IP for the login lockout instead of the proxy's own IP. Leave
|
||||||
# false (default) if the app is reachable directly with no proxy in
|
# false (default) if the app is reachable directly with no proxy in
|
||||||
# front — otherwise any client could spoof this header to dodge the
|
# front — otherwise any client could spoof this header to dodge the
|
||||||
@@ -80,7 +80,7 @@ TRUST_PROXY_HEADERS=false
|
|||||||
# Startet den Updater-Container, mit dem Admins unter Einstellungen › „OPNexus
|
# Startet den Updater-Container, mit dem Admins unter Einstellungen › „OPNexus
|
||||||
# aktualisieren“ ein Update auslösen können. Er ist der einzige Container mit
|
# aktualisieren“ ein Update auslösen können. Er ist der einzige Container mit
|
||||||
# Docker-Socket-Zugriff (= root-gleiche Rechte auf diesem Host) und standardmäßig
|
# Docker-Socket-Zugriff (= root-gleiche Rechte auf diesem Host) und standardmäßig
|
||||||
# AUS. Benötigt außerdem UPDATE_CHECK_URL (oben). Siehe Handbuch.
|
# AUS. Benötigt außerdem UPDATE_CHECK_URL (oben). Siehe Handbuch, "Updates per Klick".
|
||||||
# COMPOSE_PROFILES=updater
|
# COMPOSE_PROFILES=updater
|
||||||
|
|
||||||
# ── Transportsicherheit (nur ändern, wenn nötig) ──────────────────────
|
# ── Transportsicherheit (nur ändern, wenn nötig) ──────────────────────
|
||||||
|
|||||||
@@ -31,6 +31,33 @@ den Einstellungen.
|
|||||||
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
||||||
ansteht): Merlin …
|
ansteht): Merlin …
|
||||||
|
|
||||||
|
## [1.17.0] "Imperial" — 2026-10-19
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## [1.16.0] "Hobby" — 2026-10-10
|
## [1.16.0] "Hobby" — 2026-10-10
|
||||||
|
|
||||||
Erste Schritte nach der Installation. Ende-zu-Ende gegen einen isolierten Stack getestet, siehe
|
Erste Schritte nach der Installation. Ende-zu-Ende gegen einen isolierten Stack getestet, siehe
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
> **Stand:** `1.16.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
|
> **Stand:** `1.17.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
|
||||||
|
|
||||||
## Funktionen
|
## Funktionen
|
||||||
|
|
||||||
@@ -52,13 +52,13 @@ scripts/opnexus password
|
|||||||
|
|
||||||
Nach der ersten Anmeldung öffnet sich von selbst ein kurzer **Einrichtungsassistent**: eigenes Passwort setzen, optional Zwei-Faktor-Anmeldung einschalten, die erste Firewall verbinden (samt Anleitung für den API-Key in OPNsense). Er lässt sich überspringen und jederzeit unter System › Einrichtung wieder öffnen.
|
Nach der ersten Anmeldung öffnet sich von selbst ein kurzer **Einrichtungsassistent**: eigenes Passwort setzen, optional Zwei-Faktor-Anmeldung einschalten, die erste Firewall verbinden (samt Anleitung für den API-Key in OPNsense). Er lässt sich überspringen und jederzeit unter System › Einrichtung wieder öffnen.
|
||||||
|
|
||||||
Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelieferter Caddy-Proxy) siehe das [Handbuch](docs/HANDBUCH.md#zugriff-von-außerhalb-dieser-vm--produktivbetrieb-mit-tls).
|
Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelieferter Caddy-Proxy) siehe das [Handbuch](docs/HANDBUCH.md#zugriff-aus-dem-netz-und-tls).
|
||||||
|
|
||||||
**Fertige Images** (amd64) liegen in der Registry dieses Gitea. In der `.env`:
|
**Fertige Images** (amd64) liegen in der Registry dieses Gitea. In der `.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||||
OPNEXUS_VERSION=1.16.0
|
OPNEXUS_VERSION=1.17.0
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
> **Status:** `1.16.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
|
> **Status:** `1.17.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
@@ -54,13 +54,13 @@ scripts/opnexus password
|
|||||||
|
|
||||||
After the first sign-in a short **setup assistant** opens on its own: set your own password, optionally enable two-factor sign-in, connect your first firewall (including how to create the API key in OPNsense). You can skip it and reopen it any time under System › Setup.
|
After the first sign-in a short **setup assistant** opens on its own: set your own password, optionally enable two-factor sign-in, connect your first firewall (including how to create the API key in OPNsense). You can skip it and reopen it any time under System › Setup.
|
||||||
|
|
||||||
For access from other machines and for production use with TLS (bundled Caddy proxy), see the [handbook](docs/HANDBUCH.md#zugriff-von-außerhalb-dieser-vm--produktivbetrieb-mit-tls) (German).
|
For access from other machines and for production use with TLS (bundled Caddy proxy), see the [handbook](docs/HANDBUCH.md#zugriff-aus-dem-netz-und-tls) (German).
|
||||||
|
|
||||||
**Prebuilt images** (amd64) are published to this Gitea's registry. In `.env`:
|
**Prebuilt images** (amd64) are published to this Gitea's registry. In `.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||||
OPNEXUS_VERSION=1.16.0
|
OPNEXUS_VERSION=1.17.0
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ class ErrorCode(str, Enum):
|
|||||||
FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998 = "FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998"
|
FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998 = "FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998"
|
||||||
FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR = "FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR"
|
FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR = "FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR"
|
||||||
GEPLANTE_WARTUNG_NICHT_GEFUNDEN = "GEPLANTE_WARTUNG_NICHT_GEFUNDEN"
|
GEPLANTE_WARTUNG_NICHT_GEFUNDEN = "GEPLANTE_WARTUNG_NICHT_GEFUNDEN"
|
||||||
|
GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH = "GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH"
|
||||||
|
CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER = "CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER"
|
||||||
GROUP_HAS_ASSIGNED_ITEMS = "GROUP_HAS_ASSIGNED_ITEMS"
|
GROUP_HAS_ASSIGNED_ITEMS = "GROUP_HAS_ASSIGNED_ITEMS"
|
||||||
GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN = "GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN"
|
GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN = "GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN"
|
||||||
GRUPPENNAME_BEREITS_VERGEBEN = "GRUPPENNAME_BEREITS_VERGEBEN"
|
GRUPPENNAME_BEREITS_VERGEBEN = "GRUPPENNAME_BEREITS_VERGEBEN"
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ async def _resource_poll_loop():
|
|||||||
|
|
||||||
|
|
||||||
_SCHEDULED_MAINTENANCE_POLL_SECONDS = 60
|
_SCHEDULED_MAINTENANCE_POLL_SECONDS = 60
|
||||||
|
_RUNNING_MAINTENANCE_POLL_SECONDS = 15
|
||||||
|
|
||||||
|
|
||||||
async def _scheduled_maintenance_poll_loop():
|
async def _scheduled_maintenance_poll_loop():
|
||||||
@@ -244,9 +245,13 @@ async def _scheduled_maintenance_poll_loop():
|
|||||||
"Geplante Wartung: %d fällig, %d ausgeführt, %d blockiert/fehlgeschlagen",
|
"Geplante Wartung: %d fällig, %d ausgeführt, %d blockiert/fehlgeschlagen",
|
||||||
result["due"], result["fired"], result["blocked_or_failed"],
|
result["due"], result["fired"], result["blocked_or_failed"],
|
||||||
)
|
)
|
||||||
|
in_progress = await scheduled_maintenance_service.advance_running(db)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Geplante-Wartung-Poll fehlgeschlagen")
|
logger.exception("Geplante-Wartung-Poll fehlgeschlagen")
|
||||||
await asyncio.sleep(_SCHEDULED_MAINTENANCE_POLL_SECONDS)
|
in_progress = 0
|
||||||
|
# A handover run is stepped quickly (the partner should take over within a minute or two);
|
||||||
|
# otherwise the relaxed cadence is plenty.
|
||||||
|
await asyncio.sleep(_RUNNING_MAINTENANCE_POLL_SECONDS if in_progress else _SCHEDULED_MAINTENANCE_POLL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class NotificationSettings(Base):
|
|||||||
nat_drift_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
nat_drift_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||||
network_drift_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
network_drift_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||||
carp_auto_revert_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
carp_auto_revert_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||||
|
scheduled_maintenance_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||||
|
|
||||||
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300)
|
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||||
@@ -71,7 +72,7 @@ class NotificationProblem(Base):
|
|||||||
__table_args__ = (UniqueConstraint("problem_type", "dedup_key", name="uq_notification_problem_key"),)
|
__table_args__ = (UniqueConstraint("problem_type", "dedup_key", name="uq_notification_problem_key"),)
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
# host_offline | cert_expiry | rule_drift | revert_failed | maintenance_unknown
|
# host_offline | cert_expiry | rule_drift | revert_failed | maintenance_unknown | ... (see ProblemType)
|
||||||
problem_type: Mapped[str] = mapped_column(String(32))
|
problem_type: Mapped[str] = mapped_column(String(32))
|
||||||
dedup_key: Mapped[str] = mapped_column(String(300))
|
dedup_key: Mapped[str] = mapped_column(String(300))
|
||||||
status: Mapped[str] = mapped_column(String(16), default="open") # open | resolved
|
status: Mapped[str] = mapped_column(String(16), default="open") # open | resolved
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ table only tracks the *schedule* itself, not the resulting operation.
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Index, JSON, String
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, JSON, String
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
@@ -40,3 +40,10 @@ class ScheduledMaintenance(Base):
|
|||||||
# Set to the resulting HostOperation's id once fired, so the UI can
|
# Set to the resulting HostOperation's id once fired, so the UI can
|
||||||
# link from the schedule to the operation it produced.
|
# link from the schedule to the operation it produced.
|
||||||
host_operation_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
host_operation_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
||||||
|
# Opt-in: hand the traffic over to the CARP partner first, run the action, then switch back.
|
||||||
|
carp_handover: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||||
|
# Only while status == "running" with a handover: handover_wait | job_wait | leave | leave_wait.
|
||||||
|
# State lives here, not in memory, so a backend restart in the middle of a run resumes cleanly.
|
||||||
|
phase: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
|
phase_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
progress: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
@@ -11,6 +11,8 @@ from app.errors import ErrorCode, api_error
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import OpnsenseHost
|
from app.models import OpnsenseHost
|
||||||
from app.models.scheduled_maintenance import ScheduledMaintenance
|
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
|
from app.services.policy_service import policy_lock
|
||||||
|
|
||||||
router = APIRouter(prefix="/hosts/{host_id}/maintenance/schedule", tags=["host-maintenance"])
|
router = APIRouter(prefix="/hosts/{host_id}/maintenance/schedule", tags=["host-maintenance"])
|
||||||
@@ -20,6 +22,8 @@ class Schedule(BaseModel):
|
|||||||
action: Literal["reboot", "update", "major_update"]
|
action: Literal["reboot", "update", "major_update"]
|
||||||
run_at: datetime
|
run_at: datetime
|
||||||
acknowledge_unattended: Literal[True]
|
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:
|
def _read(sched: ScheduledMaintenance) -> dict:
|
||||||
@@ -27,6 +31,7 @@ def _read(sched: ScheduledMaintenance) -> dict:
|
|||||||
"id": sched.id, "host_id": sched.host_id, "action": sched.action, "run_at": sched.run_at,
|
"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,
|
"status": sched.status, "actor": sched.actor, "created_at": sched.created_at,
|
||||||
"result": sched.result, "host_operation_id": sched.host_operation_id,
|
"result": sched.result, "host_operation_id": sched.host_operation_id,
|
||||||
|
"carp_handover": sched.carp_handover, "phase": sched.phase,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -61,9 +66,20 @@ async def create_scheduled(host_id: uuid.UUID, payload: Schedule, request: Reque
|
|||||||
run_at = payload.run_at if payload.run_at.tzinfo else payload.run_at.replace(tzinfo=timezone.utc)
|
run_at = payload.run_at if payload.run_at.tzinfo else payload.run_at.replace(tzinfo=timezone.utc)
|
||||||
if run_at <= now:
|
if run_at <= now:
|
||||||
raise api_error(422, ErrorCode.ZEITPUNKT_MUSS_IN_DER_ZUKUNFT_LIEGEN)
|
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(
|
sched = ScheduledMaintenance(
|
||||||
host_id=host.id, host_name=host.name, action=payload.action, run_at=run_at,
|
host_id=host.id, host_name=host.name, action=payload.action, run_at=run_at,
|
||||||
actor=request.state.user.username, created_at=now,
|
actor=request.state.user.username, created_at=now, carp_handover=payload.carp_handover,
|
||||||
)
|
)
|
||||||
db.add(sched)
|
db.add(sched)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ SmtpSecurity = Literal["none", "starttls", "ssl"]
|
|||||||
ProblemType = Literal[
|
ProblemType = Literal[
|
||||||
"host_offline", "cert_expiry", "rule_drift", "revert_failed", "maintenance_unknown",
|
"host_offline", "cert_expiry", "rule_drift", "revert_failed", "maintenance_unknown",
|
||||||
"carp_split_brain", "nat_drift", "carp_auto_revert_failed", "network_drift",
|
"carp_split_brain", "nat_drift", "carp_auto_revert_failed", "network_drift",
|
||||||
|
"scheduled_maintenance_failed",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ class NotificationSettingsRead(BaseModel):
|
|||||||
nat_drift_enabled: bool
|
nat_drift_enabled: bool
|
||||||
network_drift_enabled: bool = True
|
network_drift_enabled: bool = True
|
||||||
carp_auto_revert_failed_enabled: bool
|
carp_auto_revert_failed_enabled: bool
|
||||||
|
scheduled_maintenance_failed_enabled: bool = True
|
||||||
|
|
||||||
poll_interval_seconds: int
|
poll_interval_seconds: int
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -74,6 +76,7 @@ class NotificationSettingsUpdate(BaseModel):
|
|||||||
nat_drift_enabled: bool | None = None
|
nat_drift_enabled: bool | None = None
|
||||||
network_drift_enabled: bool | None = None
|
network_drift_enabled: bool | None = None
|
||||||
carp_auto_revert_failed_enabled: bool | None = None
|
carp_auto_revert_failed_enabled: bool | None = None
|
||||||
|
scheduled_maintenance_failed_enabled: bool | None = None
|
||||||
|
|
||||||
poll_interval_seconds: int | None = Field(
|
poll_interval_seconds: int | None = Field(
|
||||||
default=None, ge=settings.notification_poll_min_interval_seconds
|
default=None, ge=settings.notification_poll_min_interval_seconds
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ async def _shared_change_blockers(db, fleet):
|
|||||||
return blocked
|
return blocked
|
||||||
|
|
||||||
|
|
||||||
async def inspect_host(db, host):
|
async def inspect_host(db, host, *, skip_role_checks=False):
|
||||||
|
"""skip_role_checks: for the planned CARP handover, where the node is still MASTER when
|
||||||
|
it is inspected and becomes BACKUP only afterwards (the role is verified separately)."""
|
||||||
fleet = await peers(db, host)
|
fleet = await peers(db, host)
|
||||||
statuses, info, firmware, running = await asyncio.gather(
|
statuses, info, firmware, running = await asyncio.gather(
|
||||||
get_statuses(fleet), client.get_firmware_info(host), client.get_firmware_status(host), client.get_firmware_running(host))
|
get_statuses(fleet), client.get_firmware_info(host), client.get_firmware_status(host), client.get_firmware_running(host))
|
||||||
@@ -68,7 +70,7 @@ async def inspect_host(db, host):
|
|||||||
if statuses.get(host.id, {}).get("status") != "online": blocked.append("Host ist nicht erreichbar.")
|
if statuses.get(host.id, {}).get("status") != "online": blocked.append("Host ist nicht erreichbar.")
|
||||||
# Fail closed: only the verified ready value permits a disruptive action.
|
# Fail closed: only the verified ready value permits a disruptive action.
|
||||||
if running.get("status") != "ready": blocked.append("Firmware-Auftrag läuft oder Status ist unbekannt.")
|
if running.get("status") != "ready": blocked.append("Firmware-Auftrag läuft oder Status ist unbekannt.")
|
||||||
if host.cluster_name:
|
if host.cluster_name and not skip_role_checks:
|
||||||
others = [h for h in fleet if h.id != host.id]
|
others = [h for h in fleet if h.id != host.id]
|
||||||
if statuses.get(host.id, {}).get("carp_status") != "BACKUP":
|
if statuses.get(host.id, {}).get("carp_status") != "BACKUP":
|
||||||
blocked.append("Cluster-Wartung nur auf dem CARP-BACKUP. Rollenwechsel separat in OPNsense durchführen.")
|
blocked.append("Cluster-Wartung nur auf dem CARP-BACKUP. Rollenwechsel separat in OPNsense durchführen.")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ whatever's currently bad. Pure detection — no delivery, no state tracking
|
|||||||
here (see notification_dedup.py for the open/resolved diffing that turns
|
here (see notification_dedup.py for the open/resolved diffing that turns
|
||||||
these snapshots into actual notifications)."""
|
these snapshots into actual notifications)."""
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -14,6 +15,7 @@ from app.models.one_to_one_deployment import OneToOneDeployment
|
|||||||
from app.models.outbound_nat_deployment import OutboundNatDeployment
|
from app.models.outbound_nat_deployment import OutboundNatDeployment
|
||||||
from app.models.port_forward_deployment import PortForwardDeployment
|
from app.models.port_forward_deployment import PortForwardDeployment
|
||||||
from app.models.rule_deployment import RuleDeployment
|
from app.models.rule_deployment import RuleDeployment
|
||||||
|
from app.models.scheduled_maintenance import ScheduledMaintenance
|
||||||
from app.services import nat_drift_service, network_drift_service, rule_drift_service
|
from app.services import nat_drift_service, network_drift_service, rule_drift_service
|
||||||
from app.services.notification_dedup import Problems
|
from app.services.notification_dedup import Problems
|
||||||
from app.services.host_status_service import get_statuses
|
from app.services.host_status_service import get_statuses
|
||||||
@@ -262,3 +264,29 @@ async def check_carp_auto_revert_failed(db: AsyncSession) -> dict[str, dict]:
|
|||||||
"message": window.message,
|
"message": window.message,
|
||||||
}
|
}
|
||||||
return problems
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
# A failed scheduled run never turns "fixed" by itself, so it is reported as an event:
|
||||||
|
# open for this long after its planned time, then closed without a "resolved" message
|
||||||
|
# (see notification_service._NO_RESOLVED_NOTICE).
|
||||||
|
SCHEDULED_FAILURE_WINDOW = timedelta(days=3)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_scheduled_maintenance_failed(db: AsyncSession) -> dict[str, dict]:
|
||||||
|
since = datetime.now(timezone.utc) - SCHEDULED_FAILURE_WINDOW
|
||||||
|
result = await db.execute(
|
||||||
|
select(ScheduledMaintenance).where(
|
||||||
|
ScheduledMaintenance.status == "failed", ScheduledMaintenance.run_at >= since
|
||||||
|
)
|
||||||
|
)
|
||||||
|
problems: dict[str, dict] = {}
|
||||||
|
for sched in result.scalars().all():
|
||||||
|
problems[str(sched.id)] = {
|
||||||
|
"schedule_id": str(sched.id),
|
||||||
|
"host_id": str(sched.host_id),
|
||||||
|
"host_name": sched.host_name,
|
||||||
|
"action": sched.action,
|
||||||
|
"run_at": sched.run_at.isoformat(),
|
||||||
|
"message": (sched.result or {}).get("error"),
|
||||||
|
}
|
||||||
|
return problems
|
||||||
@@ -33,6 +33,7 @@ _LABELS = {
|
|||||||
"nat_drift": "NAT-Drift im Cluster",
|
"nat_drift": "NAT-Drift im Cluster",
|
||||||
"network_drift": "Netzwerk-Abweichung im Cluster",
|
"network_drift": "Netzwerk-Abweichung im Cluster",
|
||||||
"carp_auto_revert_failed": "CARP-Auto-Revert fehlgeschlagen",
|
"carp_auto_revert_failed": "CARP-Auto-Revert fehlgeschlagen",
|
||||||
|
"scheduled_maintenance_failed": "Geplante Wartung nicht ausgeführt",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,13 +30,21 @@ _CHECKS = [
|
|||||||
("nat_drift", "nat_drift_enabled", notification_checks.check_nat_drift),
|
("nat_drift", "nat_drift_enabled", notification_checks.check_nat_drift),
|
||||||
("network_drift", "network_drift_enabled", notification_checks.check_network_drift),
|
("network_drift", "network_drift_enabled", notification_checks.check_network_drift),
|
||||||
("carp_auto_revert_failed", "carp_auto_revert_failed_enabled", notification_checks.check_carp_auto_revert_failed),
|
("carp_auto_revert_failed", "carp_auto_revert_failed_enabled", notification_checks.check_carp_auto_revert_failed),
|
||||||
|
("scheduled_maintenance_failed", "scheduled_maintenance_failed_enabled", notification_checks.check_scheduled_maintenance_failed),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Event-like problems: when they drop out of the window nothing was "fixed", so no
|
||||||
|
# "behoben" message goes out for them.
|
||||||
|
_NO_RESOLVED_NOTICE = {"scheduled_maintenance_failed"}
|
||||||
|
|
||||||
|
|
||||||
async def _deliver(settings_row: NotificationSettings, problem: NotificationProblem, transition: str) -> None:
|
async def _deliver(settings_row: NotificationSettings, problem: NotificationProblem, transition: str) -> None:
|
||||||
"""Best-effort, not an audit trail: attempted once per transition and
|
"""Best-effort, not an audit trail: attempted once per transition and
|
||||||
never retried, even on failure — avoids retry-storming a broken webhook
|
never retried, even on failure — avoids retry-storming a broken webhook
|
||||||
URL or SMTP relay every poll cycle."""
|
URL or SMTP relay every poll cycle."""
|
||||||
|
if transition == "resolved" and problem.problem_type in _NO_RESOLVED_NOTICE:
|
||||||
|
problem.resolved_notified_at = _now()
|
||||||
|
return
|
||||||
if settings_row.webhook_enabled:
|
if settings_row.webhook_enabled:
|
||||||
try:
|
try:
|
||||||
await notification_delivery.send_webhook(settings_row, problem, transition)
|
await notification_delivery.send_webhook(settings_row, problem, transition)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ async def update_settings(db: AsyncSession, payload: NotificationSettingsUpdate)
|
|||||||
"smtp_from_address", "smtp_to_addresses",
|
"smtp_from_address", "smtp_to_addresses",
|
||||||
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled",
|
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled",
|
||||||
"revert_failed_enabled", "maintenance_unknown_enabled", "carp_health_enabled", "nat_drift_enabled",
|
"revert_failed_enabled", "maintenance_unknown_enabled", "carp_health_enabled", "nat_drift_enabled",
|
||||||
"network_drift_enabled", "carp_auto_revert_failed_enabled",
|
"network_drift_enabled", "carp_auto_revert_failed_enabled", "scheduled_maintenance_failed_enabled",
|
||||||
"poll_interval_seconds",
|
"poll_interval_seconds",
|
||||||
]
|
]
|
||||||
for field in simple_fields:
|
for field in simple_fields:
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ action dispatch table) rather than duplicating that logic differently,
|
|||||||
since this is the exact same remote action, just fired by a timer instead
|
since this is the exact same remote action, just fired by a timer instead
|
||||||
of a click.
|
of a click.
|
||||||
|
|
||||||
|
Updates take a fresh config backup right before firing (a plan for the day after tomorrow
|
||||||
|
would otherwise always trip over the 24h freshness rule).
|
||||||
|
|
||||||
v1 scope decision: a blocked or failed scheduled fire is logged and
|
v1 scope decision: a blocked or failed scheduled fire is logged and
|
||||||
recorded on the ScheduledMaintenance row's own `result` field (visible in
|
recorded on the ScheduledMaintenance row's own `result` field (visible in
|
||||||
the UI) but is NOT wired into the NotificationSettings webhook/email
|
the UI) but is NOT wired into the NotificationSettings webhook/email
|
||||||
@@ -15,7 +18,7 @@ for this is a bigger change than this feature's own scope. Flagged here,
|
|||||||
not silently dropped, in case it's wanted later.
|
not silently dropped, in case it's wanted later.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -24,12 +27,30 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.models.host import OpnsenseHost
|
from app.models.host import OpnsenseHost
|
||||||
from app.models.host_operation import HostOperation
|
from app.models.host_operation import HostOperation
|
||||||
from app.models.scheduled_maintenance import ScheduledMaintenance
|
from app.models.scheduled_maintenance import ScheduledMaintenance
|
||||||
from app.services import audit_service, opnsense_client as client
|
from app.services import audit_service, backup_store, opnsense_client as client
|
||||||
from app.services.host_maintenance_service import ensure_no_maintenance, inspect_host
|
from app.services.host_maintenance_service import ACTIVE, ensure_no_maintenance, inspect_carp_switch, inspect_host, peers
|
||||||
|
from app.services.host_status_service import get_statuses
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_ACTION_FN = {"reboot": client.reboot_host, "update": client.update_firmware, "major_update": client.upgrade_firmware}
|
_ACTION_FN = {"reboot": client.reboot_host, "update": client.update_firmware, "major_update": client.upgrade_firmware}
|
||||||
|
# Actions that need a config backup no older than 24h (see host_maintenance_service.inspect_host).
|
||||||
|
# Unattended, nobody can take it by hand beforehand, so it is taken right before firing.
|
||||||
|
_BACKUP_BEFORE = {"update", "major_update"}
|
||||||
|
|
||||||
|
# ── CARP handover run (opt-in per plan) ─────────────────────────────────────
|
||||||
|
# MASTER -> maintenance mode -> partner must become MASTER -> action -> wait until the node is
|
||||||
|
# back -> leave maintenance mode. One step per poll tick, state in the schedule row.
|
||||||
|
# Rules that keep it safe:
|
||||||
|
# * the CARP toggle is a toggle: it is only ever sent against freshly read state and never
|
||||||
|
# replayed after an unclear answer (see routers/maintenance.py carp_execute);
|
||||||
|
# * until the action was submitted, any problem restores normal operation (nothing happened yet);
|
||||||
|
# * after that, any problem leaves the node demoted — the partner keeps carrying traffic, which is
|
||||||
|
# the safe side — and the run ends as failed (=> notification) with an "unknown" operation.
|
||||||
|
HANDOVER_TIMEOUT = timedelta(minutes=3)
|
||||||
|
LEAVE_TIMEOUT = timedelta(minutes=5)
|
||||||
|
JOB_GRACE = timedelta(minutes=5) # no sign of a job after this long: treat a package update as already done
|
||||||
|
JOB_TIMEOUT = {"reboot": timedelta(minutes=20), "update": timedelta(minutes=60), "major_update": timedelta(minutes=150)}
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
@@ -79,6 +100,16 @@ async def _fire_one(db: AsyncSession, sched: ScheduledMaintenance) -> None:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
logger.warning("Geplante Wartung %s blockiert: %s", sched.id, exc.detail)
|
logger.warning("Geplante Wartung %s blockiert: %s", sched.id, exc.detail)
|
||||||
return
|
return
|
||||||
|
if sched.carp_handover and host.cluster_name:
|
||||||
|
try:
|
||||||
|
statuses = await get_statuses(await peers(db, host))
|
||||||
|
except client.OpnsenseAPIError as exc:
|
||||||
|
statuses = {}
|
||||||
|
logger.warning("Geplante Wartung %s: Rolle nicht lesbar: %s", sched.id, exc)
|
||||||
|
if statuses.get(host.id, {}).get("carp_status") == "MASTER":
|
||||||
|
await _start_handover(db, sched, host)
|
||||||
|
return
|
||||||
|
# Already BACKUP (or role unclear): no handover needed, the normal path below decides.
|
||||||
try:
|
try:
|
||||||
state = await inspect_host(db, host)
|
state = await inspect_host(db, host)
|
||||||
except client.OpnsenseAPIError as exc:
|
except client.OpnsenseAPIError as exc:
|
||||||
@@ -87,6 +118,18 @@ async def _fire_one(db: AsyncSession, sched: ScheduledMaintenance) -> None:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
logger.warning("Geplante Wartung %s: Host nicht erreichbar", sched.id)
|
logger.warning("Geplante Wartung %s: Host nicht erreichbar", sched.id)
|
||||||
return
|
return
|
||||||
|
if sched.action in _BACKUP_BEFORE and not state["blocked"]:
|
||||||
|
# Read-only on the firewall. Fail closed: no backup, no update. Taken only after the
|
||||||
|
# other blockers passed, so a plan that is going to fail anyway does not pile up backups.
|
||||||
|
try:
|
||||||
|
backup_store.save_snapshot(host.id, await client.download_backup(host))
|
||||||
|
state = await inspect_host(db, host)
|
||||||
|
except client.OpnsenseAPIError as exc:
|
||||||
|
sched.status = "failed"
|
||||||
|
sched.result = {"error": f"Automatisches Backup vor dem Update fehlgeschlagen: {str(exc)[:300]}"}
|
||||||
|
await db.commit()
|
||||||
|
logger.warning("Geplante Wartung %s: Backup vor dem Update fehlgeschlagen", sched.id)
|
||||||
|
return
|
||||||
if state["actions"][sched.action]:
|
if state["actions"][sched.action]:
|
||||||
sched.status = "failed"
|
sched.status = "failed"
|
||||||
sched.result = {"error": " ".join(state["actions"][sched.action])}
|
sched.result = {"error": " ".join(state["actions"][sched.action])}
|
||||||
@@ -125,3 +168,248 @@ async def _fire_one(db: AsyncSession, sched: ScheduledMaintenance) -> None:
|
|||||||
host_id=str(host.id), host_name=host.name, entity_id=str(op.id),
|
host_id=str(host.id), host_name=host.name, entity_id=str(op.id),
|
||||||
message=op.message, details={"scheduled": True, "schedule_id": str(sched.id), "response": result},
|
message=op.message, details={"scheduled": True, "schedule_id": str(sched.id), "response": result},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── handover run ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _progress(sched: ScheduledMaintenance, **changes) -> dict:
|
||||||
|
"""JSON columns only notice reassignment, so always hand back a new dict."""
|
||||||
|
sched.progress = {**(sched.progress or {}), **changes}
|
||||||
|
return sched.progress
|
||||||
|
|
||||||
|
|
||||||
|
async def _audit(host, sched, op, action, status, message=None, **details):
|
||||||
|
await audit_service.persist(
|
||||||
|
kind="host", action=action, status=status, host_id=str(host.id), host_name=host.name,
|
||||||
|
entity_id=str(op.id) if op else None, message=message,
|
||||||
|
details={"scheduled": True, "schedule_id": str(sched.id), "handover": True, **details},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _end(db, sched, host, op, *, ok, message, op_status=None, **result):
|
||||||
|
sched.status = "succeeded" if ok else "failed"
|
||||||
|
sched.phase = None
|
||||||
|
sched.result = {"handover": True, **result} if ok else {"error": message[:900], **result}
|
||||||
|
if op is not None:
|
||||||
|
op.status = op_status or ("acknowledged" if ok else "unknown")
|
||||||
|
op.message = message[:1000]
|
||||||
|
await db.commit()
|
||||||
|
await _audit(host, sched, op, sched.action, "completed" if ok else (op_status or "unknown"), message)
|
||||||
|
if not ok:
|
||||||
|
logger.warning("Geplante Wartung %s (CARP-Übergabe) beendet: %s", sched.id, message)
|
||||||
|
|
||||||
|
|
||||||
|
async def _toggle(host, expected: str) -> None:
|
||||||
|
"""One CARP maintenance toggle; raises OpnsenseAPIError for an unclear or wrong-direction answer."""
|
||||||
|
result = await client.toggle_carp_maintenance(host)
|
||||||
|
if result.get("action") != expected:
|
||||||
|
raise client.OpnsenseAPIError("CARP-Antwort passt nicht zur angeforderten Richtung")
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_handover(db: AsyncSession, sched: ScheduledMaintenance, host: OpnsenseHost) -> None:
|
||||||
|
"""The node is MASTER now. All preconditions are checked while nothing has been changed yet."""
|
||||||
|
def fail(message: str):
|
||||||
|
sched.status = "failed"
|
||||||
|
sched.result = {"error": message[:900]}
|
||||||
|
return db.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
carp = await inspect_carp_switch(db, host)
|
||||||
|
state = await inspect_host(db, host, skip_role_checks=True)
|
||||||
|
blocked = list(dict.fromkeys(carp["blocked"] + state["blocked"]))
|
||||||
|
if carp["action"] != "carp_enter": # MASTER but already in maintenance mode: unclear, do not toggle blindly
|
||||||
|
blocked.append(f"{host.name} steht bereits im CARP-Wartungsmodus. Rollen prüfen.")
|
||||||
|
if not blocked and sched.action in _BACKUP_BEFORE:
|
||||||
|
backup_store.save_snapshot(host.id, await client.download_backup(host))
|
||||||
|
state = await inspect_host(db, host, skip_role_checks=True)
|
||||||
|
blocked = list(dict.fromkeys(blocked + state["actions"][sched.action]))
|
||||||
|
except client.OpnsenseAPIError as exc:
|
||||||
|
await fail(f"Vorbereitung der CARP-Übergabe fehlgeschlagen, nichts wurde verändert: {str(exc)[:300]}")
|
||||||
|
return
|
||||||
|
if blocked:
|
||||||
|
await fail(" ".join(blocked))
|
||||||
|
return
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
op = HostOperation(
|
||||||
|
host_id=host.id, host_name=host.name, actor=sched.actor, action=sched.action,
|
||||||
|
expires_at=now, fingerprint="", snapshot=state, status="starting", started_at=now,
|
||||||
|
)
|
||||||
|
db.add(op)
|
||||||
|
sched.status = "running"
|
||||||
|
await db.commit()
|
||||||
|
sched.host_operation_id = op.id
|
||||||
|
await _audit(host, sched, op, "carp_enter", "requested", snapshot=state)
|
||||||
|
try:
|
||||||
|
await _toggle(host, "enter_maintenance")
|
||||||
|
except client.OpnsenseAPIError as exc:
|
||||||
|
await _end(db, sched, host, op, ok=False,
|
||||||
|
message=f"CARP-Umschalten mit unklarem Ergebnis: {str(exc)[:300]}. Nicht automatisch wiederholt — "
|
||||||
|
f"Rollen von {host.name} und Partner prüfen.")
|
||||||
|
return
|
||||||
|
sched.phase, sched.phase_since = "handover_wait", _now()
|
||||||
|
_progress(sched, version_before=state.get("version"))
|
||||||
|
op.message = "CARP-Übergabe angefordert, warte auf Bestätigung."
|
||||||
|
await db.commit()
|
||||||
|
await _audit(host, sched, op, "carp_enter", "submitted")
|
||||||
|
|
||||||
|
|
||||||
|
async def advance_running(db: AsyncSession) -> int:
|
||||||
|
"""One step for every handover run in progress. Returns how many are still running."""
|
||||||
|
rows = list((await db.scalars(
|
||||||
|
select(ScheduledMaintenance).where(ScheduledMaintenance.status == "running", ScheduledMaintenance.phase.is_not(None))
|
||||||
|
)).all())
|
||||||
|
for sched in rows:
|
||||||
|
host = await db.get(OpnsenseHost, sched.host_id)
|
||||||
|
op = await db.get(HostOperation, sched.host_operation_id) if sched.host_operation_id else None
|
||||||
|
try:
|
||||||
|
if host is None:
|
||||||
|
raise RuntimeError("Host nicht mehr vorhanden")
|
||||||
|
if op is not None and op.status not in ACTIVE:
|
||||||
|
# A human closed or cancelled the operation: stop here and touch nothing more.
|
||||||
|
sched.status, sched.phase = "failed", None
|
||||||
|
sched.result = {"error": f"Vorgang von Hand beendet, Ablauf gestoppt. CARP-Rolle von {host.name} prüfen."}
|
||||||
|
await db.commit()
|
||||||
|
continue
|
||||||
|
await _STEPS[sched.phase](db, sched, host, op)
|
||||||
|
except Exception as exc: # a stuck run must not loop forever or take the poll loop down
|
||||||
|
logger.exception("Geplante Wartung %s: Schritt %s fehlgeschlagen", sched.id, sched.phase)
|
||||||
|
if host is not None:
|
||||||
|
await _end(db, sched, host, op, ok=False,
|
||||||
|
message=f"Unerwarteter Fehler während der CARP-Übergabe ({type(exc).__name__}). "
|
||||||
|
f"{host.name} kann im CARP-Wartungsmodus verblieben sein — Rollen prüfen.")
|
||||||
|
return sum(1 for s in rows if s.status == "running")
|
||||||
|
|
||||||
|
|
||||||
|
def _since(sched: ScheduledMaintenance) -> timedelta:
|
||||||
|
return _now() - (sched.phase_since or _now())
|
||||||
|
|
||||||
|
|
||||||
|
async def _roles(db, host) -> tuple[dict, bool]:
|
||||||
|
"""(own status, a partner is online and MASTER)."""
|
||||||
|
fleet = await peers(db, host)
|
||||||
|
statuses = await get_statuses(fleet)
|
||||||
|
partner_master = any(
|
||||||
|
statuses.get(h.id, {}).get("status") == "online" and statuses.get(h.id, {}).get("carp_status") == "MASTER"
|
||||||
|
for h in fleet if h.id != host.id
|
||||||
|
)
|
||||||
|
return statuses.get(host.id, {}), partner_master
|
||||||
|
|
||||||
|
|
||||||
|
async def _step_handover_wait(db, sched, host, op):
|
||||||
|
me, partner_master = await _roles(db, host)
|
||||||
|
if me.get("status") == "online" and me.get("carp_maintenance_mode") and me.get("carp_status") == "BACKUP" and partner_master:
|
||||||
|
try:
|
||||||
|
await _ACTION_FN[sched.action](host)
|
||||||
|
except client.OpnsenseAPIError as exc:
|
||||||
|
await _end(db, sched, host, op, ok=False,
|
||||||
|
message=f"Auftrag „{sched.action}“ mit unklarem Ergebnis: {str(exc)[:300]}. Nicht wiederholt. "
|
||||||
|
f"{host.name} bleibt im CARP-Wartungsmodus, der Partner trägt den Verkehr.")
|
||||||
|
return
|
||||||
|
sched.phase, sched.phase_since = "job_wait", _now()
|
||||||
|
_progress(sched, job_started_at=_now().isoformat(), seen_down=False, seen_running=False)
|
||||||
|
op.status, op.message = "submitted", "Übergabe bestätigt, Auftrag gestartet."
|
||||||
|
await db.commit()
|
||||||
|
await _audit(host, sched, op, sched.action, "submitted")
|
||||||
|
return
|
||||||
|
if _since(sched) < HANDOVER_TIMEOUT:
|
||||||
|
return
|
||||||
|
# The partner never took over. Nothing has run yet, so put the node back to normal.
|
||||||
|
if me.get("status") == "online" and me.get("carp_maintenance_mode"):
|
||||||
|
try:
|
||||||
|
await _toggle(host, "leave_maintenance")
|
||||||
|
except client.OpnsenseAPIError as exc:
|
||||||
|
await _end(db, sched, host, op, ok=False,
|
||||||
|
message=f"CARP-Übergabe nicht bestätigt, und der Normalbetrieb konnte nicht wiederhergestellt werden "
|
||||||
|
f"({str(exc)[:200]}). {host.name} prüfen.")
|
||||||
|
return
|
||||||
|
await _end(db, sched, host, op, ok=False, op_status="cancelled",
|
||||||
|
message="CARP-Übergabe nicht bestätigt (der Partner wurde nicht MASTER). Normalbetrieb wiederhergestellt, nichts aktualisiert.")
|
||||||
|
elif me.get("status") == "online":
|
||||||
|
await _end(db, sched, host, op, ok=False, op_status="cancelled",
|
||||||
|
message="CARP-Übergabe hat nicht stattgefunden (Wartungsmodus nicht aktiv). Nichts aktualisiert.")
|
||||||
|
else:
|
||||||
|
await _end(db, sched, host, op, ok=False,
|
||||||
|
message=f"CARP-Übergabe nicht bestätigt und {host.name} nicht erreichbar. Rollen prüfen.")
|
||||||
|
|
||||||
|
|
||||||
|
async def _step_job_wait(db, sched, host, op):
|
||||||
|
prog = dict(sched.progress or {})
|
||||||
|
started = datetime.fromisoformat(prog["job_started_at"])
|
||||||
|
elapsed = _now() - started
|
||||||
|
try:
|
||||||
|
running = (await client.get_firmware_running(host)).get("status")
|
||||||
|
except client.OpnsenseAPIError:
|
||||||
|
_progress(sched, seen_down=True) # rebooting: exactly what a reboot or base update looks like
|
||||||
|
running = None
|
||||||
|
else:
|
||||||
|
if running != "ready":
|
||||||
|
_progress(sched, seen_running=True)
|
||||||
|
prog = sched.progress
|
||||||
|
if running == "ready":
|
||||||
|
if sched.action == "reboot":
|
||||||
|
done = bool(prog.get("seen_down"))
|
||||||
|
else:
|
||||||
|
done = bool(prog.get("seen_down") or prog.get("seen_running")) or elapsed >= JOB_GRACE
|
||||||
|
if done:
|
||||||
|
sched.phase, sched.phase_since = "leave", _now()
|
||||||
|
await db.commit()
|
||||||
|
return
|
||||||
|
if elapsed >= JOB_TIMEOUT[sched.action]:
|
||||||
|
await _end(db, sched, host, op, ok=False,
|
||||||
|
message=f"Auftrag „{sched.action}“ auf {host.name} nicht rechtzeitig abgeschlossen. {host.name} bleibt im "
|
||||||
|
f"CARP-Wartungsmodus, der Partner trägt den Verkehr. Bitte prüfen und von Hand zurückschalten.")
|
||||||
|
return
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _step_leave(db, sched, host, op):
|
||||||
|
me, _partner = await _roles(db, host)
|
||||||
|
if me.get("status") != "online":
|
||||||
|
if _since(sched) >= LEAVE_TIMEOUT:
|
||||||
|
await _end(db, sched, host, op, ok=False,
|
||||||
|
message=f"{host.name} ist nach dem Auftrag nicht wieder erreichbar. Der Partner trägt den Verkehr.")
|
||||||
|
return
|
||||||
|
if me.get("carp_maintenance_mode"):
|
||||||
|
try:
|
||||||
|
await _toggle(host, "leave_maintenance")
|
||||||
|
except client.OpnsenseAPIError as exc:
|
||||||
|
await _end(db, sched, host, op, ok=False,
|
||||||
|
message=f"Zurückschalten mit unklarem Ergebnis: {str(exc)[:300]}. Nicht automatisch wiederholt — "
|
||||||
|
f"{host.name} prüfen, ggf. „CARP-Normalbetrieb herstellen“.")
|
||||||
|
return
|
||||||
|
await _audit(host, sched, op, "carp_leave", "submitted")
|
||||||
|
sched.phase, sched.phase_since = "leave_wait", _now()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _step_leave_wait(db, sched, host, op):
|
||||||
|
fleet = await peers(db, host)
|
||||||
|
statuses = await get_statuses(fleet)
|
||||||
|
me = statuses.get(host.id, {})
|
||||||
|
online = [h for h in fleet if statuses.get(h.id, {}).get("status") == "online"]
|
||||||
|
masters = [h for h in online if statuses.get(h.id, {}).get("carp_status") == "MASTER"]
|
||||||
|
if me.get("status") == "online" and not me.get("carp_maintenance_mode") and len(masters) == 1 and len(online) == len(fleet):
|
||||||
|
extra = {}
|
||||||
|
try:
|
||||||
|
extra["version_after"] = (await client.get_firmware_info(host)).get("product_version")
|
||||||
|
extra["reboot_required"] = str((await client.get_firmware_status(host)).get("status_reboot", "0")) == "1"
|
||||||
|
except client.OpnsenseAPIError:
|
||||||
|
pass
|
||||||
|
extra["version_before"] = (sched.progress or {}).get("version_before")
|
||||||
|
text = "Automatisch abgeschlossen: Übergabe, Auftrag und Zurückschalten bestätigt."
|
||||||
|
if extra.get("reboot_required"):
|
||||||
|
text += " Ein Neustart ist noch erforderlich."
|
||||||
|
await _end(db, sched, host, op, ok=True, message=text, **extra)
|
||||||
|
return
|
||||||
|
if _since(sched) >= LEAVE_TIMEOUT:
|
||||||
|
await _end(db, sched, host, op, ok=False,
|
||||||
|
message=f"Normalbetrieb nach dem Zurückschalten nicht bestätigt. Rollen von {host.name} und Partner prüfen.")
|
||||||
|
|
||||||
|
|
||||||
|
_STEPS = {
|
||||||
|
"handover_wait": _step_handover_wait,
|
||||||
|
"job_wait": _step_job_wait,
|
||||||
|
"leave": _step_leave,
|
||||||
|
"leave_wait": _step_leave_wait,
|
||||||
|
}
|
||||||
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
|
|||||||
Versioning scheme: see CHANGELOG.md ("Versionierung").
|
Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||||
"""
|
"""
|
||||||
|
|
||||||
APP_VERSION = "1.16.0"
|
APP_VERSION = "1.17.0"
|
||||||
APP_CODENAME = "Hobby"
|
APP_CODENAME = "Imperial"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Benachrichtigung, wenn eine geplante Wartung nicht ausgeführt werden konnte.
|
||||||
|
|
||||||
|
Revision ID: 0006
|
||||||
|
Revises: 0005
|
||||||
|
Create Date: 2026-10-10
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0006"
|
||||||
|
down_revision = "0005"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS "
|
||||||
|
"scheduled_maintenance_failed_enabled boolean NOT NULL DEFAULT true"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE notification_settings DROP COLUMN IF EXISTS scheduled_maintenance_failed_enabled")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Geplante Wartung mit automatischer CARP-Übergabe: Option, Phase und Fortschritt.
|
||||||
|
|
||||||
|
Revision ID: 0007
|
||||||
|
Revises: 0006
|
||||||
|
Create Date: 2026-10-19
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0007"
|
||||||
|
down_revision = "0006"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE scheduled_maintenance ADD COLUMN IF NOT EXISTS carp_handover boolean NOT NULL DEFAULT false")
|
||||||
|
op.execute("ALTER TABLE scheduled_maintenance ADD COLUMN IF NOT EXISTS phase varchar(16)")
|
||||||
|
op.execute("ALTER TABLE scheduled_maintenance ADD COLUMN IF NOT EXISTS phase_since timestamp with time zone")
|
||||||
|
# The default only fills existing rows; like every JSON column here the model has no server default.
|
||||||
|
op.execute("ALTER TABLE scheduled_maintenance ADD COLUMN IF NOT EXISTS progress json NOT NULL DEFAULT '{}'::json")
|
||||||
|
op.execute("ALTER TABLE scheduled_maintenance ALTER COLUMN progress DROP DEFAULT")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for column in ("progress", "phase_since", "phase", "carp_handover"):
|
||||||
|
op.execute(f"ALTER TABLE scheduled_maintenance DROP COLUMN IF EXISTS {column}")
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
"""Scheduled maintenance with the opt-in CARP handover: MASTER -> maintenance -> partner MASTER ->
|
||||||
|
action -> node back -> leave maintenance. A tiny fake cluster stands in for the two firewalls."""
|
||||||
|
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.scheduled_maintenance import ScheduledMaintenance
|
||||||
|
from app.services import backup_store, scheduled_maintenance_service as svc
|
||||||
|
from tests.test_rules import _create_host
|
||||||
|
|
||||||
|
A, B = "https://fw-a.test", "https://fw-b.test"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCluster:
|
||||||
|
"""fw-a is the node being maintained (MASTER), fw-b the partner."""
|
||||||
|
|
||||||
|
def __init__(self, respx_mock, *, toggle_flips=True, partner_follows=True, toggle_error=None, firmware="update"):
|
||||||
|
self.mock = respx_mock
|
||||||
|
self.roles = {A: {"role": "MASTER", "maintenance": False}, B: {"role": "BACKUP", "maintenance": False}}
|
||||||
|
self.job = "ready" # what fw-a's firmware/running reports: ready | running | down
|
||||||
|
self.toggle_flips, self.partner_follows, self.toggle_error, self.firmware = toggle_flips, partner_follows, toggle_error, firmware
|
||||||
|
self.toggles = respx_mock.post(f"{A}/api/diagnostics/interface/carp_status/maintenance").mock(side_effect=self._toggle)
|
||||||
|
self.update = respx_mock.post(f"{A}/api/core/firmware/update").respond(200, json={"status": "ok"})
|
||||||
|
self.reboot = respx_mock.post(f"{A}/api/core/system/reboot").respond(200, json={"status": "ok"})
|
||||||
|
self.backup = respx_mock.get(f"{A}/api/core/backup/download/this").respond(200, content=b"<opnsense/>")
|
||||||
|
for base in (A, B):
|
||||||
|
respx_mock.get(f"{base}/api/diagnostics/interface/getVipStatus").mock(side_effect=lambda r, b=base: self._vip(b))
|
||||||
|
respx_mock.get(f"{base}/api/core/firmware/info").respond(200, json={"product_version": "26.7.5"})
|
||||||
|
respx_mock.get(f"{base}/api/core/firmware/status").respond(
|
||||||
|
200, json={"status": firmware, "all_packages": {}, "status_msg": "x", "status_reboot": "0"})
|
||||||
|
respx_mock.get(f"{A}/api/core/firmware/running").mock(side_effect=self._running_a)
|
||||||
|
respx_mock.get(f"{B}/api/core/firmware/running").respond(200, json={"status": "ready"})
|
||||||
|
|
||||||
|
def _vip(self, base):
|
||||||
|
if base == A and self.job == "down":
|
||||||
|
raise httpx.ConnectError("rebooting")
|
||||||
|
cur = self.roles[base]
|
||||||
|
return httpx.Response(200, json={"rows": [{"vhid": "1", "status": cur["role"]}], "carp": {
|
||||||
|
"allow": "1", "maintenancemode": cur["maintenance"], "demotion": "240" if cur["maintenance"] else "0", "status_msg": ""}})
|
||||||
|
|
||||||
|
def _running_a(self, request):
|
||||||
|
if self.job == "down":
|
||||||
|
raise httpx.ConnectError("rebooting")
|
||||||
|
return httpx.Response(200, json={"status": self.job})
|
||||||
|
|
||||||
|
def _toggle(self, request):
|
||||||
|
if self.toggle_error:
|
||||||
|
raise self.toggle_error
|
||||||
|
cur = self.roles[A]
|
||||||
|
cur["maintenance"] = not cur["maintenance"]
|
||||||
|
entering = cur["maintenance"]
|
||||||
|
if self.toggle_flips:
|
||||||
|
cur["role"] = "BACKUP" if entering else "MASTER"
|
||||||
|
if self.partner_follows:
|
||||||
|
self.roles[B]["role"] = "MASTER" if entering else "BACKUP"
|
||||||
|
return httpx.Response(200, json={"status": "ok", "action": "enter_maintenance" if entering else "leave_maintenance"})
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup(client, **kwargs):
|
||||||
|
a = await _create_host(client, "fw-a", A)
|
||||||
|
b = await _create_host(client, "fw-b", B)
|
||||||
|
await client.patch(f"/api/hosts/{a['id']}", json={"cluster_name": "ha"})
|
||||||
|
await client.patch(f"/api/hosts/{b['id']}", json={"cluster_name": "ha"})
|
||||||
|
return a, b
|
||||||
|
|
||||||
|
|
||||||
|
async def _plan(host, action="update", handover=True):
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
s = ScheduledMaintenance(
|
||||||
|
host_id=uuid.UUID(host["id"]), host_name=host["name"], action=action, carp_handover=handover,
|
||||||
|
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(sched_id):
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
s = await db.get(ScheduledMaintenance, sched_id)
|
||||||
|
op = await db.get(HostOperation, s.host_operation_id) if s.host_operation_id else None
|
||||||
|
return s, op
|
||||||
|
|
||||||
|
|
||||||
|
async def _age(sched_id, *, phase=None, job=None):
|
||||||
|
"""Pretend the phase (or the job) started `phase`/`job` ago."""
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
s = await db.get(ScheduledMaintenance, sched_id)
|
||||||
|
if phase is not None:
|
||||||
|
s.phase_since = datetime.now(timezone.utc) - phase
|
||||||
|
if job is not None:
|
||||||
|
s.progress = {**s.progress, "job_started_at": (datetime.now(timezone.utc) - job).isoformat()}
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── happy paths ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_with_handover_runs_end_to_end(client, respx_mock):
|
||||||
|
cluster = FakeCluster(respx_mock)
|
||||||
|
a, _b = await _setup(client)
|
||||||
|
sid = await _plan(a, "update")
|
||||||
|
|
||||||
|
assert (await _fire())["fired"] == 1
|
||||||
|
s, op = await _get(sid)
|
||||||
|
assert (s.status, s.phase, op.status) == ("running", "handover_wait", "starting")
|
||||||
|
assert cluster.backup.call_count == 1 and cluster.toggles.call_count == 1 and cluster.update.call_count == 0
|
||||||
|
assert cluster.roles[B]["role"] == "MASTER"
|
||||||
|
|
||||||
|
assert await _advance() == 1 # partner confirmed -> job submitted
|
||||||
|
s, op = await _get(sid)
|
||||||
|
assert (s.phase, op.status) == ("job_wait", "submitted") and cluster.update.call_count == 1
|
||||||
|
|
||||||
|
cluster.job = "running"
|
||||||
|
await _advance()
|
||||||
|
cluster.job = "ready"
|
||||||
|
await _advance()
|
||||||
|
assert (await _get(sid))[0].phase == "leave"
|
||||||
|
assert cluster.toggles.call_count == 1 # not back yet
|
||||||
|
|
||||||
|
await _advance() # leave: toggles once
|
||||||
|
assert cluster.toggles.call_count == 2 and (await _get(sid))[0].phase == "leave_wait"
|
||||||
|
assert await _advance() == 0 # roles back to normal -> done
|
||||||
|
|
||||||
|
s, op = await _get(sid)
|
||||||
|
assert s.status == "succeeded" and s.phase is None and s.result["handover"] is True
|
||||||
|
assert s.result["version_before"] == "26.7.5"
|
||||||
|
assert op.status == "acknowledged"
|
||||||
|
assert cluster.roles[A] == {"role": "MASTER", "maintenance": False}
|
||||||
|
assert cluster.update.call_count == 1 and cluster.toggles.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reboot_with_handover_needs_to_see_the_node_go_down(client, respx_mock):
|
||||||
|
cluster = FakeCluster(respx_mock, firmware="none")
|
||||||
|
a, _b = await _setup(client)
|
||||||
|
sid = await _plan(a, "reboot")
|
||||||
|
await _fire()
|
||||||
|
assert cluster.backup.call_count == 0 # no backup for a plain reboot
|
||||||
|
await _advance()
|
||||||
|
assert cluster.reboot.call_count == 1
|
||||||
|
|
||||||
|
await _advance() # still "ready" and never seen down: not a finished reboot
|
||||||
|
assert (await _get(sid))[0].phase == "job_wait"
|
||||||
|
cluster.job = "down"
|
||||||
|
await _advance()
|
||||||
|
cluster.job = "ready"
|
||||||
|
await _advance()
|
||||||
|
assert (await _get(sid))[0].phase == "leave"
|
||||||
|
await _advance()
|
||||||
|
await _advance()
|
||||||
|
assert (await _get(sid))[0].status == "succeeded"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handover_flag_on_a_backup_node_just_runs_the_action(client, respx_mock):
|
||||||
|
cluster = FakeCluster(respx_mock)
|
||||||
|
cluster.roles[A].update(role="BACKUP")
|
||||||
|
cluster.roles[B].update(role="MASTER")
|
||||||
|
a, _b = await _setup(client)
|
||||||
|
sid = await _plan(a, "update")
|
||||||
|
await _fire()
|
||||||
|
s, _op = await _get(sid)
|
||||||
|
assert s.status == "succeeded" and s.phase is None
|
||||||
|
assert cluster.toggles.call_count == 0 and cluster.update.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_without_the_flag_a_master_is_still_refused(client, respx_mock):
|
||||||
|
cluster = FakeCluster(respx_mock)
|
||||||
|
a, _b = await _setup(client)
|
||||||
|
sid = await _plan(a, "update", handover=False)
|
||||||
|
await _fire()
|
||||||
|
s, _ = await _get(sid)
|
||||||
|
assert s.status == "failed" and "CARP-BACKUP" in s.result["error"]
|
||||||
|
assert cluster.toggles.call_count == 0 and cluster.update.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── failures: what happens to the node ────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_partner_never_takes_over_restores_normal_operation(client, respx_mock):
|
||||||
|
cluster = FakeCluster(respx_mock, toggle_flips=False)
|
||||||
|
a, _b = await _setup(client)
|
||||||
|
sid = await _plan(a, "update")
|
||||||
|
await _fire()
|
||||||
|
cluster.roles[A]["maintenance"] = True # the toggle was accepted, but no role change happened
|
||||||
|
await _advance() # too early: keep waiting
|
||||||
|
assert (await _get(sid))[0].phase == "handover_wait" and cluster.toggles.call_count == 1
|
||||||
|
|
||||||
|
await _age(sid, phase=timedelta(minutes=4))
|
||||||
|
await _advance()
|
||||||
|
s, op = await _get(sid)
|
||||||
|
assert s.status == "failed" and "wiederhergestellt" in s.result["error"] and op.status == "cancelled"
|
||||||
|
assert cluster.toggles.call_count == 2 and cluster.roles[A]["maintenance"] is False
|
||||||
|
assert cluster.update.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_demoted_node_but_partner_not_master_never_starts_the_job(client, respx_mock):
|
||||||
|
"""The dangerous case: both nodes BACKUP, nobody carries traffic. The action must not start."""
|
||||||
|
cluster = FakeCluster(respx_mock, partner_follows=False)
|
||||||
|
a, _b = await _setup(client)
|
||||||
|
sid = await _plan(a, "update")
|
||||||
|
await _fire()
|
||||||
|
assert cluster.roles[A]["role"] == "BACKUP" and cluster.roles[B]["role"] == "BACKUP"
|
||||||
|
await _advance()
|
||||||
|
assert (await _get(sid))[0].phase == "handover_wait" and cluster.update.call_count == 0
|
||||||
|
|
||||||
|
await _age(sid, phase=timedelta(minutes=4))
|
||||||
|
await _advance()
|
||||||
|
s, op = await _get(sid)
|
||||||
|
assert s.status == "failed" and op.status == "cancelled" and cluster.update.call_count == 0
|
||||||
|
assert cluster.roles[A]["maintenance"] is False # normal operation restored
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unclear_toggle_answer_is_never_replayed(client, respx_mock):
|
||||||
|
cluster = FakeCluster(respx_mock, toggle_error=httpx.ReadTimeout("timeout"))
|
||||||
|
a, _b = await _setup(client)
|
||||||
|
sid = await _plan(a, "update")
|
||||||
|
await _fire()
|
||||||
|
s, op = await _get(sid)
|
||||||
|
assert s.status == "failed" and op.status == "unknown" and "unklar" in s.result["error"]
|
||||||
|
assert await _advance() == 0
|
||||||
|
assert cluster.toggles.call_count == 1 and cluster.update.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_job_timeout_leaves_the_node_demoted(client, respx_mock):
|
||||||
|
cluster = FakeCluster(respx_mock)
|
||||||
|
a, _b = await _setup(client)
|
||||||
|
sid = await _plan(a, "update")
|
||||||
|
await _fire()
|
||||||
|
await _advance()
|
||||||
|
cluster.job = "running"
|
||||||
|
await _age(sid, job=timedelta(minutes=61))
|
||||||
|
await _advance()
|
||||||
|
s, op = await _get(sid)
|
||||||
|
assert s.status == "failed" and op.status == "unknown" and "Wartungsmodus" in s.result["error"]
|
||||||
|
assert cluster.roles[A]["maintenance"] is True and cluster.toggles.call_count == 1 # the safe side: partner keeps traffic
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_blocked_preconditions_change_nothing(client, respx_mock):
|
||||||
|
cluster = FakeCluster(respx_mock)
|
||||||
|
cluster.roles[B]["role"] = "MASTER" # no BACKUP partner to hand over to (split-brain-like)
|
||||||
|
a, _b = await _setup(client)
|
||||||
|
sid = await _plan(a, "update")
|
||||||
|
await _fire()
|
||||||
|
s, op = await _get(sid)
|
||||||
|
assert s.status == "failed" and op is None
|
||||||
|
assert cluster.toggles.call_count == 0 and cluster.update.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_backup_aborts_before_any_switch(client, respx_mock):
|
||||||
|
cluster = FakeCluster(respx_mock)
|
||||||
|
respx_mock.get(f"{A}/api/core/backup/download/this").mock(side_effect=httpx.ConnectError("down"))
|
||||||
|
a, _b = await _setup(client)
|
||||||
|
sid = await _plan(a, "update")
|
||||||
|
await _fire()
|
||||||
|
s, _ = await _get(sid)
|
||||||
|
assert s.status == "failed" and "nichts wurde verändert" in s.result["error"]
|
||||||
|
assert cluster.toggles.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_stops_when_a_human_closes_the_operation(client, respx_mock):
|
||||||
|
cluster = FakeCluster(respx_mock)
|
||||||
|
a, _b = await _setup(client)
|
||||||
|
sid = await _plan(a, "update")
|
||||||
|
await _fire()
|
||||||
|
_s, op = await _get(sid)
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
(await db.get(HostOperation, op.id)).status = "cancelled"
|
||||||
|
await db.commit()
|
||||||
|
assert await _advance() == 0
|
||||||
|
s, _ = await _get(sid)
|
||||||
|
assert s.status == "failed" and "von Hand" in s.result["error"]
|
||||||
|
assert cluster.toggles.call_count == 1 and cluster.update.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_step_survives_backend_restart(client, respx_mock):
|
||||||
|
"""Every step reads its state from the row; nothing lives in memory between ticks."""
|
||||||
|
cluster = FakeCluster(respx_mock)
|
||||||
|
a, _b = await _setup(client)
|
||||||
|
sid = await _plan(a, "update")
|
||||||
|
await _fire()
|
||||||
|
async with SessionLocal() as db: # a fresh session per step is what a restarted backend does
|
||||||
|
assert await svc.advance_running(db) == 1
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
s = await db.get(ScheduledMaintenance, sid)
|
||||||
|
assert s.phase == "job_wait" and s.progress["job_started_at"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── API ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_schedule_api_accepts_handover_for_the_master_only_with_the_flag(client, respx_mock):
|
||||||
|
FakeCluster(respx_mock)
|
||||||
|
a, _b = await _setup(client)
|
||||||
|
when = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
|
||||||
|
url = f"/api/hosts/{a['id']}/maintenance/schedule"
|
||||||
|
|
||||||
|
plain = await client.post(url, json={"action": "update", "run_at": when, "acknowledge_unattended": True})
|
||||||
|
assert plain.status_code == 409
|
||||||
|
ok = await client.post(url, json={"action": "update", "run_at": when, "acknowledge_unattended": True, "carp_handover": True})
|
||||||
|
assert ok.status_code == 200 and ok.json()["carp_handover"] is True and ok.json()["phase"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_schedule_api_handover_needs_a_cluster(client, respx_mock):
|
||||||
|
respx_mock.get("https://solo.test/api/diagnostics/interface/getVipStatus").respond(200, json={"rows": []})
|
||||||
|
h = await _create_host(client, "solo", "https://solo.test")
|
||||||
|
when = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
|
||||||
|
r = await client.post(f"/api/hosts/{h['id']}/maintenance/schedule",
|
||||||
|
json={"action": "reboot", "run_at": when, "acknowledge_unattended": True, "carp_handover": True})
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["detail"]["code"] == "CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER"
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -76,25 +78,58 @@ async def test_fire_due_reboot_success(client, respx_mock):
|
|||||||
assert reboot.call_count == 1
|
assert reboot.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
async def _due_update(h, action="update"):
|
||||||
async def test_fire_due_update_blocked_without_fresh_backup(client, respx_mock):
|
|
||||||
mock_reads(respx_mock, status="update")
|
|
||||||
h = await _create_host(client, "fw1", "https://fw1.test")
|
|
||||||
|
|
||||||
async with SessionLocal() as db:
|
async with SessionLocal() as db:
|
||||||
sched = ScheduledMaintenance(
|
sched = ScheduledMaintenance(
|
||||||
host_id=uuid.UUID(h["id"]), host_name="fw1", action="update",
|
host_id=uuid.UUID(h["id"]), host_name="fw1", action=action,
|
||||||
run_at=datetime.now(timezone.utc) - timedelta(minutes=1),
|
run_at=datetime.now(timezone.utc) - timedelta(minutes=1),
|
||||||
actor="admin", created_at=datetime.now(timezone.utc),
|
actor="admin", created_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
db.add(sched)
|
db.add(sched)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
return sched.id
|
||||||
|
|
||||||
result = await scheduled_maintenance_service.fire_due(db)
|
|
||||||
assert result == {"due": 1, "fired": 0, "blocked_or_failed": 1}
|
@pytest.mark.asyncio
|
||||||
refreshed = await db.get(ScheduledMaintenance, sched.id)
|
async def test_fire_due_update_takes_its_own_backup_first(client, respx_mock):
|
||||||
|
mock_reads(respx_mock, status="update")
|
||||||
|
h = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
download = respx_mock.get("https://fw1.test/api/core/backup/download/this").respond(200, content=b"<opnsense/>")
|
||||||
|
update = respx_mock.post("https://fw1.test/api/core/firmware/update").respond(200, json={"status": "ok"})
|
||||||
|
sched_id = await _due_update(h)
|
||||||
|
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert await scheduled_maintenance_service.fire_due(db) == {"due": 1, "fired": 1, "blocked_or_failed": 0}
|
||||||
|
assert (await db.get(ScheduledMaintenance, sched_id)).status == "succeeded"
|
||||||
|
assert download.call_count == 1 and update.call_count == 1
|
||||||
|
assert len(backup_store.list_snapshots(uuid.UUID(h["id"]))) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fire_due_update_fails_closed_when_backup_fails(client, respx_mock):
|
||||||
|
mock_reads(respx_mock, status="update")
|
||||||
|
h = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
respx_mock.get("https://fw1.test/api/core/backup/download/this").mock(side_effect=httpx.ConnectError("down"))
|
||||||
|
update = respx_mock.post("https://fw1.test/api/core/firmware/update").respond(200, json={"status": "ok"})
|
||||||
|
sched_id = await _due_update(h)
|
||||||
|
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
assert await scheduled_maintenance_service.fire_due(db) == {"due": 1, "fired": 0, "blocked_or_failed": 1}
|
||||||
|
refreshed = await db.get(ScheduledMaintenance, sched_id)
|
||||||
assert refreshed.status == "failed"
|
assert refreshed.status == "failed"
|
||||||
assert "Konfigurationsbackup" in refreshed.result["error"]
|
assert "Backup" in refreshed.result["error"]
|
||||||
|
assert update.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fire_due_reboot_takes_no_backup(client, respx_mock):
|
||||||
|
mock_reads(respx_mock)
|
||||||
|
h = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
respx_mock.post("https://fw1.test/api/core/system/reboot").respond(200, json={"status": "ok"})
|
||||||
|
sched_id = await _due_update(h, action="reboot") # no backup route mocked: a download would raise
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
await scheduled_maintenance_service.fire_due(db)
|
||||||
|
assert (await db.get(ScheduledMaintenance, sched_id)).status == "succeeded"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -126,3 +161,123 @@ async def test_fire_due_blocked_by_active_operation_on_cluster_peer(client, resp
|
|||||||
assert result == {"due": 1, "fired": 0, "blocked_or_failed": 1}
|
assert result == {"due": 1, "fired": 0, "blocked_or_failed": 1}
|
||||||
refreshed = await db.get(ScheduledMaintenance, sched.id)
|
refreshed = await db.get(ScheduledMaintenance, sched.id)
|
||||||
assert refreshed.status == "failed"
|
assert refreshed.status == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
# ── CARP role is checked when the plan is created, not first at 3 a.m. ────
|
||||||
|
|
||||||
|
def _cluster_roles(respx_mock, roles):
|
||||||
|
for base, role in roles.items():
|
||||||
|
if role is None:
|
||||||
|
respx_mock.get(f"{base}/api/diagnostics/interface/getVipStatus").mock(side_effect=httpx.ConnectError("down"))
|
||||||
|
else:
|
||||||
|
respx_mock.get(f"{base}/api/diagnostics/interface/getVipStatus").respond(
|
||||||
|
200, json={"rows": [{"vhid": "1", "status": role}], "carp": {"allow": "1", "maintenancemode": "0", "demotion": "0"}}
|
||||||
|
)
|
||||||
|
respx_mock.get(f"{base}/api/core/firmware/running").respond(200, json={"status": "ready"})
|
||||||
|
|
||||||
|
|
||||||
|
async def _cluster(client, a_url="https://fw-a.test", b_url="https://fw-b.test"):
|
||||||
|
a = await _create_host(client, "fw-a", a_url)
|
||||||
|
b = await _create_host(client, "fw-b", b_url)
|
||||||
|
await client.patch(f"/api/hosts/{a['id']}", json={"cluster_name": "ha"})
|
||||||
|
await client.patch(f"/api/hosts/{b['id']}", json={"cluster_name": "ha"})
|
||||||
|
return a, b
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_schedule_on_carp_master_is_rejected_up_front(client, respx_mock):
|
||||||
|
_cluster_roles(respx_mock, {"https://fw-a.test": "MASTER", "https://fw-b.test": "BACKUP"})
|
||||||
|
a, b = await _cluster(client)
|
||||||
|
|
||||||
|
rejected = await schedule(client, a, action="update")
|
||||||
|
assert rejected.status_code == 409, rejected.text
|
||||||
|
assert rejected.json()["detail"]["code"] == "GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH"
|
||||||
|
assert (await client.get(f"/api/hosts/{a['id']}/maintenance/schedule")).json() == []
|
||||||
|
|
||||||
|
# The backup node is where unattended maintenance belongs.
|
||||||
|
accepted = await schedule(client, b, action="update")
|
||||||
|
assert accepted.status_code == 200, accepted.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_schedule_accepted_when_role_cannot_be_determined(client, respx_mock):
|
||||||
|
# Unreachable now does not mean wrong at run time — the full check still runs when it fires.
|
||||||
|
_cluster_roles(respx_mock, {"https://fw-a.test": None, "https://fw-b.test": "MASTER"})
|
||||||
|
a, _b = await _cluster(client)
|
||||||
|
assert (await schedule(client, a)).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_schedule_on_standalone_host_skips_role_check(client, respx_mock):
|
||||||
|
mock_reads(respx_mock, carp="MASTER")
|
||||||
|
h = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
assert (await schedule(client, h)).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ── a plan that did not run is reported ───────────────────────────────────
|
||||||
|
|
||||||
|
async def _only_enable(db, flag):
|
||||||
|
from app.services import notification_settings_service
|
||||||
|
row = await notification_settings_service.get_settings(db)
|
||||||
|
for column in row.__table__.columns.keys():
|
||||||
|
if column.endswith("_enabled"):
|
||||||
|
setattr(row, column, column == flag)
|
||||||
|
row.webhook_enabled = True
|
||||||
|
row.webhook_url = "https://hooks.test/notify"
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_schedule_is_notified_once_and_closes_silently(client, respx_mock):
|
||||||
|
from app.models.notification import NotificationProblem
|
||||||
|
from app.services.notification_service import run_notification_checks
|
||||||
|
|
||||||
|
mock_reads(respx_mock)
|
||||||
|
h = await _create_host(client, "fw1", "https://fw1.test")
|
||||||
|
hook = respx_mock.post("https://hooks.test/notify").respond(200)
|
||||||
|
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
await _only_enable(db, "scheduled_maintenance_failed_enabled")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
failed = ScheduledMaintenance(
|
||||||
|
host_id=uuid.UUID(h["id"]), host_name="fw1", action="update", run_at=now - timedelta(hours=2),
|
||||||
|
status="failed", actor="admin", created_at=now - timedelta(days=1),
|
||||||
|
result={"error": "Cluster-Wartung nur auf dem CARP-BACKUP."},
|
||||||
|
)
|
||||||
|
ok = ScheduledMaintenance(
|
||||||
|
host_id=uuid.UUID(h["id"]), host_name="fw1", action="reboot", run_at=now - timedelta(hours=1),
|
||||||
|
status="succeeded", actor="admin", created_at=now - timedelta(days=1),
|
||||||
|
)
|
||||||
|
old = ScheduledMaintenance(
|
||||||
|
host_id=uuid.UUID(h["id"]), host_name="fw1", action="reboot", run_at=now - timedelta(days=10),
|
||||||
|
status="failed", actor="admin", created_at=now - timedelta(days=11),
|
||||||
|
)
|
||||||
|
db.add_all([failed, ok, old])
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
assert (await run_notification_checks(db))["opened"] == 1
|
||||||
|
assert hook.call_count == 1
|
||||||
|
sent = json.loads(hook.calls.last.request.content)
|
||||||
|
assert sent["problem_type"] == "scheduled_maintenance_failed"
|
||||||
|
assert sent["context"]["host_name"] == "fw1" and "CARP-BACKUP" in sent["context"]["message"]
|
||||||
|
|
||||||
|
# Still failed on the next poll: no second message.
|
||||||
|
assert (await run_notification_checks(db))["opened"] == 0
|
||||||
|
assert hook.call_count == 1
|
||||||
|
|
||||||
|
# Once it drops out of the window nothing was "fixed" — no "behoben" message.
|
||||||
|
failed.run_at = now - timedelta(days=4)
|
||||||
|
await db.commit()
|
||||||
|
result = await run_notification_checks(db)
|
||||||
|
assert result["resolved"] == 1
|
||||||
|
assert hook.call_count == 1
|
||||||
|
problem = (await db.execute(select(NotificationProblem).where(NotificationProblem.problem_type == "scheduled_maintenance_failed"))).scalar_one()
|
||||||
|
assert problem.status == "resolved"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_settings_roundtrip_scheduled_maintenance_failed_toggle(client):
|
||||||
|
assert (await client.get("/api/notifications/settings")).json()["scheduled_maintenance_failed_enabled"] is True
|
||||||
|
resp = await client.put("/api/notifications/settings", json={"scheduled_maintenance_failed_enabled": False})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["scheduled_maintenance_failed_enabled"] is False
|
||||||
@@ -28,9 +28,8 @@ services:
|
|||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
# Docker's default 64MB /dev/shm is too small for Postgres's parallel
|
# Docker's default 64MB /dev/shm is too small for Postgres's parallel
|
||||||
# vacuum/query workers on anything but a toy dataset — found the hard
|
# vacuum/query workers on anything but a toy dataset — found the hard
|
||||||
# way running VACUUM on a multi-GB table (see docs/HANDBUCH.md's "Backup &
|
# way running VACUUM on a multi-GB table (see docs/HANDBUCH.md's "Logs" section for how
|
||||||
# Restore" section for how that table got that large in the first
|
# large that table gets). Official Postgres image docs recommend raising this.
|
||||||
# place). Official Postgres image docs recommend raising this.
|
|
||||||
shm_size: "256mb"
|
shm_size: "256mb"
|
||||||
environment:
|
environment:
|
||||||
# Datenbank- und Benutzername. Wirken nur beim allerersten Start eines
|
# Datenbank- und Benutzername. Wirken nur beim allerersten Start eines
|
||||||
@@ -62,8 +61,8 @@ services:
|
|||||||
# path at all. Reuses the postgres image directly (pg_dump is already
|
# path at all. Reuses the postgres image directly (pg_dump is already
|
||||||
# in it) rather than adding a new image to build/maintain.
|
# in it) rather than adding a new image to build/maintain.
|
||||||
# Excludes firewall_log_entries/host_resource_samples: high-volume,
|
# Excludes firewall_log_entries/host_resource_samples: high-volume,
|
||||||
# disposable operational history (see docs/HANDBUCH.md's "Backup & Restore"
|
# disposable operational history (see docs/HANDBUCH.md's "Logs" section for
|
||||||
# section for exactly how large this got in practice) rather than
|
# how large these get) rather than
|
||||||
# config a disaster recovery actually needs — including them would
|
# config a disaster recovery actually needs — including them would
|
||||||
# make the backup itself multi-GB and a disk-usage risk in its own
|
# make the backup itself multi-GB and a disk-usage risk in its own
|
||||||
# right, working against the point of this service.
|
# right, working against the point of this service.
|
||||||
@@ -142,7 +141,7 @@ services:
|
|||||||
# Bound to loopback only by default — reachable from this host, not
|
# Bound to loopback only by default — reachable from this host, not
|
||||||
# the network. Set BIND_ADDRESS in .env (e.g. to 0.0.0.0, or a
|
# the network. Set BIND_ADDRESS in .env (e.g. to 0.0.0.0, or a
|
||||||
# specific interface IP) for network-wide access instead of editing
|
# specific interface IP) for network-wide access instead of editing
|
||||||
# this file — see docs/HANDBUCH.md's "Produktivbetrieb" for the TLS-proxy
|
# this file — see docs/HANDBUCH.md's "Zugriff aus dem Netz und TLS" for the TLS-proxy
|
||||||
# alternative, which is safer than widening this directly.
|
# alternative, which is safer than widening this directly.
|
||||||
ports:
|
ports:
|
||||||
- "${BIND_ADDRESS:-127.0.0.1}:${BACKEND_PORT:-8100}:8000"
|
- "${BIND_ADDRESS:-127.0.0.1}:${BACKEND_PORT:-8100}:8000"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 88 KiB |
@@ -60,6 +60,7 @@ function NotificationsSection() {
|
|||||||
const [natDrift, setNatDrift] = useState(true);
|
const [natDrift, setNatDrift] = useState(true);
|
||||||
const [networkDrift, setNetworkDrift] = useState(true);
|
const [networkDrift, setNetworkDrift] = useState(true);
|
||||||
const [carpAutoRevertFailed, setCarpAutoRevertFailed] = useState(true);
|
const [carpAutoRevertFailed, setCarpAutoRevertFailed] = useState(true);
|
||||||
|
const [scheduledFailed, setScheduledFailed] = useState(true);
|
||||||
const [pollInterval, setPollInterval] = useState(300);
|
const [pollInterval, setPollInterval] = useState(300);
|
||||||
|
|
||||||
function applyFromServer(data: NotificationSettings) {
|
function applyFromServer(data: NotificationSettings) {
|
||||||
@@ -82,6 +83,7 @@ function NotificationsSection() {
|
|||||||
setNatDrift(data.nat_drift_enabled);
|
setNatDrift(data.nat_drift_enabled);
|
||||||
setNetworkDrift(data.network_drift_enabled);
|
setNetworkDrift(data.network_drift_enabled);
|
||||||
setCarpAutoRevertFailed(data.carp_auto_revert_failed_enabled);
|
setCarpAutoRevertFailed(data.carp_auto_revert_failed_enabled);
|
||||||
|
setScheduledFailed(data.scheduled_maintenance_failed_enabled);
|
||||||
setPollInterval(data.poll_interval_seconds);
|
setPollInterval(data.poll_interval_seconds);
|
||||||
setWebhookSecret("");
|
setWebhookSecret("");
|
||||||
setClearWebhookSecret(false);
|
setClearWebhookSecret(false);
|
||||||
@@ -122,6 +124,7 @@ function NotificationsSection() {
|
|||||||
nat_drift_enabled: natDrift,
|
nat_drift_enabled: natDrift,
|
||||||
network_drift_enabled: networkDrift,
|
network_drift_enabled: networkDrift,
|
||||||
carp_auto_revert_failed_enabled: carpAutoRevertFailed,
|
carp_auto_revert_failed_enabled: carpAutoRevertFailed,
|
||||||
|
scheduled_maintenance_failed_enabled: scheduledFailed,
|
||||||
poll_interval_seconds: pollInterval,
|
poll_interval_seconds: pollInterval,
|
||||||
});
|
});
|
||||||
applyFromServer(data);
|
applyFromServer(data);
|
||||||
@@ -253,6 +256,7 @@ function NotificationsSection() {
|
|||||||
[t("settings.notify.event.natDrift"), natDrift, setNatDrift],
|
[t("settings.notify.event.natDrift"), natDrift, setNatDrift],
|
||||||
[t("settings.notify.event.networkDrift"), networkDrift, setNetworkDrift],
|
[t("settings.notify.event.networkDrift"), networkDrift, setNetworkDrift],
|
||||||
[t("settings.notify.event.carpAutoRevertFailed"), carpAutoRevertFailed, setCarpAutoRevertFailed],
|
[t("settings.notify.event.carpAutoRevertFailed"), carpAutoRevertFailed, setCarpAutoRevertFailed],
|
||||||
|
[t("settings.notify.event.scheduledFailed"), scheduledFailed, setScheduledFailed],
|
||||||
].map(([label, value, setter]) => (
|
].map(([label, value, setter]) => (
|
||||||
<label key={label as string} className="flex items-center gap-2 text-sm">
|
<label key={label as string} className="flex items-center gap-2 text-sm">
|
||||||
<input type="checkbox" checked={value as boolean} onChange={(e) => (setter as (v: boolean) => void)(e.target.checked)} />
|
<input type="checkbox" checked={value as boolean} onChange={(e) => (setter as (v: boolean) => void)(e.target.checked)} />
|
||||||
|
|||||||
@@ -21,22 +21,23 @@ export function HostMaintenance({host}: {host:Host}) {
|
|||||||
const [name,setName]=useState('');const [ack,setAck]=useState(false);const [log,setLog]=useState('');
|
const [name,setName]=useState('');const [ack,setAck]=useState(false);const [log,setLog]=useState('');
|
||||||
const [cancelOp,setCancelOp]=useState<HostOperation|null>(null);const [reason,setReason]=useState('');
|
const [cancelOp,setCancelOp]=useState<HostOperation|null>(null);const [reason,setReason]=useState('');
|
||||||
const [scheduled,setScheduled]=useState<ScheduledMaintenanceItem[]>([]);
|
const [scheduled,setScheduled]=useState<ScheduledMaintenanceItem[]>([]);
|
||||||
const [schedule,setSchedule]=useState<{action:'reboot'|'update'|'major_update';runAt:string;ack:boolean}|null>(null);
|
const [schedule,setSchedule]=useState<{action:'reboot'|'update'|'major_update';runAt:string;ack:boolean;handover:boolean}|null>(null);
|
||||||
async function load(){setError('');try{setData(await api.hostMaintenance(host.id));}catch(e){setError(errorMessage(e,'maint.loadFailed'));}}
|
async function load(){setError('');try{setData(await api.hostMaintenance(host.id));}catch(e){setError(errorMessage(e,'maint.loadFailed'));}}
|
||||||
async function loadScheduled(){try{setScheduled(await api.listScheduledMaintenance(host.id));}catch{/* non-fatal, section just stays empty */}}
|
async function loadScheduled(){try{setScheduled(await api.listScheduledMaintenance(host.id));}catch{/* non-fatal, section just stays empty */}}
|
||||||
useEffect(()=>{if(open){load();loadScheduled();}},[open,host.id]);
|
useEffect(()=>{if(open){load();loadScheduled();}},[open,host.id]);
|
||||||
async function createSchedule(){if(!schedule)return;setBusy(true);setError('');try{await api.createScheduledMaintenance(host.id,schedule.action,new Date(schedule.runAt).toISOString());setSchedule(null);await loadScheduled();}catch(e){setError(errorMessage(e,'maint.scheduleFailed'));}finally{setBusy(false);}}
|
async function createSchedule(){if(!schedule)return;setBusy(true);setError('');try{await api.createScheduledMaintenance(host.id,schedule.action,new Date(schedule.runAt).toISOString(),schedule.handover);setSchedule(null);await loadScheduled();}catch(e){setError(errorMessage(e,'maint.scheduleFailed'));}finally{setBusy(false);}}
|
||||||
async function cancelSchedule(id:string){setBusy(true);setError('');try{await api.cancelScheduledMaintenance(host.id,id);await loadScheduled();}catch(e){setError(errorMessage(e,'maint.cancelFailed'));}finally{setBusy(false);}}
|
async function cancelSchedule(id:string){setBusy(true);setError('');try{await api.cancelScheduledMaintenance(host.id,id);await loadScheduled();}catch(e){setError(errorMessage(e,'maint.cancelFailed'));}finally{setBusy(false);}}
|
||||||
useEffect(()=>{if(!open || !data?.operations.some(o=>['starting','submitted','unknown'].includes(o.status)))return;const timer=setInterval(load,15000);return()=>clearInterval(timer);},[open,host.id,data?.operations.map(o=>o.status).join(',')]);
|
useEffect(()=>{if(!open || !data?.operations.some(o=>['starting','submitted','unknown'].includes(o.status)))return;const timer=setInterval(()=>{load();loadScheduled();},15000);return()=>clearInterval(timer);},[open,host.id,data?.operations.map(o=>o.status).join(',')]);
|
||||||
async function preview(action:'reboot'|'update'|'major_update'){setBusy(true);setError('');try{setPlan(await api.previewMaintenance(host.id,action));setName('');setAck(false);}catch(e){setError(errorMessage(e,'maint.previewFailed'));}finally{setBusy(false);}}
|
async function preview(action:'reboot'|'update'|'major_update'){setBusy(true);setError('');try{setPlan(await api.previewMaintenance(host.id,action));setName('');setAck(false);}catch(e){setError(errorMessage(e,'maint.previewFailed'));}finally{setBusy(false);}}
|
||||||
async function execute(){if(!plan)return;setBusy(true);setError('');try{const op=await api.executeMaintenance(host.id,plan.preview_id,name,ack);setNotice(op.message ? ts(op.message) : t('maint.jobSubmitted'));setPlan(null);await load();}catch(e){setError(errorMessage(e,'maint.outcomeUnclear'));setPlan(null);await load();}finally{setBusy(false);}}
|
async function execute(){if(!plan)return;setBusy(true);setError('');try{const op=await api.executeMaintenance(host.id,plan.preview_id,name,ack);setNotice(op.message ? ts(op.message) : t('maint.jobSubmitted'));setPlan(null);await load();}catch(e){setError(errorMessage(e,'maint.outcomeUnclear'));setPlan(null);await load();}finally{setBusy(false);}}
|
||||||
async function closeOperation(){if(!finish)return;setBusy(true);setError('');try{await api.acknowledgeMaintenance(host.id,finish.id,name);setFinish(null);await load();}catch(e){setError(errorMessage(e,'maint.finishFailed'));}finally{setBusy(false);}}
|
async function closeOperation(){if(!finish)return;setBusy(true);setError('');try{await api.acknowledgeMaintenance(host.id,finish.id,name);setFinish(null);await load();}catch(e){setError(errorMessage(e,'maint.finishFailed'));}finally{setBusy(false);}}
|
||||||
async function cancelOperation(){if(!cancelOp)return;setBusy(true);setError('');try{await api.cancelMaintenance(host.id,cancelOp.id,name,reason);setCancelOp(null);await load();}catch(e){setError(errorMessage(e,'maint.cancelFailed'));}finally{setBusy(false);}}
|
async function cancelOperation(){if(!cancelOp)return;setBusy(true);setError('');try{await api.cancelMaintenance(host.id,cancelOp.id,name,reason);setCancelOp(null);await load();}catch(e){setError(errorMessage(e,'maint.cancelFailed'));}finally{setBusy(false);}}
|
||||||
async function check(){setBusy(true);setError('');try{await api.checkHostUpdates(host.id);setNotice(t('maint.checkRequested'));await load();}catch(e){setError(errorMessage(e,'maint.checkFailed'));}finally{setBusy(false);}}
|
async function check(){setBusy(true);setError('');try{await api.checkHostUpdates(host.id);setNotice(t('maint.checkRequested'));await load();}catch(e){setError(errorMessage(e,'maint.checkFailed'));}finally{setBusy(false);}}
|
||||||
return <section className="my-6 rounded-xl border border-slate-200 bg-white p-5"><div className="flex flex-wrap items-center justify-between gap-2"><h2 className="flex items-center gap-2 font-semibold">{t('maint.title')}<InfoTip text={t('maint.intro')} /></h2><Button variant="ghost" onClick={()=>setOpen(v=>!v)}>{open?t('maint.close'):t('maint.open')}</Button></div>{open && <div className="mt-4 space-y-4">{error && <p role="alert" className="text-sm text-red-600">{error}</p>}{notice && <p role="status" className="text-sm text-sky-700">{notice}</p>}{!data && !error && <p>{t('common.loading')}</p>}{data?.error && <p role="alert" className="text-sm text-red-600">{ts(data.error)}</p>}{data?.state && <><div className="grid gap-2 text-sm sm:grid-cols-2"><p>{t('maint.installed',{version:''})}<strong>{data.state.version}</strong></p><p>{t('maint.firmwareJob',{state:data.state.running})}</p><p className="sm:col-span-2">{ts(data.state.firmware_message)}</p><p>{t('maint.lastCheck',{value:data.state.last_check || t('maint.unknown')})}</p><p>{t('maint.backup',{value:data.state.backup?new Date(data.state.backup.created_at).toLocaleString(locale):t('maint.noBackup')})}</p></div><div className="flex flex-wrap items-center gap-2"><Tooltip content={t('updates.tipCheck')}><Button variant="secondary" disabled={busy || data.state.running!=='ready' || !host.enabled || data.operations.some(o=>['starting','submitted','unknown'].includes(o.status))} onClick={check}>{t('maint.checkUpdates')}</Button></Tooltip><CarpSwitchControl host={host} state={data.state} disabled={busy || data.operations.some(o=>['starting','submitted','unknown'].includes(o.status))} onChanged={load}/><Button variant="danger" disabled={busy || data.state.actions.reboot.length>0} onClick={()=>preview('reboot')}>{t('maint.previewReboot')}</Button><Tooltip content={t('updates.tipPrepare')}><Button disabled={busy || data.state.actions.update.length>0} onClick={()=>preview('update')}>{t('maint.previewUpdate')}</Button></Tooltip><Tooltip content={t('updates.tipPrepareMajor')}><Button variant="danger" disabled={busy || data.state.actions.major_update.length>0} onClick={()=>preview('major_update')}>{t('maint.previewMajor')}</Button></Tooltip><Button variant="ghost" disabled={busy || !host.enabled} onClick={()=>setSchedule({action:'reboot',runAt:'',ack:false})}>{t('maint.scheduleLater')}</Button><Button variant="ghost" className="ml-auto" disabled={busy} onClick={load}>{t('maint.refreshStatus')}</Button></div>{readableReasons(data.state,host,t,ts).length>0 && <Tooltip focusable placement="bottom" content={<span className="block space-y-1">{readableReasons(data.state,host,t,ts).map(reason=><span key={reason} className="block">{reason}</span>)}</span>}><Badge tone="warning">{t('maint.hints')} ({readableReasons(data.state,host,t,ts).length})</Badge></Tooltip>}{scheduled.length>0 && <div className="space-y-2"><h3 className="text-sm font-semibold text-slate-700">{t('maint.scheduled')}</h3>{scheduled.map(s=><div key={s.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-slate-200 p-3 text-sm"><div><strong>{labels[s.action]}</strong> · {new Date(s.run_at).toLocaleString(locale)} · {t(`maint.scheduleStatus.${s.status}`)}{s.result?.error && <span className="text-red-600"> — {ts(s.result.error)}</span>}</div>{s.status==='pending' && <Button variant="ghost" disabled={busy} onClick={()=>cancelSchedule(s.id)}>{t('common.cancel')}</Button>}</div>)}</div>}</>}{data?.operations && data.operations.length>0 && <h3 className="text-sm font-semibold text-slate-700">{t('maint.history')}</h3>}{data?.operations.map(op=><div key={op.id} className="rounded-lg border border-slate-200 p-3 text-sm"><strong>{labels[op.action]} · {['acknowledged','cancelled','unknown','starting'].includes(op.status)?t(`maint.opStatus.${op.status}`):t('maint.opStatus.submitted')}</strong><p className="text-slate-500">{new Date(op.created_at).toLocaleString(locale)} · {op.actor}</p><p className="mt-1">{ts(op.message ?? "")}</p>{['starting','submitted','unknown'].includes(op.status) && <div className="mt-2 flex flex-wrap gap-2"><Button variant="secondary" disabled={busy} onClick={()=>{setFinish(op);setName('');setAck(false);}}>{t('maint.confirmFinish')}</Button><Button variant="danger" disabled={busy} onClick={()=>{setCancelOp(op);setName('');setReason('');}}>{t('maint.cancelOperation')}</Button></div>}</div>)}<Button variant="ghost" disabled={busy} onClick={async()=>{try{const p=await api.hostUpdateProgress(host.id);setLog(p.log || p.status);}catch(e){setError(errorMessage(e,'maint.logFailed'));}}}>{t('maint.readLog')}</Button>{log && <pre className="max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-slate-50 p-3 text-xs">{log}</pre>}</div>}
|
const masterBlocked=!!data?.state?.cluster_name && data.state.carp_status==='MASTER' && !schedule?.handover;
|
||||||
|
return <section className="my-6 rounded-xl border border-slate-200 bg-white p-5"><div className="flex flex-wrap items-center justify-between gap-2"><h2 className="flex items-center gap-2 font-semibold">{t('maint.title')}<InfoTip text={t('maint.intro')} /></h2><Button variant="ghost" onClick={()=>setOpen(v=>!v)}>{open?t('maint.close'):t('maint.open')}</Button></div>{open && <div className="mt-4 space-y-4">{error && <p role="alert" className="text-sm text-red-600">{error}</p>}{notice && <p role="status" className="text-sm text-sky-700">{notice}</p>}{!data && !error && <p>{t('common.loading')}</p>}{data?.error && <p role="alert" className="text-sm text-red-600">{ts(data.error)}</p>}{data?.state && <><div className="grid gap-2 text-sm sm:grid-cols-2"><p>{t('maint.installed',{version:''})}<strong>{data.state.version}</strong></p><p>{t('maint.firmwareJob',{state:data.state.running})}</p><p className="sm:col-span-2">{ts(data.state.firmware_message)}</p><p>{t('maint.lastCheck',{value:data.state.last_check || t('maint.unknown')})}</p><p>{t('maint.backup',{value:data.state.backup?new Date(data.state.backup.created_at).toLocaleString(locale):t('maint.noBackup')})}</p></div><div className="flex flex-wrap items-center gap-2"><Tooltip content={t('updates.tipCheck')}><Button variant="secondary" disabled={busy || data.state.running!=='ready' || !host.enabled || data.operations.some(o=>['starting','submitted','unknown'].includes(o.status))} onClick={check}>{t('maint.checkUpdates')}</Button></Tooltip><CarpSwitchControl host={host} state={data.state} disabled={busy || data.operations.some(o=>['starting','submitted','unknown'].includes(o.status))} onChanged={load}/><Button variant="danger" disabled={busy || data.state.actions.reboot.length>0} onClick={()=>preview('reboot')}>{t('maint.previewReboot')}</Button><Tooltip content={t('updates.tipPrepare')}><Button disabled={busy || data.state.actions.update.length>0} onClick={()=>preview('update')}>{t('maint.previewUpdate')}</Button></Tooltip><Tooltip content={t('updates.tipPrepareMajor')}><Button variant="danger" disabled={busy || data.state.actions.major_update.length>0} onClick={()=>preview('major_update')}>{t('maint.previewMajor')}</Button></Tooltip><Button variant="ghost" disabled={busy || !host.enabled} onClick={()=>setSchedule({action:'reboot',runAt:'',ack:false,handover:false})}>{t('maint.scheduleLater')}</Button><Button variant="ghost" className="ml-auto" disabled={busy} onClick={load}>{t('maint.refreshStatus')}</Button></div>{readableReasons(data.state,host,t,ts).length>0 && <Tooltip focusable placement="bottom" content={<span className="block space-y-1">{readableReasons(data.state,host,t,ts).map(reason=><span key={reason} className="block">{reason}</span>)}</span>}><Badge tone="warning">{t('maint.hints')} ({readableReasons(data.state,host,t,ts).length})</Badge></Tooltip>}{scheduled.length>0 && <div className="space-y-2"><h3 className="text-sm font-semibold text-slate-700">{t('maint.scheduled')}</h3>{scheduled.map(s=><div key={s.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-slate-200 p-3 text-sm"><div><strong>{labels[s.action]}</strong> · {new Date(s.run_at).toLocaleString(locale)} · {t(`maint.scheduleStatus.${s.status==='succeeded' && s.result?.handover ? 'completed' : s.status}`)}{s.status==='running' && s.phase && <span> ({t(`maint.phase.${s.phase}`)})</span>}{s.carp_handover && <Badge tone="info" className="ml-2">{t('maint.handoverBadge')}</Badge>}{s.result?.error && <span className="text-red-600"> — {ts(s.result.error)}</span>}</div>{s.status==='pending' && <Button variant="ghost" disabled={busy} onClick={()=>cancelSchedule(s.id)}>{t('common.cancel')}</Button>}</div>)}</div>}</>}{data?.operations && data.operations.length>0 && <h3 className="text-sm font-semibold text-slate-700">{t('maint.history')}</h3>}{data?.operations.map(op=><div key={op.id} className="rounded-lg border border-slate-200 p-3 text-sm"><strong>{labels[op.action]} · {['acknowledged','cancelled','unknown','starting'].includes(op.status)?t(`maint.opStatus.${op.status}`):t('maint.opStatus.submitted')}</strong><p className="text-slate-500">{new Date(op.created_at).toLocaleString(locale)} · {op.actor}</p><p className="mt-1">{ts(op.message ?? "")}</p>{['starting','submitted','unknown'].includes(op.status) && <div className="mt-2 flex flex-wrap gap-2"><Button variant="secondary" disabled={busy} onClick={()=>{setFinish(op);setName('');setAck(false);}}>{t('maint.confirmFinish')}</Button><Button variant="danger" disabled={busy} onClick={()=>{setCancelOp(op);setName('');setReason('');}}>{t('maint.cancelOperation')}</Button></div>}</div>)}<Button variant="ghost" disabled={busy} onClick={async()=>{try{const p=await api.hostUpdateProgress(host.id);setLog(p.log || p.status);}catch(e){setError(errorMessage(e,'maint.logFailed'));}}}>{t('maint.readLog')}</Button>{log && <pre className="max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-slate-50 p-3 tLine truncated
|
||||||
{plan && <Modal title={t('maint.confirmTitle',{action:labels[plan.action]})} onClose={()=>{if(!busy)setPlan(null);}}><div className="space-y-4 text-sm"><p><strong>{plan.state.host_name}</strong> · {plan.state.base_url} · {plan.state.version}</p>{plan.action==='major_update' && <p className="flex items-center gap-2 font-medium text-red-700"><AlertTriangle size={16}/>{t('updates.majorWarnShort')}<InfoTip text={t('updates.majorWarnTip')}/></p>}<p className="flex items-center gap-2 font-medium text-flame-800"><Clock3 size={16}/>{t('updates.downtimeShort')}<InfoTip text={t('maint.downtimeNote',{reboot:plan.state.reboot_required?t('maint.andReboot'):''})}/></p><p className="text-xs text-slate-500">{t('updates.validUntilShort',{time:new Date(plan.expires_at).toLocaleTimeString(locale)})}</p><label className="block"><span className="flex items-center gap-1">{t('maint.confirmHostname')}<InfoTip text={t('updates.tipConfirmName')} size={13}/></span><Input className="mt-1" autoFocus autoComplete="off" spellCheck={false} aria-label={t('maint.confirmHostnameAria')} value={name} placeholder={host.name} onChange={e=>setName(e.target.value)} onKeyDown={e=>{if(e.key==='Enter'&&!busy&&name===host.name&&ack)void execute();}}/></label><label className="flex items-start gap-2"><input type="checkbox" checked={ack} onChange={e=>setAck(e.target.checked)}/>{t('maint.windowReady')}</label><Tooltip focusable={busy||name!==host.name||!ack} content={name!==host.name?t('updates.confirmRunDisabledName'):!ack?t('updates.confirmRunDisabledAck'):null}><Button variant="danger" disabled={busy || name!==host.name || !ack} onClick={execute}>{busy?t('maint.submitting'):t('maint.executeNow',{action:labels[plan.action]})}</Button></Tooltip></div></Modal>}
|
{plan && <Modal title={t('maint.confirmTitle',{action:labels[plan.action]})} onClose={()=>{if(!busy)setPlan(null);}}><div className="space-y-4 text-sm"><p><strong>{plan.state.host_name}</strong> · {plan.state.base_url} · {plan.state.version}</p>{plan.action==='major_update' && <p className="flex items-center gap-2 font-medium text-red-700"><AlertTriangle size={16}/>{t('updates.majorWarnShort')}<InfoTip text={t('updates.majorWarnTip')}/></p>}<p className="flex items-center gap-2 font-medium text-flame-800"><Clock3 size={16}/>{t('updates.downtimeShort')}<InfoTip text={t('maint.downtimeNote',{reboot:plan.state.reboot_required?t('maint.andReboot'):''})}/></p><p className="text-xs text-slate-500">{t('updates.validUntilShort',{time:new Date(plan.expires_at).toLocaleTimeString(locale)})}</p><label className="block"><span className="flex items-center gap-1">{t('maint.confirmHostname')}<InfoTip text={t('updates.tipConfirmName')} size={13}/></span><Input className="mt-1" autoFocus autoComplete="off" spellCheck={false} aria-label={t('maint.confirmHostnameAria')} value={name} placeholder={host.name} onChange={e=>setName(e.target.value)} onKeyDown={e=>{if(e.key==='Enter'&&!busy&&name===host.name&&ack)void execute();}}/></label><label className="flex items-start gap-2"><input type="checkbox" checked={ack} onChange={e=>setAck(e.target.checked)}/>{t('maint.windowReady')}</label><Tooltip focusable={busy||name!==host.name||!ack} content={name!==host.name?t('updates.confirmRunDisabledName'):!ack?t('updates.confirmRunDisabledAck'):null}><Button variant="danger" disabled={busy || name!==host.name || !ack} onClick={execute}>{busy?t('maint.submitting'):t('maint.executeNow',{action:labels[plan.action]})}</Button></Tooltip></div></Modal>}
|
||||||
{finish && <Modal title={finish.action.startsWith('carp_')?t('maint.finishCarpTitle'):t('maint.finishTitle')} onClose={()=>{if(!busy)setFinish(null);}}><div className="space-y-4 text-sm"><p className="flex items-start gap-1">{finish.action.startsWith('carp_')?t('maint.finishCarpCheck'):<>{t('maint.finishCheckPre')}<strong>{host.name}</strong>. </>}<InfoTip text={t('updates.finishNoteTip')} size={13} className="mt-0.5"/></p><Input autoFocus autoComplete="off" spellCheck={false} aria-label={t('maint.finishHostnameAria')} value={name} onChange={e=>setName(e.target.value)} placeholder={host.name}/><label className="flex gap-2"><input type="checkbox" checked={ack} onChange={e=>setAck(e.target.checked)}/>{t('maint.finishChecked')}</label>{error && <p className="text-red-600">{error}</p>}<Tooltip focusable={busy||name!==host.name||!ack} content={name!==host.name?t('updates.confirmRunDisabledName'):!ack?t('updates.confirmRunDisabledAck'):null}><Button disabled={busy || name!==host.name || !ack} onClick={closeOperation}>{t('maint.saveFinish')}</Button></Tooltip></div></Modal>}
|
{finish && <Modal title={finish.action.startsWith('carp_')?t('maint.finishCarpTitle'):t('maint.finishTitle')} onClose={()=>{if(!busy)setFinish(null);}}><div className="space-y-4 text-sm"><p className="flex items-start gap-1">{finish.action.startsWith('carp_')?t('maint.finishCarpCheck'):<>{t('maint.finishCheckPre')}<strong>{host.name}</strong>. </>}<InfoTip text={t('updates.finishNoteTip')} size={13} className="mt-0.5"/></p><Input autoFocus autoComplete="off" spellCheck={false} aria-label={t('maint.finishHostnameAria')} value={name} onChange={e=>setName(e.target.value)} placeholder={host.name}/><label className="flex gap-2"><input type="checkbox" checked={ack} onChange={e=>setAck(e.target.checked)}/>{t('maint.finishChecked')}</label>{error && <p className="text-red-600">{error}</p>}<Tooltip focusable={busy||name!==host.name||!ack} content={name!==host.name?t('updates.confirmRunDisabledName'):!ack?t('updates.confirmRunDisabledAck'):null}><Button disabled={busy || name!==host.name || !ack} onClick={closeOperation}>{t('maint.saveFinish')}</Button></Tooltip></div></Modal>}
|
||||||
{cancelOp && <Modal title={t('maint.cancelOperation')} onClose={()=>{if(!busy)setCancelOp(null);}}><div className="space-y-4 text-sm"><p className="flex items-center gap-2 font-medium text-flame-800"><AlertTriangle size={16}/>{t('updates.cancelWarnShort')}<InfoTip text={t('updates.cancelWarnTip')}/></p><Input autoFocus autoComplete="off" spellCheck={false} aria-label={t('maint.cancelHostnameAria')} value={name} onChange={e=>setName(e.target.value)} placeholder={host.name}/><label className="block"><span className="flex items-center gap-1">{t('maint.reasonLabel')}<InfoTip text={t('updates.reasonMinHint')} size={13}/></span><textarea className="mt-1 w-full rounded-lg border border-slate-300 p-2" rows={2} maxLength={500} value={reason} onChange={e=>setReason(e.target.value)} placeholder={t('maint.reasonPlaceholder')}/></label>{error && <p className="text-red-600">{error}</p>}<Button variant="danger" disabled={busy || name!==host.name || reason.trim().length<3} onClick={cancelOperation}>{t('maint.closeWithoutConfirm')}</Button></div></Modal>}
|
{cancelOp && <Modal title={t('maint.cancelOperation')} onClose={()=>{if(!busy)setCancelOp(null);}}><div className="space-y-4 text-sm"><p className="flex items-center gap-2 font-medium text-flame-800"><AlertTriangle size={16}/>{t('updates.cancelWarnShort')}<InfoTip text={t('updates.cancelWarnTip')}/></p><Input autoFocus autoComplete="off" spellCheck={false} aria-label={t('maint.cancelHostnameAria')} value={name} onChange={e=>setName(e.target.value)} placeholder={host.name}/><label className="block"><span className="flex items-center gap-1">{t('maint.reasonLabel')}<InfoTip text={t('updates.reasonMinHint')} size={13}/></span><textarea className="mt-1 w-full rounded-lg border border-slate-300 p-2" rows={2} maxLength={500} value={reason} onChange={e=>setReason(e.target.value)} placeholder={t('maint.reasonPlaceholder')}/></label>{error && <p className="text-red-600">{error}</p>}<Button variant="danger" disabled={busy || name!==host.name || reason.trim().length<3} onClick={cancelOperation}>{t('maint.closeWithoutConfirm')}</Button></div></Modal>}
|
||||||
{schedule && <Modal title={t('maint.scheduleTitle')} onClose={()=>{if(!busy)setSchedule(null);}}><div className="space-y-4 text-sm"><label className="block"><span className="block">{t('maint.actionLabel')}</span><select className="mt-1 w-full rounded-lg border border-slate-300 p-2" value={schedule.action} onChange={e=>setSchedule({...schedule,action:e.target.value as typeof schedule.action})}><option value="reboot">{t('maint.action.reboot')}</option><option value="update">{t('maint.action.update')}</option><option value="major_update">{t('maint.action.major_update')}</option></select></label><label className="block"><span className="block">{t('maint.timeLabel')}</span><Input className="mt-1" aria-label={t('maint.timeLabel')} type="datetime-local" value={schedule.runAt} onChange={e=>setSchedule({...schedule,runAt:e.target.value})}/></label><p className="flex items-center gap-2 font-medium text-flame-800"><AlertTriangle size={16}/>{t('maint.unattendedShort')}<InfoTip text={t('maint.unattendedWarning')}/></p><label className="flex items-start gap-2"><input type="checkbox" checked={schedule.ack} onChange={e=>setSchedule({...schedule,ack:e.target.checked})}/>{t('maint.unattendedAck')}</label>{error && <p className="text-red-600">{error}</p>}<Button variant="danger" disabled={busy || !schedule.runAt || !schedule.ack} onClick={createSchedule}>{busy?t('maint.scheduling'):t('maint.scheduleIt')}</Button></div></Modal>}
|
{schedule && <Modal title={t('maint.scheduleTitle')} onClose={()=>{if(!busy)setSchedule(null);}}><div className="space-y-4 text-sm"><label className="block"><span className="block">{t('maint.actionLabel')}</span><select className="mt-1 w-full rounded-lg border border-slate-300 p-2" value={schedule.action} onChange={e=>setSchedule({...schedule,action:e.target.value as typeof schedule.action})}><option value="reboot">{t('maint.action.reboot')}</option><option value="update">{t('maint.action.update')}</option><option value="major_update">{t('maint.action.major_update')}</option></select></label><label className="block"><span className="block">{t('maint.timeLabel')}</span><Input className="mt-1" aria-label={t('maint.timeLabel')} type="datetime-local" value={schedule.runAt} onChange={e=>setSchedule({...schedule,runAt:e.target.value})}/></label>{host.cluster_name && <label className="flex items-start gap-2"><input type="checkbox" className="mt-1" checked={schedule.handover} onChange={e=>setSchedule({...schedule,handover:e.target.checked})}/><span>{t('maint.handoverOption')}<InfoTip text={t('maint.handoverHint')}/></span></label>}<p className="flex items-center gap-2 font-medium text-flame-800"><AlertTriangle size={16}/>{t('maint.unattendedShort')}<InfoTip text={t('maint.unattendedWarning')}/></p><label className="flex items-start gap-2"><input type="checkbox" checked={schedule.ack} onChange={e=>setSchedule({...schedule,ack:e.target.checked})}/>{t('maint.unattendedAck')}</label>{masterBlocked && <p role="alert" className="flex items-start gap-2 rounded-lg bg-red-50 p-3 text-red-700"><AlertTriangle size={16} className="mt-0.5 shrink-0"/>{t('maint.scheduleMasterBlocked')}</p>}{error && <p className="text-red-600">{error}</p>}<Button variant="danger" disabled={busy || masterBlocked || !schedule.runAt || !schedule.ack} onClick={createSchedule}>{busy?t('maint.scheduling'):t('maint.scheduleIt')}</Button></div></Modal>}
|
||||||
</section>;
|
</section>;
|
||||||
}
|
}
|
||||||
@@ -1122,7 +1122,7 @@ export const api = {
|
|||||||
stopService: (id:string, name:string) => request<{result:string}>(`/hosts/${id}/services/${encodeURIComponent(name)}/stop`,{method:"POST"}),
|
stopService: (id:string, name:string) => request<{result:string}>(`/hosts/${id}/services/${encodeURIComponent(name)}/stop`,{method:"POST"}),
|
||||||
restartService: (id:string, name:string) => request<{result:string}>(`/hosts/${id}/services/${encodeURIComponent(name)}/restart`,{method:"POST"}),
|
restartService: (id:string, name:string) => request<{result:string}>(`/hosts/${id}/services/${encodeURIComponent(name)}/restart`,{method:"POST"}),
|
||||||
listScheduledMaintenance: (id:string) => request<ScheduledMaintenanceItem[]>(`/hosts/${id}/maintenance/schedule`),
|
listScheduledMaintenance: (id:string) => request<ScheduledMaintenanceItem[]>(`/hosts/${id}/maintenance/schedule`),
|
||||||
createScheduledMaintenance: (id:string, action:"reboot"|"update"|"major_update", run_at:string) => request<ScheduledMaintenanceItem>(`/hosts/${id}/maintenance/schedule`,{method:"POST",body:JSON.stringify({action,run_at,acknowledge_unattended:true})}),
|
createScheduledMaintenance: (id:string, action:"reboot"|"update"|"major_update", run_at:string, carp_handover=false) => request<ScheduledMaintenanceItem>(`/hosts/${id}/maintenance/schedule`,{method:"POST",body:JSON.stringify({action,run_at,acknowledge_unattended:true,carp_handover})}),
|
||||||
cancelScheduledMaintenance: (id:string, scheduleId:string) => request<ScheduledMaintenanceItem>(`/hosts/${id}/maintenance/schedule/${scheduleId}`,{method:"DELETE"}),
|
cancelScheduledMaintenance: (id:string, scheduleId:string) => request<ScheduledMaintenanceItem>(`/hosts/${id}/maintenance/schedule/${scheduleId}`,{method:"DELETE"}),
|
||||||
audit: (filters: Record<string,string | number> = {}) => request<AuditPage>(`/audit?${new URLSearchParams(Object.entries(filters).filter(([,v])=>v!=="").map(([k,v])=>[k,String(v)]))}`),
|
audit: (filters: Record<string,string | number> = {}) => request<AuditPage>(`/audit?${new URLSearchParams(Object.entries(filters).filter(([,v])=>v!=="").map(([k,v])=>[k,String(v)]))}`),
|
||||||
dashboard: () => request<DashboardSummary>("/dashboard"),
|
dashboard: () => request<DashboardSummary>("/dashboard"),
|
||||||
@@ -1488,6 +1488,7 @@ export interface FirmwarePackageChange {
|
|||||||
export interface MaintenanceState {
|
export interface MaintenanceState {
|
||||||
host_name: string;
|
host_name: string;
|
||||||
base_url: string;
|
base_url: string;
|
||||||
|
cluster_name?: string | null;
|
||||||
version: string;
|
version: string;
|
||||||
product_latest?: string | null;
|
product_latest?: string | null;
|
||||||
carp_status?: string | null;
|
carp_status?: string | null;
|
||||||
@@ -1507,7 +1508,7 @@ export interface MaintenanceState {
|
|||||||
export interface HostOperation { id:string; host_id:string; action:"reboot"|"update"|"major_update"|"carp_enter"|"carp_leave"; status:string; created_at:string; expires_at:string; actor:string; message:string|null; }
|
export interface HostOperation { id:string; host_id:string; action:"reboot"|"update"|"major_update"|"carp_enter"|"carp_leave"; status:string; created_at:string; expires_at:string; actor:string; message:string|null; }
|
||||||
export interface OpnsenseService { id:string; name:string; description:string; locked:boolean|number; running:boolean|number; }
|
export interface OpnsenseService { id:string; name:string; description:string; locked:boolean|number; running:boolean|number; }
|
||||||
export interface HostResourceSample { id:string; sampled_at:string; mem_used_pct:number|null; load_avg_1m:number|null; disk_used_pct:number|null; raw:Record<string,unknown>; }
|
export interface HostResourceSample { id:string; sampled_at:string; mem_used_pct:number|null; load_avg_1m:number|null; disk_used_pct:number|null; raw:Record<string,unknown>; }
|
||||||
export interface ScheduledMaintenanceItem { id:string; host_id:string; action:"reboot"|"update"|"major_update"; run_at:string; status:"pending"|"running"|"succeeded"|"failed"|"cancelled"; actor:string; created_at:string; result:{error?:string;response?:unknown}; host_operation_id:string|null; }
|
export interface ScheduledMaintenanceItem { id:string; host_id:string; action:"reboot"|"update"|"major_update"; run_at:string; status:"pending"|"running"|"succeeded"|"failed"|"cancelled"; actor:string; created_at:string; result:{error?:string;response?:unknown;handover?:boolean;version_before?:string|null;version_after?:string|null;reboot_required?:boolean}; host_operation_id:string|null; carp_handover:boolean; phase:"handover_wait"|"job_wait"|"leave"|"leave_wait"|null; }
|
||||||
export interface MaintenanceRead {state:MaintenanceState|null;error:string|null;operations:HostOperation[];}
|
export interface MaintenanceRead {state:MaintenanceState|null;error:string|null;operations:HostOperation[];}
|
||||||
export interface MaintenancePreview {preview_id:string;expires_at:string;action:"reboot"|"update"|"major_update";state:MaintenanceState;}
|
export interface MaintenancePreview {preview_id:string;expires_at:string;action:"reboot"|"update"|"major_update";state:MaintenanceState;}
|
||||||
export interface CarpSwitchState {
|
export interface CarpSwitchState {
|
||||||
@@ -1552,6 +1553,7 @@ export interface NotificationSettings {
|
|||||||
nat_drift_enabled: boolean;
|
nat_drift_enabled: boolean;
|
||||||
network_drift_enabled: boolean;
|
network_drift_enabled: boolean;
|
||||||
carp_auto_revert_failed_enabled: boolean;
|
carp_auto_revert_failed_enabled: boolean;
|
||||||
|
scheduled_maintenance_failed_enabled: boolean;
|
||||||
poll_interval_seconds: number;
|
poll_interval_seconds: number;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -1579,6 +1581,7 @@ export interface NotificationSettingsUpdate {
|
|||||||
nat_drift_enabled?: boolean;
|
nat_drift_enabled?: boolean;
|
||||||
network_drift_enabled?: boolean;
|
network_drift_enabled?: boolean;
|
||||||
carp_auto_revert_failed_enabled?: boolean;
|
carp_auto_revert_failed_enabled?: boolean;
|
||||||
|
scheduled_maintenance_failed_enabled?: boolean;
|
||||||
poll_interval_seconds?: number;
|
poll_interval_seconds?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1601,7 +1604,8 @@ export interface LogSettingsUpdate {
|
|||||||
|
|
||||||
export type ProblemType =
|
export type ProblemType =
|
||||||
| "host_offline" | "cert_expiry" | "rule_drift" | "revert_failed" | "maintenance_unknown"
|
| "host_offline" | "cert_expiry" | "rule_drift" | "revert_failed" | "maintenance_unknown"
|
||||||
| "carp_split_brain" | "nat_drift" | "carp_auto_revert_failed" | "network_drift";
|
| "carp_split_brain" | "nat_drift" | "carp_auto_revert_failed" | "network_drift"
|
||||||
|
| "scheduled_maintenance_failed";
|
||||||
|
|
||||||
export interface CarpEvent {
|
export interface CarpEvent {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"carpHealth": "CARP-Zustand kritisch (Split-Brain / kein Master)",
|
"carpHealth": "CARP-Zustand kritisch (Split-Brain / kein Master)",
|
||||||
"natDrift": "NAT-Drift im Cluster",
|
"natDrift": "NAT-Drift im Cluster",
|
||||||
"carpAutoRevertFailed": "CARP-Auto-Revert fehlgeschlagen",
|
"carpAutoRevertFailed": "CARP-Auto-Revert fehlgeschlagen",
|
||||||
|
"scheduledFailed": "Geplante Wartung nicht ausgeführt",
|
||||||
"networkDrift": "Netzwerk-Abweichung im Cluster"
|
"networkDrift": "Netzwerk-Abweichung im Cluster"
|
||||||
},
|
},
|
||||||
"pollInterval": "Prüfintervall (Sekunden)",
|
"pollInterval": "Prüfintervall (Sekunden)",
|
||||||
@@ -468,6 +469,8 @@
|
|||||||
"FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998": "Für diesen Tunnel ist noch kein Server-Privatschlüssel hinterlegt",
|
"FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998": "Für diesen Tunnel ist noch kein Server-Privatschlüssel hinterlegt",
|
||||||
"FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR": "Für dieses Ziel liegt kein wiederholbarer Rollback vor",
|
"FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR": "Für dieses Ziel liegt kein wiederholbarer Rollback vor",
|
||||||
"GEPLANTE_WARTUNG_NICHT_GEFUNDEN": "Geplante Wartung nicht gefunden",
|
"GEPLANTE_WARTUNG_NICHT_GEFUNDEN": "Geplante Wartung nicht gefunden",
|
||||||
|
"GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH": "Geplante Wartung ist nur auf dem CARP-BACKUP möglich. Diese Firewall ist gerade MASTER – zuerst den CARP-Wartungsmodus aktivieren oder den Partner-Knoten einplanen",
|
||||||
|
"CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER": "Die automatische CARP-Umschaltung gibt es nur für Hosts in einem Cluster",
|
||||||
"GROUP_HAS_ASSIGNED_ITEMS": "Gruppe hat noch zugeordnete {label}. Diese zuerst verschieben oder löschen.",
|
"GROUP_HAS_ASSIGNED_ITEMS": "Gruppe hat noch zugeordnete {label}. Diese zuerst verschieben oder löschen.",
|
||||||
"GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN": "Gruppenmitglieder oder Regeln wurden geändert. Auswirkungen erneut prüfen.",
|
"GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN": "Gruppenmitglieder oder Regeln wurden geändert. Auswirkungen erneut prüfen.",
|
||||||
"GRUPPENNAME_BEREITS_VERGEBEN": "Gruppenname bereits vergeben",
|
"GRUPPENNAME_BEREITS_VERGEBEN": "Gruppenname bereits vergeben",
|
||||||
@@ -862,7 +865,8 @@
|
|||||||
"running": "läuft",
|
"running": "läuft",
|
||||||
"succeeded": "übergeben",
|
"succeeded": "übergeben",
|
||||||
"cancelled": "abgebrochen",
|
"cancelled": "abgebrochen",
|
||||||
"failed": "fehlgeschlagen"
|
"failed": "fehlgeschlagen",
|
||||||
|
"completed": "abgeschlossen"
|
||||||
},
|
},
|
||||||
"history": "Verlauf",
|
"history": "Verlauf",
|
||||||
"opStatus": {
|
"opStatus": {
|
||||||
@@ -903,11 +907,16 @@
|
|||||||
"scheduleTitle": "Wartung später ausführen",
|
"scheduleTitle": "Wartung später ausführen",
|
||||||
"actionLabel": "Aktion",
|
"actionLabel": "Aktion",
|
||||||
"timeLabel": "Zeitpunkt",
|
"timeLabel": "Zeitpunkt",
|
||||||
"unattendedWarning": "Läuft unbeaufsichtigt zum gewählten Zeitpunkt. Voraussetzungen (z. B. frisches Backup für ein Update) werden erst dann erneut geprüft — ist eine nicht erfüllt, wird die Aktion nicht ausgeführt und als fehlgeschlagen markiert, ohne erneuten Versuch.",
|
"unattendedWarning": "Läuft unbeaufsichtigt zum gewählten Zeitpunkt. Für Updates erstellt OPNexus unmittelbar davor selbst ein Konfigurationsbackup. Die übrigen Voraussetzungen werden erst dann erneut geprüft — ist eine nicht erfüllt, wird die Aktion nicht ausgeführt und als fehlgeschlagen markiert, ohne erneuten Versuch.",
|
||||||
"unattendedAck": "Mir ist bewusst, dass niemand anwesend sein wird, wenn die Aktion ausgeführt wird.",
|
"unattendedAck": "Mir ist bewusst, dass niemand anwesend sein wird, wenn die Aktion ausgeführt wird.",
|
||||||
"scheduling": "Wird geplant…",
|
"scheduling": "Wird geplant…",
|
||||||
"scheduleIt": "Wartung einplanen",
|
"scheduleIt": "Wartung einplanen",
|
||||||
"unattendedShort": "Läuft unbeaufsichtigt zum gewählten Zeitpunkt"
|
"unattendedShort": "Läuft unbeaufsichtigt zum gewählten Zeitpunkt",
|
||||||
|
"scheduleMasterBlocked": "Diese Firewall ist gerade CARP-MASTER. Geplante Wartung läuft nur auf dem BACKUP-Knoten, weil sonst der Verkehr ausfällt. Plane stattdessen den Partner-Knoten ein, oder aktiviere zuerst den CARP-Wartungsmodus an diesem Host.",
|
||||||
|
"handoverOption": "Vorher automatisch auf den Partner umschalten und danach zurückschalten (CARP)",
|
||||||
|
"handoverHint": "Ablauf: Wartungsmodus auf diesem Host, Partner wird MASTER (wird geprüft), dann läuft die Aktion, nach Rückkehr des Hosts wird der Wartungsmodus wieder verlassen. Bei Problemen bleibt der Partner MASTER und es kommt eine Benachrichtigung. Beim Zurückschalten kann der Host je nach CARP-Einstellung die MASTER-Rolle zurückholen (kurzer zweiter Wechsel).",
|
||||||
|
"handoverBadge": "mit CARP-Umschaltung",
|
||||||
|
"phase": {"handover_wait": "wartet auf Übernahme durch den Partner", "job_wait": "Auftrag läuft", "leave": "schaltet zurück", "leave_wait": "prüft den Normalbetrieb"}
|
||||||
},
|
},
|
||||||
"restore": {
|
"restore": {
|
||||||
"handoffWarning": "OPNexus führt die Wiederherstellung NICHT selbst aus. Nach der Übergabe musst du die Sicherung manuell im OPNsense-Webinterface importieren. Solange dieser Vorgang offen ist, sind Regel-/Alias-/NAT-Pushes und Host-Wartung für diesen Host gesperrt.",
|
"handoffWarning": "OPNexus führt die Wiederherstellung NICHT selbst aus. Nach der Übergabe musst du die Sicherung manuell im OPNsense-Webinterface importieren. Solange dieser Vorgang offen ist, sind Regel-/Alias-/NAT-Pushes und Host-Wartung für diesen Host gesperrt.",
|
||||||
@@ -1856,7 +1865,7 @@
|
|||||||
"managementNote": "OPNexus erreicht diese Firewall über {device}. Änderungen an dieser Schnittstelle können die Verbindung trennen und werden später besonders abgesichert.",
|
"managementNote": "OPNexus erreicht diese Firewall über {device}. Änderungen an dieser Schnittstelle können die Verbindung trennen und werden später besonders abgesichert.",
|
||||||
"managementUnknown": "Über welche Schnittstelle OPNexus diese Firewall erreicht, lässt sich nicht bestimmen (z. B. über NAT oder eine CARP-Adresse).",
|
"managementUnknown": "Über welche Schnittstelle OPNexus diese Firewall erreicht, lässt sich nicht bestimmen (z. B. über NAT oder eine CARP-Adresse).",
|
||||||
"unavailable": "Nicht abrufbar von dieser Firewall: {sections}.",
|
"unavailable": "Nicht abrufbar von dieser Firewall: {sections}.",
|
||||||
"cannotConfigure": "Diese OPNsense-Version erlaubt keine IP-Konfiguration von Schnittstellen per API (ab ca. 26.7.6).",
|
"cannotConfigure": "Diese Firewall ist zu alt, um Schnittstellen-IPs per API zu ändern (möglich ab OPNsense 26.7.6). Ändere die IP direkt in OPNsense oder aktualisiere die Firewall.",
|
||||||
"sections": {
|
"sections": {
|
||||||
"assignments": "Schnittstellen-Zuweisung",
|
"assignments": "Schnittstellen-Zuweisung",
|
||||||
"vlans": "VLANs",
|
"vlans": "VLANs",
|
||||||
@@ -2067,7 +2076,7 @@
|
|||||||
"blocks": {
|
"blocks": {
|
||||||
"api_unavailable": "Diese Firewall stellt dafür keine API bereit ({section}).",
|
"api_unavailable": "Diese Firewall stellt dafür keine API bereit ({section}).",
|
||||||
"nothing_to_change": "Es gibt nichts zu ändern.",
|
"nothing_to_change": "Es gibt nichts zu ändern.",
|
||||||
"interface_api_unavailable": "Diese OPNsense-Version kann Schnittstellen nicht per API konfigurieren (ab ca. 26.7.6).",
|
"interface_api_unavailable": "Diese Firewall ist zu alt, um Schnittstellen per API zu konfigurieren (möglich ab OPNsense 26.7.6). Bitte direkt in OPNsense ändern oder aktualisieren.",
|
||||||
"interface_changes_pending": "Auf der Firewall warten bereits vorgemerkte Schnittstellen-Änderungen auf das Anwenden. Zuerst dort prüfen und anwenden oder verwerfen.",
|
"interface_changes_pending": "Auf der Firewall warten bereits vorgemerkte Schnittstellen-Änderungen auf das Anwenden. Zuerst dort prüfen und anwenden oder verwerfen.",
|
||||||
"interface_locked": "Die Schnittstelle ist gesperrt. Zuerst die Sperre aufheben, dann löschen.",
|
"interface_locked": "Die Schnittstelle ist gesperrt. Zuerst die Sperre aufheben, dann löschen.",
|
||||||
"tunable_default_row": "Das ist eine Vorgabe der Firewall. Zum Ändern einen eigenen Eintrag mit „Überschreiben“ anlegen."
|
"tunable_default_row": "Das ist eine Vorgabe der Firewall. Zum Ändern einen eigenen Eintrag mit „Überschreiben“ anlegen."
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"carpHealth": "CARP state critical (split-brain / no master)",
|
"carpHealth": "CARP state critical (split-brain / no master)",
|
||||||
"natDrift": "NAT drift in the cluster",
|
"natDrift": "NAT drift in the cluster",
|
||||||
"carpAutoRevertFailed": "CARP auto-revert failed",
|
"carpAutoRevertFailed": "CARP auto-revert failed",
|
||||||
|
"scheduledFailed": "Scheduled maintenance did not run",
|
||||||
"networkDrift": "Network differences in the cluster"
|
"networkDrift": "Network differences in the cluster"
|
||||||
},
|
},
|
||||||
"pollInterval": "Check interval (seconds)",
|
"pollInterval": "Check interval (seconds)",
|
||||||
@@ -468,6 +469,8 @@
|
|||||||
"FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998": "No server private key has been stored for this tunnel",
|
"FUER_DIESEN_TUNNEL_IST_NOCH_KEIN_SERVER_PRIVATSCHLUESSEL_D7527998": "No server private key has been stored for this tunnel",
|
||||||
"FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR": "No repeatable rollback exists for this target",
|
"FUER_DIESES_ZIEL_LIEGT_KEIN_WIEDERHOLBARER_ROLLBACK_VOR": "No repeatable rollback exists for this target",
|
||||||
"GEPLANTE_WARTUNG_NICHT_GEFUNDEN": "Scheduled maintenance not found",
|
"GEPLANTE_WARTUNG_NICHT_GEFUNDEN": "Scheduled maintenance not found",
|
||||||
|
"GEPLANTE_WARTUNG_NUR_AUF_DEM_CARP_BACKUP_MOEGLICH": "Scheduled maintenance is only possible on the CARP BACKUP. This firewall is currently MASTER – enable CARP maintenance mode first or schedule the partner node",
|
||||||
|
"CARP_UEBERGABE_BRAUCHT_EINEN_CLUSTER": "Automatic CARP switchover is only available for hosts in a cluster",
|
||||||
"GROUP_HAS_ASSIGNED_ITEMS": "The group still has assigned {label}. Move or delete them first.",
|
"GROUP_HAS_ASSIGNED_ITEMS": "The group still has assigned {label}. Move or delete them first.",
|
||||||
"GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN": "Group members or rules changed. Review the impact again.",
|
"GRUPPENMITGLIEDER_ODER_REGELN_WURDEN_GEAENDERT_AUSWIRKUNGEN_ERNEUT_PRUEFEN": "Group members or rules changed. Review the impact again.",
|
||||||
"GRUPPENNAME_BEREITS_VERGEBEN": "Group name is already in use",
|
"GRUPPENNAME_BEREITS_VERGEBEN": "Group name is already in use",
|
||||||
@@ -862,7 +865,8 @@
|
|||||||
"running": "running",
|
"running": "running",
|
||||||
"succeeded": "submitted",
|
"succeeded": "submitted",
|
||||||
"cancelled": "cancelled",
|
"cancelled": "cancelled",
|
||||||
"failed": "failed"
|
"failed": "failed",
|
||||||
|
"completed": "completed"
|
||||||
},
|
},
|
||||||
"history": "History",
|
"history": "History",
|
||||||
"opStatus": {
|
"opStatus": {
|
||||||
@@ -903,11 +907,16 @@
|
|||||||
"scheduleTitle": "Run maintenance later",
|
"scheduleTitle": "Run maintenance later",
|
||||||
"actionLabel": "Action",
|
"actionLabel": "Action",
|
||||||
"timeLabel": "Time",
|
"timeLabel": "Time",
|
||||||
"unattendedWarning": "Runs unattended at the chosen time. Prerequisites (e.g. a fresh backup for an update) are only re-checked then — if one is not met, the action is not executed and is marked as failed, without a retry.",
|
"unattendedWarning": "Runs unattended at the chosen time. For updates, OPNexus takes a configuration backup itself right before. The other prerequisites are only re-checked then — if one is not met, the action is not executed and is marked as failed, without a retry.",
|
||||||
"unattendedAck": "I understand that nobody will be present when the action runs.",
|
"unattendedAck": "I understand that nobody will be present when the action runs.",
|
||||||
"scheduling": "Scheduling…",
|
"scheduling": "Scheduling…",
|
||||||
"scheduleIt": "Schedule maintenance",
|
"scheduleIt": "Schedule maintenance",
|
||||||
"unattendedShort": "Runs unattended at the chosen time"
|
"unattendedShort": "Runs unattended at the chosen time",
|
||||||
|
"scheduleMasterBlocked": "This firewall is currently the CARP MASTER. Scheduled maintenance only runs on the BACKUP node, otherwise traffic would drop. Schedule the partner node instead, or enable CARP maintenance mode on this host first.",
|
||||||
|
"handoverOption": "Switch to the partner first and switch back afterwards (CARP)",
|
||||||
|
"handoverHint": "Flow: maintenance mode on this host, the partner becomes MASTER (verified), then the action runs, and once the host is back the maintenance mode is left again. If anything goes wrong the partner stays MASTER and a notification is sent. When switching back, the host may take the MASTER role back depending on the CARP settings (a short second switchover).",
|
||||||
|
"handoverBadge": "with CARP switchover",
|
||||||
|
"phase": {"handover_wait": "waiting for the partner to take over", "job_wait": "job running", "leave": "switching back", "leave_wait": "verifying normal operation"}
|
||||||
},
|
},
|
||||||
"restore": {
|
"restore": {
|
||||||
"handoffWarning": "OPNexus does NOT perform the restore itself. After the handover you must import the backup manually in the OPNsense web interface. While this operation is open, rule/alias/NAT pushes and host maintenance are blocked for this host.",
|
"handoffWarning": "OPNexus does NOT perform the restore itself. After the handover you must import the backup manually in the OPNsense web interface. While this operation is open, rule/alias/NAT pushes and host maintenance are blocked for this host.",
|
||||||
@@ -1856,7 +1865,7 @@
|
|||||||
"managementNote": "OPNexus reaches this firewall through {device}. Changes to this interface can cut the connection and will get extra safeguards later.",
|
"managementNote": "OPNexus reaches this firewall through {device}. Changes to this interface can cut the connection and will get extra safeguards later.",
|
||||||
"managementUnknown": "Which interface OPNexus uses to reach this firewall cannot be determined (e.g. via NAT or a CARP address).",
|
"managementUnknown": "Which interface OPNexus uses to reach this firewall cannot be determined (e.g. via NAT or a CARP address).",
|
||||||
"unavailable": "Not available from this firewall: {sections}.",
|
"unavailable": "Not available from this firewall: {sections}.",
|
||||||
"cannotConfigure": "This OPNsense version does not allow configuring interface IPs through its API (from about 26.7.6).",
|
"cannotConfigure": "This firewall is too old to change interface IPs through its API (possible from OPNsense 26.7.6). Change the IP directly in OPNsense or update the firewall.",
|
||||||
"sections": {
|
"sections": {
|
||||||
"assignments": "interface assignments",
|
"assignments": "interface assignments",
|
||||||
"vlans": "VLANs",
|
"vlans": "VLANs",
|
||||||
@@ -2067,7 +2076,7 @@
|
|||||||
"blocks": {
|
"blocks": {
|
||||||
"api_unavailable": "This firewall does not offer an API for this ({section}).",
|
"api_unavailable": "This firewall does not offer an API for this ({section}).",
|
||||||
"nothing_to_change": "There is nothing to change.",
|
"nothing_to_change": "There is nothing to change.",
|
||||||
"interface_api_unavailable": "This OPNsense version cannot configure interfaces through its API (from about 26.7.6).",
|
"interface_api_unavailable": "This firewall is too old to configure interfaces through its API (possible from OPNsense 26.7.6). Please change it directly in OPNsense or update the firewall.",
|
||||||
"interface_changes_pending": "Interface changes are already staged on the firewall and waiting to be applied. Check and apply or discard them there first.",
|
"interface_changes_pending": "Interface changes are already staged on the firewall and waiting to be applied. Check and apply or discard them there first.",
|
||||||
"interface_locked": "The interface is locked. Remove the lock first, then delete.",
|
"interface_locked": "The interface is locked. Remove the lock first, then delete.",
|
||||||
"tunable_default_row": "This is a built-in default of the firewall. To change it, create your own entry with “Override”."
|
"tunable_default_row": "This is a built-in default of the firewall. To change it, create your own entry with “Override”."
|
||||||
|
|||||||
@@ -120,6 +120,25 @@ export const serverMessagesEn: Array<[string | RegExp, string]> = [
|
|||||||
["Unerwarteter Fehler beim Ausführen", "Unexpected error during execution"],
|
["Unerwarteter Fehler beim Ausführen", "Unexpected error during execution"],
|
||||||
["Host nicht gefunden oder deaktiviert", "Host not found or disabled"],
|
["Host nicht gefunden oder deaktiviert", "Host not found or disabled"],
|
||||||
[/^Host nicht erreichbar: (?<detail>[\s\S]*)$/, "Host unreachable: {detail}"],
|
[/^Host nicht erreichbar: (?<detail>[\s\S]*)$/, "Host unreachable: {detail}"],
|
||||||
|
[/^Automatisches Backup vor dem Update fehlgeschlagen: (?<detail>[\s\S]*)$/, "Automatic backup before the update failed: {detail}"],
|
||||||
|
[/^Vorbereitung der CARP-Übergabe fehlgeschlagen, nichts wurde verändert: (?<detail>[\s\S]*)$/, "Preparing the CARP handover failed, nothing was changed: {detail}"],
|
||||||
|
[/^(?<name>.+) steht bereits im CARP-Wartungsmodus\. Rollen prüfen\.$/, "{name} is already in CARP maintenance mode. Check the roles."],
|
||||||
|
[/^CARP-Umschalten mit unklarem Ergebnis: (?<detail>[\s\S]*)\. Nicht automatisch wiederholt — Rollen von (?<name>.+) und Partner prüfen\.$/, "CARP switch with an unclear result: {detail}. Not retried automatically — check the roles of {name} and its partner."],
|
||||||
|
[/^Auftrag „(?<action>[^“]+)“ mit unklarem Ergebnis: (?<detail>[\s\S]*)\. Nicht wiederholt\. (?<name>.+) bleibt im CARP-Wartungsmodus, der Partner trägt den Verkehr\.$/, "Job “{action}” with an unclear result: {detail}. Not retried. {name} stays in CARP maintenance mode, the partner carries the traffic."],
|
||||||
|
[/^CARP-Übergabe nicht bestätigt, und der Normalbetrieb konnte nicht wiederhergestellt werden \((?<detail>[\s\S]*)\)\. (?<name>.+) prüfen\.$/, "CARP handover not confirmed, and normal operation could not be restored ({detail}). Check {name}."],
|
||||||
|
["CARP-Übergabe nicht bestätigt (der Partner wurde nicht MASTER). Normalbetrieb wiederhergestellt, nichts aktualisiert.", "CARP handover not confirmed (the partner did not become MASTER). Normal operation restored, nothing was updated."],
|
||||||
|
["CARP-Übergabe hat nicht stattgefunden (Wartungsmodus nicht aktiv). Nichts aktualisiert.", "The CARP handover did not happen (maintenance mode not active). Nothing was updated."],
|
||||||
|
[/^CARP-Übergabe nicht bestätigt und (?<name>.+) nicht erreichbar\. Rollen prüfen\.$/, "CARP handover not confirmed and {name} is unreachable. Check the roles."],
|
||||||
|
[/^Auftrag „(?<action>[^“]+)“ auf (?<name>.+) nicht rechtzeitig abgeschlossen\. .+ bleibt im CARP-Wartungsmodus, der Partner trägt den Verkehr\. Bitte prüfen und von Hand zurückschalten\.$/, "Job “{action}” on {name} did not finish in time. {name} stays in CARP maintenance mode, the partner carries the traffic. Please check and switch back by hand."],
|
||||||
|
[/^(?<name>.+) ist nach dem Auftrag nicht wieder erreichbar\. Der Partner trägt den Verkehr\.$/, "{name} is not reachable again after the job. The partner carries the traffic."],
|
||||||
|
[/^Zurückschalten mit unklarem Ergebnis: (?<detail>[\s\S]*)\. Nicht automatisch wiederholt — (?<name>.+) prüfen, ggf\. „CARP-Normalbetrieb herstellen“\.$/, "Switching back with an unclear result: {detail}. Not retried automatically — check {name}, if needed use “Restore CARP normal operation”."],
|
||||||
|
[/^Normalbetrieb nach dem Zurückschalten nicht bestätigt\. Rollen von (?<name>.+) und Partner prüfen\.$/, "Normal operation not confirmed after switching back. Check the roles of {name} and its partner."],
|
||||||
|
[/^Unerwarteter Fehler während der CARP-Übergabe \((?<detail>[^)]*)\)\. (?<name>.+) kann im CARP-Wartungsmodus verblieben sein — Rollen prüfen\.$/, "Unexpected error during the CARP handover ({detail}). {name} may have stayed in CARP maintenance mode — check the roles."],
|
||||||
|
[/^Vorgang von Hand beendet, Ablauf gestoppt\. CARP-Rolle von (?<name>.+) prüfen\.$/, "Operation closed by hand, run stopped. Check the CARP role of {name}."],
|
||||||
|
["Automatisch abgeschlossen: Übergabe, Auftrag und Zurückschalten bestätigt.", "Completed automatically: handover, job and switch-back confirmed."],
|
||||||
|
["Automatisch abgeschlossen: Übergabe, Auftrag und Zurückschalten bestätigt. Ein Neustart ist noch erforderlich.", "Completed automatically: handover, job and switch-back confirmed. A reboot is still required."],
|
||||||
|
["CARP-Übergabe angefordert, warte auf Bestätigung.", "CARP handover requested, waiting for confirmation."],
|
||||||
|
["Übergabe bestätigt, Auftrag gestartet.", "Handover confirmed, job started."],
|
||||||
["Manuell vor Ablauf des Zeitfensters in den Normalbetrieb zurückgeschaltet.", "Manually switched back to normal operation before the window expired."],
|
["Manuell vor Ablauf des Zeitfensters in den Normalbetrieb zurückgeschaltet.", "Manually switched back to normal operation before the window expired."],
|
||||||
["Host wurde gelöscht, bevor das Zeitfenster ablief.", "The host was deleted before the window expired."],
|
["Host wurde gelöscht, bevor das Zeitfenster ablief.", "The host was deleted before the window expired."],
|
||||||
["Host war beim Ablauf bereits nicht mehr im CARP-Wartungsmodus.", "The host was no longer in CARP maintenance mode when the window expired."],
|
["Host war beim Ablauf bereits nicht mehr im CARP-Wartungsmodus.", "The host was no longer in CARP maintenance mode when the window expired."],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Manually bumped whenever a notable change ships — mirrors the dated
|
// Manually bumped whenever a notable change ships — mirrors the dated
|
||||||
// entries already kept in the project README/CHANGELOG.md, no build
|
// entries already kept in the project README/CHANGELOG.md, no build
|
||||||
// tooling involved. Versioning scheme: see CHANGELOG.md ("Versionierung").
|
// tooling involved. Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||||
export const APP_VERSION = "1.16.0";
|
export const APP_VERSION = "1.17.0";
|
||||||
export const APP_CODENAME = "Hobby";
|
export const APP_CODENAME = "Imperial";
|
||||||
export const APP_MAINTAINER = "Julian Boldt";
|
export const APP_MAINTAINER = "Julian Boldt";
|
||||||