This commit is contained in:
Jonas Bengtsson
2026-09-19 18:08:02 +02:00
parent a16a01b1e7
commit 680ad7a624
14 changed files with 483 additions and 14 deletions
+11
View File
@@ -17,6 +17,13 @@ from app.models import BrokerSettings, Certificate, ListenerConfig
_BOOL = {True: "true", False: "false"}
# BrokerSettings keys that are INTERNAL platform state, not broker
# options — they must never be emitted into the generated mosquitto.conf
# (a leaked key makes `mosquitto --test-config` fail with "Unknown
# configuration variable").
RESTART_REQUIRED_KEY = "restart_required"
RESERVED_SETTINGS_KEYS = frozenset({RESTART_REQUIRED_KEY})
@dataclass
class GenerationResult:
@@ -31,6 +38,10 @@ def _global_section(db: Session, warnings: list[str]) -> list[str]:
value = setting.value.strip()
if not key or key.startswith("#"):
continue
if key in RESERVED_SETTINGS_KEYS:
# Internal platform state, not a broker option — emitting it
# would fail validation (see RESERVED_SETTINGS_KEYS above).
continue
lines.append(f"{key} {value}")
return lines
+3 -2
View File
@@ -125,10 +125,11 @@ def health_ready():
# Routers
from app.routers import auth, broker, mqtt, pki, users # noqa: E402
from app.routers import auth, broker, mqtt, pki, system, users # noqa: E402
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(mqtt.router)
app.include_router(broker.router)
app.include_router(pki.router)
app.include_router(pki.router)
app.include_router(system.router)
+64 -1
View File
@@ -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,
}
+80
View File
@@ -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}
+21
View File
@@ -224,6 +224,27 @@ def client(db_session, monkeypatch):
# The PKI client-cert endpoint validates the dynsec client exists.
monkeypatch.setattr(pki_router, "dynsec_client", fake)
# The control sidecar is not running in tests: fake its responses so
# deploy/system endpoints can be exercised (broker_alive always true).
from app.routers import broker as broker_router
from app.routers import system as system_router
def _fake_call_control(path, *, method="POST", json_body=None):
if path == "/deploy":
return {
"status": "deployed",
"broker_healthy": True,
"validation": {"valid": True},
}
if path == "/restart":
return {"status": "restarting"}
if path == "/status":
return {"broker_pid_alive": True, "config_file": "/mosquitto/config/mosquitto.conf"}
raise AssertionError(f"unexpected control path in tests: {path}")
monkeypatch.setattr(broker_router, "_call_control", _fake_call_control)
monkeypatch.setattr(system_router, "_call_control", _fake_call_control)
with TestClient(main_module.app) as test_client:
yield test_client
+64
View File
@@ -130,6 +130,70 @@ def test_client_id_binding(admin_client):
assert refused.status_code == 400
# --- System: broker restart + restart-required flag (spec 28, 29) -------------
def test_deploy_sets_restart_required_on_listener_change(admin_client):
"""Adding a listener definition vs the bootstrap config must flag a
pending restart (reload cannot add listeners, spec section 28)."""
# No deployed revision yet -> compared against the bootstrap config
# (listener 1883 0.0.0.0). A listener on 8883 differs from that.
admin_client.post(
"/api/v1/broker/listeners",
json={"name": "New TLS", "port": 8883, "protocol": "mqtt"},
)
deploy = admin_client.post(
"/api/v1/broker/config/deploy", json={"comment": "listener added"}
)
assert deploy.status_code == 200
body = deploy.json()
assert body["restart_required"] is True
# The status endpoint reflects the same flag (drives the glow).
status = admin_client.get("/api/v1/system/status").json()
assert status["restart_required"] is True
assert status["broker_alive"] is True
def test_deploy_without_listener_change_needs_no_restart(admin_client):
"""Deploying a config whose listener lines match the previous deployed
revision must NOT set the flag (everything else is reload-safe)."""
admin_client.post(
"/api/v1/broker/listeners",
json={"name": "Baseline", "port": 1883, "protocol": "mqtt"},
)
first = admin_client.post(
"/api/v1/broker/config/deploy", json={"comment": "baseline"}
).json()
# 1883 matches the bootstrap signature on the FIRST deploy; the flag
# may or may not be set then. The SECOND deploy (identical listener
# lines) must clear it.
second = admin_client.post(
"/api/v1/broker/config/deploy", json={"comment": "no change"}
).json()
assert second["restart_required"] is False
def test_restart_clears_the_flag(admin_client):
admin_client.post(
"/api/v1/broker/config/deploy", json={"comment": "flag set"}
)
restarted = admin_client.post("/api/v1/system/broker/restart")
assert restarted.status_code == 200
assert restarted.json()["status"] == "restarting"
status = admin_client.get("/api/v1/system/status").json()
assert status["restart_required"] is False
def test_system_endpoints_rbac(auditor_client):
"""Status is read-only (auditor may read); restart is privileged."""
assert auditor_client.get("/api/v1/system/status").status_code == 200
assert (
auditor_client.post("/api/v1/system/broker/restart").status_code == 403
)
def test_role_acl_lifecycle(admin_client):
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})
+14
View File
@@ -84,6 +84,20 @@ def test_published_port_listener_does_not_warn(session):
assert not any("not published to the host" in w for w in result.warnings)
def test_internal_settings_never_leak_into_config(session):
"""Platform-internal BrokerSettings keys (restart_required) are state,
not broker options: emitting them fails `--test-config` with 'Unknown
configuration variable' (found live in the restart E2E)."""
session.add(ListenerConfig(name="l", port=1883, protocol="mqtt"))
session.add(BrokerSettings(key="restart_required", value="true", value_type="bool"))
session.add(BrokerSettings(key="max_packet_size", value="2000000", value_type="int"))
session.commit()
result = generate_config(session)
assert "max_packet_size 2000000" in result.config_text
assert "restart_required" not in result.config_text
def test_tls_listener_without_pki_warns(session):
session.add(
ListenerConfig(