Harden production readiness

This commit is contained in:
sh4un committed 2026-08-23 18:53:02 -04:00
1 parent f4adf90eb0
commit 933aac3f9f
23 files changed
+373 -39

No files matched your search

+11 -5
View File
@@ -30,7 +30,6 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Lint with flake8
@@ -40,11 +39,18 @@ jobs:
- name: Type check with mypy
run: |
mypy ammb/version.py ammb/api.py ammb/api_async.py \
ammb/bridge_async.py ammb/message_logger.py \
ammb/metrics.py ammb/rate_limiter.py \
run_bridge.py run_bridge_async.py
mypy ammb run_bridge.py run_bridge_async.py run_bridge_tui.py
- name: Test with pytest
run: |
pytest --cov=ammb --cov-report=term-missing --cov-report=xml
- name: Audit runtime dependencies
if: matrix.python-version == '3.12'
run: pip-audit -r requirements.txt
- name: Build and validate release artifacts
if: matrix.python-version == '3.12'
run: |
python -m build
twine check dist/*
+3
View File
@@ -68,6 +68,9 @@ sudo systemctl status ammb.service
```
The unit restarts on failure. Watch logs with `journalctl -u ammb.service -f`.
The production entry points handle systemd's `SIGTERM` and flush message logs
before exiting. Fatal preflight findings prevent startup and are reported in the
journal; run the explicit `--check` command for the full diagnostic report.
## Operational Notes
- Only one process can access a serial port at a time.
+5
View File
@@ -0,0 +1,5 @@
include CHANGELOG.md
include DEPLOYMENT.md
recursive-include docs *.md
recursive-include examples *.ini *.example
recursive-include packaging *.service
+22 -1
View File
@@ -15,6 +15,8 @@ from .health import get_health_monitor
from .metrics import get_metrics
from .version import __version__
MAX_CONTROL_BODY_BYTES = 64 * 1024
def extract_request_token(
authorization: Optional[str],
@@ -154,14 +156,27 @@ class BridgeAPIHandler(BaseHTTPRequestHandler):
def _handle_control(self):
"""Handle control requests."""
content_length = int(self.headers.get("Content-Length", 0))
try:
content_length = int(self.headers.get("Content-Length", 0))
except (TypeError, ValueError):
self._send_response(400, {"error": "Invalid Content-Length"})
return
if content_length == 0:
self._send_response(400, {"error": "No request body"})
return
if content_length < 0:
self._send_response(400, {"error": "Invalid Content-Length"})
return
if content_length > MAX_CONTROL_BODY_BYTES:
self._send_response(413, {"error": "Request body too large"})
return
body = self.rfile.read(content_length)
try:
data = json.loads(body.decode("utf-8"))
if not isinstance(data, dict):
self._send_response(400, {"error": "JSON object required"})
return
action = data.get("action")
if action == "reset_metrics":
@@ -181,6 +196,8 @@ class BridgeAPIHandler(BaseHTTPRequestHandler):
self.send_response(status_code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(response)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
if status_code == 401:
self.send_header("WWW-Authenticate", "Bearer")
self.end_headers()
@@ -234,6 +251,10 @@ class BridgeAPIServer:
self.logger.error(
"Failed to start API server: %s", e, exc_info=True
)
if self.server is not None:
self.server.server_close()
self.server = None
raise RuntimeError("Failed to start the configured API server") from e
def stop(self):
"""Stop the API server."""
+37 -2
View File
@@ -2,12 +2,17 @@
"""
Async REST API for monitoring and controlling the bridge using FastAPI.
"""
import json
from typing import Optional
from fastapi import FastAPI, Header, Request
from fastapi.responses import JSONResponse
from ammb.api import extract_request_token, token_matches
from ammb.api import (
MAX_CONTROL_BODY_BYTES,
extract_request_token,
token_matches,
)
from ammb.health import get_health_monitor
from ammb.metrics import get_metrics
from ammb.version import __version__
@@ -122,7 +127,37 @@ async def control(
):
if not _authorized(authorization, x_api_token):
return _unauthorized()
data = await request.json()
content_length = request.headers.get("content-length")
if content_length:
try:
if int(content_length) > MAX_CONTROL_BODY_BYTES:
return JSONResponse(
content={"error": "Request body too large"},
status_code=413,
)
except ValueError:
return JSONResponse(
content={"error": "Invalid Content-Length"},
status_code=400,
)
body = await request.body()
if len(body) > MAX_CONTROL_BODY_BYTES:
return JSONResponse(
content={"error": "Request body too large"},
status_code=413,
)
try:
data = json.loads(body)
except (json.JSONDecodeError, UnicodeDecodeError):
return JSONResponse(
content={"error": "Invalid JSON"},
status_code=400,
)
if not isinstance(data, dict):
return JSONResponse(
content={"error": "JSON object required"},
status_code=400,
)
action = data.get("action")
if action == "reset_metrics":
get_metrics().reset()
+29 -5
View File
@@ -22,7 +22,15 @@ class AsyncBridge:
def __init__(self, config: BridgeConfig):
self.logger = logging.getLogger(__name__)
self.config = config
self.bridge = Bridge(config)
# The async wrapper owns the FastAPI server. Disable the synchronous
# HTTP server in the wrapped Bridge so both implementations never try
# to bind the same configured port.
bridge_config = (
config._replace(api_enabled=False)
if config.api_enabled
else config
)
self.bridge = Bridge(bridge_config)
self._running = False
self._bridge_thread: Optional[threading.Thread] = None
@@ -70,6 +78,13 @@ class AsyncBridge:
try:
while self._bridge_thread.is_alive() and self._running:
if api_task is not None and api_task.done():
failure = api_task.exception()
if failure is not None:
raise RuntimeError(
"Async API server stopped unexpectedly"
) from failure
raise RuntimeError("Async API server stopped unexpectedly")
await asyncio.sleep(0.25)
except asyncio.CancelledError:
self.logger.info("AsyncBridge received cancellation signal.")
@@ -78,17 +93,26 @@ class AsyncBridge:
self.logger.critical(
"Unhandled exception in AsyncBridge: %s", e, exc_info=True
)
raise
finally:
if server is not None:
server.should_exit = True
if api_task is not None:
api_task.cancel()
try:
await api_task
except (asyncio.CancelledError, Exception):
pass
await asyncio.wait_for(api_task, timeout=5)
except asyncio.TimeoutError:
api_task.cancel()
try:
await api_task
except asyncio.CancelledError:
pass
await self.shutdown()
def request_shutdown(self) -> None:
"""Request a graceful stop from a synchronous signal handler."""
self._running = False
self.bridge.shutdown_event.set()
async def shutdown(self):
self.logger.info("Shutting down AsyncBridge...")
self._running = False
+12 -10
View File
@@ -198,8 +198,11 @@ def load_config(config_path: str = CONFIG_FILE) -> Optional[BridgeConfig]:
cfg_section = config["DEFAULT"]
# Only set meshtastic_port if explicitly present and not commented out
meshtastic_port: Optional[str]
if "MESHTASTIC_SERIAL_PORT" in cfg_section:
meshtastic_port = cfg_section.get("MESHTASTIC_SERIAL_PORT", "").strip()
meshtastic_port = cfg_section.get(
"MESHTASTIC_SERIAL_PORT", ""
).strip()
if not meshtastic_port or meshtastic_port.startswith("#"):
meshtastic_port = None
else:
@@ -270,17 +273,12 @@ def load_config(config_path: str = CONFIG_FILE) -> Optional[BridgeConfig]:
)
return None
if serial_protocol not in VALID_SERIAL_PROTOCOLS:
logger.warning(
"Unrecognized SERIAL_PROTOCOL '%s'."
" Valid options: %s.",
logger.error(
"Invalid SERIAL_PROTOCOL '%s'. Valid options: %s.",
serial_protocol,
VALID_SERIAL_PROTOCOLS,
)
logger.warning(
"Attempting to use '%s' - ensure a corresponding "
"handler exists.",
serial_protocol,
)
return None
try:
serial_baud = cfg_section.getint("SERIAL_BAUD_RATE")
if serial_baud is None or serial_baud <= 0:
@@ -467,7 +465,11 @@ def load_config(config_path: str = CONFIG_FILE) -> Optional[BridgeConfig]:
meshtastic_retry_on_boot=meshtastic_retry_on_boot,
meshtastic_retry_delay_s=meshtastic_retry_delay_s,
)
logger.debug("Configuration loaded: %s", bridge_config)
# Never log the NamedTuple itself: it contains MQTT and API secrets.
logger.debug(
"Configuration loaded for external transport: %s",
bridge_config.external_transport,
)
return bridge_config
except Exception as e:
+6 -3
View File
@@ -26,6 +26,9 @@ class MeshcoreHandler:
RECONNECT_DELAY_S = 10
AUTO_DETECT_FAILURE_THRESHOLD = 5
receiver_thread: Optional[threading.Thread]
sender_thread: Optional[threading.Thread]
_contacts_poll_thread: Optional[threading.Thread]
def start_threads(self):
"""Start the receiver and sender threads for MeshcoreHandler."""
@@ -80,9 +83,9 @@ class MeshcoreHandler:
self.shutdown_event = shutdown_event
self.serial_port: Optional[serial.Serial] = None
self.receiver_thread: Optional[threading.Thread] = None
self.sender_thread: Optional[threading.Thread] = None
self._contacts_poll_thread: Optional[threading.Thread] = None
self.receiver_thread = None
self.sender_thread = None
self._contacts_poll_thread = None
self._lock = threading.Lock()
self._is_connected = threading.Event()
+3
View File
@@ -5,6 +5,7 @@ Message persistence and logging for the bridge.
import json
import logging
import os
import threading
from datetime import datetime
from pathlib import Path
@@ -76,6 +77,8 @@ class MessageLogger:
assert self.log_file is not None
self._log_path = Path(self.log_file)
self._log_path.parent.mkdir(parents=True, exist_ok=True)
self._log_path.touch(mode=0o600, exist_ok=True)
os.chmod(self._log_path, 0o600)
self._start_worker()
@property
+9
View File
@@ -375,6 +375,15 @@ def _mqtt_diagnostics(config: BridgeConfig) -> list[Diagnostic]:
)
if config.mqtt_tls_enabled:
if config.mqtt_tls_insecure:
diagnostics.append(
Diagnostic(
"warning",
"MQTT certificate verification disabled",
"MQTT_TLS_INSECURE disables broker identity checks.",
"Set MQTT_TLS_INSECURE = False for production.",
)
)
if config.mqtt_port == 1883:
diagnostics.append(
Diagnostic(
+5 -3
View File
@@ -16,7 +16,7 @@ import binascii
import json
import logging
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Type
# --- Base Class ---
@@ -325,7 +325,7 @@ class MeshcoreCompanionProtocol(MeshcoreProtocolHandler):
return None
fw_ver = raw_data[1]
device_info = {"fw_ver": fw_ver}
device_info: Dict[str, Any] = {"fw_ver": fw_ver}
if fw_ver >= 3:
minimum_length = 1 + 1 + 1 + 1 + 4 + 12 + 40 + 20
if len(raw_data) < minimum_length:
@@ -595,7 +595,9 @@ class MeshcoreCompanionProtocol(MeshcoreProtocolHandler):
return None
_serial_protocol_handlers = {
_serial_protocol_handlers: Dict[
str, Type[MeshcoreProtocolHandler]
] = {
"json_newline": JsonNewlineProtocol,
"raw_serial": RawSerialProtocol,
"companion_radio": MeshcoreCompanionProtocol,
+10 -2
View File
@@ -45,11 +45,14 @@ VISIBLE_LOG_LINES = 200
class BridgeRuntime(Protocol):
"""Runtime surface required by the dashboard controller."""
external_handler: object | None
shutdown_event: threading.Event
to_meshtastic_queue: Queue
to_external_queue: Queue
@property
def external_handler(self) -> object | None:
"""Configured external transport handler, when initialized."""
def run(self) -> None:
"""Run the bridge loop."""
@@ -60,6 +63,11 @@ class BridgeRuntime(Protocol):
BridgeFactory = Callable[[BridgeConfig], BridgeRuntime]
def _default_bridge_factory(config: BridgeConfig) -> BridgeRuntime:
"""Construct the production bridge through the controller protocol."""
return Bridge(config)
@dataclass(frozen=True)
class LogEntry:
"""Single log line rendered into the live tail."""
@@ -165,7 +173,7 @@ class BridgeController:
self,
config: BridgeConfig,
*,
bridge_factory: BridgeFactory = Bridge,
bridge_factory: BridgeFactory = _default_bridge_factory,
event_sink: Optional[Callable[[str, str], None]] = None,
):
self.config = config
+3 -1
View File
@@ -41,7 +41,9 @@ SERIAL_BAUD_RATE = 9600
# raw_serial: Reads raw binary/text bytes and forwards them as Hex.
# (Use this for MeshCore Companion Mode or binary protocols)
# companion_radio: MeshCore Companion USB framing protocol (recommended for MeshCore radios)
SERIAL_PROTOCOL = json_newline # Use json_newline for text/JSON, raw_serial for binary/hex, companion_radio for MeshCore USB framing
# Use json_newline for text/JSON, raw_serial for binary/hex, or
# companion_radio for MeshCore USB framing.
SERIAL_PROTOCOL = json_newline
# Auto-switch between json_newline and raw_serial after repeated decode failures.
# Set False to disable protocol auto-detection.
+9
View File
@@ -16,6 +16,15 @@ RestartSec=5
TimeoutStopSec=20
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
UMask=0077
# Serial devices need to remain visible to the service.
SupplementaryGroups=dialout
+31 -1
View File
@@ -1,5 +1,5 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
requires = ["setuptools>=77.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
@@ -9,6 +9,28 @@ description = "Bridge between Meshtastic and MeshCore networks"
readme = "README.md"
requires-python = ">=3.10"
dynamic = ["dependencies"]
license = "GPL-3.0-only"
license-files = ["LICENSE"]
authors = [
{name = "Akita Engineering", email = "info@akitaengineering.com"},
]
keywords = ["meshtastic", "meshcore", "lora", "mqtt", "bridge"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Communications",
]
[project.urls]
Homepage = "https://www.akitaengineering.com"
Repository = "https://github.com/AkitaEngineering/Akita-Meshtastic-Meshcore-Bridge"
Documentation = "https://github.com/AkitaEngineering/Akita-Meshtastic-Meshcore-Bridge/blob/main/DEPLOYMENT.md"
Issues = "https://github.com/AkitaEngineering/Akita-Meshtastic-Meshcore-Bridge/issues"
[tool.setuptools.dynamic]
dependencies = {file = ["requirements.txt"]}
@@ -21,6 +43,14 @@ py-modules = [
"run_bridge_tui"
]
[tool.setuptools.data-files]
"share/akita-mesh-bridge" = [
"CHANGELOG.md",
"DEPLOYMENT.md",
"examples/config.ini.example",
"packaging/ammb.service",
]
[project.scripts]
run-bridge = "run_bridge:main"
run-bridge-async = "run_bridge_async:main"
+3
View File
@@ -10,3 +10,6 @@ pytest-mock>=3.10.0,<4.0.0
pytest-asyncio>=1.0.0,<2.0.0
flake8>=6.0.0,<7.0.0
mypy>=1.0.0,<2.0.0
build>=1.2.0,<2.0.0
twine>=5.0.0,<7.0.0
pip-audit>=2.10.0,<3.0.0
+28 -3
View File
@@ -15,6 +15,7 @@ This script handles:
import argparse
import logging
import os
import signal
import sys
# Ensure the script can find the 'ammb' package
@@ -46,7 +47,8 @@ except ImportError as e:
# --- Imports ---
try:
from ammb import Bridge
from ammb.config_handler import load_config, resolve_config_path
from ammb.config_handler import resolve_config_path
from ammb.preflight import run_preflight
from ammb.utils import setup_logging
except ImportError as e:
print(f"ERROR: Failed to import AMMB modules: {e}", file=sys.stderr)
@@ -83,10 +85,25 @@ def main(argv=None):
args.config, fallback=os.path.join(project_root, "config.ini")
)
logging.info("Loading configuration from: %s", config_path)
config = load_config(config_path)
if not config:
report = run_preflight(config_path)
config = report.config
if not report.ready or config is None:
for diagnostic in report.diagnostics:
log = (
logging.error
if diagnostic.is_error
else logging.warning
)
log("Preflight %s: %s", diagnostic.title, diagnostic.detail)
logging.critical("Failed to load configuration. Bridge cannot start.")
sys.exit(1)
for diagnostic in report.diagnostics:
if diagnostic.severity.lower() == "warning":
logging.warning(
"Preflight warning: %s: %s",
diagnostic.title,
diagnostic.detail,
)
logging.info("Configuration loaded successfully.")
logging.info("Selected external transport: %s", config.external_transport)
@@ -105,6 +122,14 @@ def main(argv=None):
)
sys.exit(1)
def _handle_sigterm(signum, _frame):
logging.info(
"Signal %s received. Initiating graceful shutdown...", signum
)
bridge.shutdown_event.set()
signal.signal(signal.SIGTERM, _handle_sigterm)
try:
logging.info("Starting bridge run loop...")
bridge.run()
+33 -3
View File
@@ -10,6 +10,7 @@ import argparse
import asyncio
import logging
import os
import signal
import sys
project_root = os.path.dirname(os.path.abspath(__file__))
@@ -17,7 +18,8 @@ sys.path.insert(0, project_root)
try:
from ammb.bridge_async import AsyncBridge
from ammb.config_handler import load_config, resolve_config_path
from ammb.config_handler import resolve_config_path
from ammb.preflight import run_preflight
from ammb.utils import setup_logging
except ImportError as e:
print(f"ERROR: Failed to import AMMB modules: {e}", file=sys.stderr)
@@ -48,10 +50,25 @@ def main(argv=None):
args.config, fallback=os.path.join(project_root, "config.ini")
)
logging.info("Loading configuration from: %s", config_path)
config = load_config(config_path)
if not config:
report = run_preflight(config_path)
config = report.config
if not report.ready or config is None:
for diagnostic in report.diagnostics:
log = (
logging.error
if diagnostic.is_error
else logging.warning
)
log("Preflight %s: %s", diagnostic.title, diagnostic.detail)
logging.critical("Failed to load configuration. Bridge cannot start.")
sys.exit(1)
for diagnostic in report.diagnostics:
if diagnostic.severity.lower() == "warning":
logging.warning(
"Preflight warning: %s: %s",
diagnostic.title,
diagnostic.detail,
)
logging.info("Configuration loaded successfully.")
logging.info("Selected external transport: %s", config.external_transport)
@@ -59,6 +76,19 @@ def main(argv=None):
logging.debug("Logging level set to %s", config.log_level)
bridge = AsyncBridge(config)
if not bridge.bridge.external_handler:
logging.critical(
"Bridge initialization failed (likely handler issue). Exiting."
)
sys.exit(1)
def _handle_sigterm(signum, _frame):
logging.info(
"Signal %s received. Initiating graceful shutdown...", signum
)
bridge.request_shutdown()
signal.signal(signal.SIGTERM, _handle_sigterm)
try:
asyncio.run(bridge.start())
+50
View File
@@ -4,7 +4,9 @@ from queue import Queue
from types import SimpleNamespace
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from ammb.api import BridgeAPIServer, extract_request_token, token_matches
@@ -76,6 +78,17 @@ def test_sync_api_info_uses_package_version():
server.stop()
def test_sync_api_startup_failure_is_fatal():
bridge = _FakeBridge(make_bridge_config(api_enabled=True))
server = BridgeAPIServer(bridge, host="127.0.0.1", port=8080)
with patch(
"ammb.api.ThreadingHTTPServer",
side_effect=OSError("address unavailable"),
), pytest.raises(RuntimeError, match="configured API server"):
server.start()
def test_sync_api_requires_token_when_configured():
config = make_bridge_config(
api_enabled=True,
@@ -127,6 +140,24 @@ def test_sync_api_reset_metrics():
server.stop()
def test_sync_api_rejects_oversized_control_body():
config = make_bridge_config(api_enabled=True, api_port=0)
server = BridgeAPIServer(_FakeBridge(config), host="127.0.0.1", port=0)
server.start()
try:
status, data = _request(
"127.0.0.1",
server.port,
"POST",
"/api/control",
body={"payload": "x" * (64 * 1024)},
)
assert status == 413
assert data["error"] == "Request body too large"
finally:
server.stop()
def test_metrics_get_all_stats_does_not_deadlock():
get_metrics().record_meshtastic_connection()
stats = get_metrics().get_all_stats()
@@ -147,3 +178,22 @@ def test_async_api_requires_token_and_reports_version():
assert allowed.status_code == 200
assert allowed.json()["version"] == __version__
reset_async_api()
def test_async_api_rejects_invalid_and_oversized_control_bodies():
reset_async_api()
client = TestClient(app)
invalid = client.post(
"/api/control",
content=b"not-json",
headers={"Content-Type": "application/json"},
)
assert invalid.status_code == 400
oversized = client.post(
"/api/control",
content=b"x" * (64 * 1024 + 1),
headers={"Content-Type": "application/json"},
)
assert oversized.status_code == 413
+11
View File
@@ -50,3 +50,14 @@ def test_async_bridge_does_not_need_api_when_disabled():
config = make_bridge_config(api_enabled=False)
async_bridge = AsyncBridge(config)
assert async_bridge.bridge.config is config
def test_async_bridge_disables_duplicate_sync_api():
config = make_bridge_config(api_enabled=True)
with patch("ammb.bridge_async.Bridge") as bridge_class:
AsyncBridge(config)
wrapped_config = bridge_class.call_args.args[0]
assert wrapped_config.api_enabled is False
assert config.api_enabled is True
+25
View File
@@ -54,3 +54,28 @@ def test_resolve_config_path_prefers_explicit_then_env(tmp_path, monkeypatch):
monkeypatch.setenv("AMMB_CONFIG", str(env_file))
assert resolve_config_path(str(explicit)).endswith("explicit.ini")
assert resolve_config_path().endswith("env.ini")
def test_load_config_never_logs_secrets(tmp_path, caplog):
secret = "must-not-appear-in-logs"
path = _write_config(
tmp_path / "config.ini",
extra=(
"API_TOKEN = %s\n"
"MQTT_PASSWORD = %s\n"
) % (secret, secret),
)
with caplog.at_level("DEBUG"):
assert load_config(str(path)) is not None
assert secret not in caplog.text
def test_load_config_rejects_unknown_serial_protocol(tmp_path):
path = _write_config(
tmp_path / "config.ini",
extra="SERIAL_PROTOCOL = unknown-handler\n",
)
assert load_config(str(path)) is None
+2
View File
@@ -1,4 +1,5 @@
import json
import stat
import time
from pathlib import Path
@@ -39,6 +40,7 @@ def test_logger_writes_json_lines(tmp_path):
assert entry["payload"] == "hello"
assert entry["direction"] == "meshtastic_to_external"
assert "logged_at" in entry
assert stat.S_IMODE(log_file.stat().st_mode) == 0o600
def test_configure_message_logger_is_process_wide(tmp_path):
+26
View File
@@ -68,6 +68,32 @@ LOG_LEVEL = INFO
assert report.diagnostics[0].title == "MQTT CA certificate missing"
def test_preflight_warns_when_mqtt_certificate_checks_are_disabled(tmp_path):
config_path = _write_config(
tmp_path / "mqtt.ini",
"""
EXTERNAL_TRANSPORT = mqtt
MQTT_BROKER = broker.example
MQTT_PORT = 8883
MQTT_TOPIC_IN = ammb/in
MQTT_TOPIC_OUT = ammb/out
MQTT_CLIENT_ID = bridge-client
MQTT_QOS = 1
MQTT_RETAIN_OUT = False
MQTT_TLS_ENABLED = True
MQTT_TLS_INSECURE = True
LOG_LEVEL = INFO
""",
)
report = run_preflight(str(config_path), importer=_fake_importer)
assert report.ready is True
assert "MQTT certificate verification disabled" in {
item.title for item in report.diagnostics
}
def test_preflight_allows_warnings_without_blocking_start(tmp_path):
config_path = _write_config(
tmp_path / "serial.ini",