🚧 WIP
This commit is contained in:
@@ -20,7 +20,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit
|
||||
from app.config import get_settings
|
||||
from app.configgen import generate_config
|
||||
from app.configgen import RESTART_REQUIRED_KEY, generate_config
|
||||
from app.db import get_db
|
||||
from app.deps import CurrentUser, require_roles
|
||||
from app.models import (
|
||||
@@ -65,6 +65,51 @@ def _next_revision_number(db: Session) -> int:
|
||||
return (highest or 0) + 1
|
||||
|
||||
|
||||
# BrokerSettings key holding the pending-restart flag (value "true"/"false").
|
||||
# Defined in configgen (RESERVED_SETTINGS_KEYS keeps it out of the
|
||||
# generated config); the bootstrap config the broker starts with must
|
||||
# match the generator's explicit listener form for the comparison.
|
||||
_BOOTSTRAP_LISTENERS = {"listener 1883 0.0.0.0"}
|
||||
|
||||
|
||||
def _listener_signature(config_text: str) -> set[str]:
|
||||
"""The listener DEFINITION lines of a generated config.
|
||||
|
||||
Mosquitto's reload cannot add, remove or redefine listeners (spec
|
||||
section 28): textual changes to `listener <port> <bind>` lines are
|
||||
treated as different listeners and only take effect on restart.
|
||||
Everything else (cert paths, TLS versions, max_connections, ...) is
|
||||
reload-safe.
|
||||
"""
|
||||
import re
|
||||
|
||||
return set(re.findall(r"^listener \d+.*$", config_text, re.MULTILINE))
|
||||
|
||||
|
||||
def _set_restart_required(db: Session, required: bool) -> None:
|
||||
setting = db.scalar(
|
||||
select(BrokerSettings).where(BrokerSettings.key == RESTART_REQUIRED_KEY)
|
||||
)
|
||||
if setting is None:
|
||||
db.add(
|
||||
BrokerSettings(
|
||||
key=RESTART_REQUIRED_KEY,
|
||||
value="true" if required else "false",
|
||||
value_type="bool",
|
||||
)
|
||||
)
|
||||
else:
|
||||
setting.value = "true" if required else "false"
|
||||
|
||||
|
||||
def restart_required(db: Session) -> bool:
|
||||
"""Whether the deployed config has listener changes pending a restart."""
|
||||
setting = db.scalar(
|
||||
select(BrokerSettings.value).where(BrokerSettings.key == RESTART_REQUIRED_KEY)
|
||||
)
|
||||
return setting == "true"
|
||||
|
||||
|
||||
def _call_control(
|
||||
path: str, *, method: str = "POST", json_body: dict | None = None
|
||||
) -> dict:
|
||||
@@ -306,6 +351,23 @@ def deploy_config(
|
||||
if response.get("status") != "deployed":
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, response)
|
||||
|
||||
# Spec section 28: a reload cannot apply listener definition changes.
|
||||
# Compare the listener lines against the previously DEPLOYED revision
|
||||
# (or the bootstrap config when none exists yet) and flag a pending
|
||||
# restart so the UI can surface it (glowing indicator -> System page).
|
||||
previous = db.scalar(
|
||||
select(ConfigRevision)
|
||||
.where(ConfigRevision.deployed.is_(True))
|
||||
.order_by(ConfigRevision.revision_number.desc())
|
||||
)
|
||||
previous_listeners = (
|
||||
_listener_signature(previous.config_text)
|
||||
if previous is not None
|
||||
else set(_BOOTSTRAP_LISTENERS)
|
||||
)
|
||||
needs_restart = _listener_signature(result.config_text) != previous_listeners
|
||||
_set_restart_required(db, needs_restart)
|
||||
|
||||
revision = ConfigRevision(
|
||||
revision_number=_next_revision_number(db),
|
||||
created_by=user.username,
|
||||
@@ -329,6 +391,7 @@ def deploy_config(
|
||||
"status": "deployed",
|
||||
"revision": revision.revision_number,
|
||||
"broker_healthy": response.get("broker_healthy"),
|
||||
"restart_required": needs_restart,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""System administration (spec sections 29, 41, 46).
|
||||
|
||||
Broker status and restart through the control sidecar. The restart
|
||||
primitive is SIGTERM to PID 1 in the broker's (shared) PID namespace —
|
||||
the broker shuts down gracefully (persistence saved) and Docker's
|
||||
`restart: unless-stopped` policy brings the container back. No Docker
|
||||
socket is involved (spec section 29).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit
|
||||
from app.db import get_db
|
||||
from app.deps import CurrentUser, require_roles
|
||||
from app.models import ManagementRole
|
||||
from app.routers.broker import _call_control, restart_required
|
||||
|
||||
router = APIRouter(prefix="/api/v1/system", tags=["system"])
|
||||
|
||||
BrokerAdmin = Annotated[CurrentUser, Depends(require_roles(ManagementRole.BROKER_ADMIN))]
|
||||
AnyReader = Annotated[
|
||||
CurrentUser,
|
||||
Depends(
|
||||
require_roles(
|
||||
ManagementRole.BROKER_ADMIN,
|
||||
ManagementRole.OPERATOR,
|
||||
ManagementRole.AUDITOR,
|
||||
)
|
||||
),
|
||||
]
|
||||
DbDep = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def system_status(db: DbDep, _user: AnyReader):
|
||||
"""Broker process state + pending-restart flag (spec section 46).
|
||||
|
||||
Used by the sidebar indicator (polls lightly) and the System page.
|
||||
The control call is allowed to fail — an unreachable sidecar is a
|
||||
status, not an error.
|
||||
"""
|
||||
try:
|
||||
control = _call_control("/status", method="GET")
|
||||
broker_alive = bool(control.get("broker_pid_alive"))
|
||||
except Exception: # noqa: BLE001 - status endpoint must not 500
|
||||
broker_alive = False
|
||||
return {
|
||||
"broker_alive": broker_alive,
|
||||
"restart_required": restart_required(db),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/broker/restart")
|
||||
def restart_broker(request: Request, db: DbDep, user: BrokerAdmin):
|
||||
"""Restart the broker via the control sidecar (spec section 29).
|
||||
|
||||
The sidecar acknowledges and then SIGTERMs PID 1; Docker restarts
|
||||
the container. The pending-restart flag is cleared here — the config
|
||||
on disk is what the broker will boot with, so listener changes take
|
||||
effect by definition once the restart completes.
|
||||
"""
|
||||
from app.routers.broker import _set_restart_required
|
||||
|
||||
response = _call_control("/restart")
|
||||
_set_restart_required(db, False)
|
||||
audit.record(
|
||||
db,
|
||||
"BROKER_RESTARTED",
|
||||
request=request,
|
||||
user=user.username,
|
||||
object_type="broker",
|
||||
object_id="mosquitto",
|
||||
result="initiated",
|
||||
)
|
||||
return {"status": "restarting", "broker_alive": False}
|
||||
Reference in New Issue
Block a user