mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +02:00
fix(dashboard): v0.4 C.2.1 -- route PUT /config to multi-file save_section (Rule 17 persistence unblocked)
C.2 surfaced that GUI config saves were broken in the prod multi-file
layout. This fixes it. Pre-existing v0.3-era bug (predates C.2; affected
EVERY config section, not just environmental).
Save flow (before -> after):
before: PUT /api/config/{section} -> config.py::load_config(config.yaml)
[monolithic, vanilla YAML] -> blows up on the !include orchestrator
("could not determine a constructor for the tag '!include'"),
then config.py::save_config (same !include-blind path). Every save
500'd; nothing persisted.
after: PUT validates the body by coercing to the section dataclass (runs
__post_init__ validators, e.g. feed_source), then persists via
config_loader.py::save_section(section, dict, config_dir) -- the
multi-file / !include-aware writer. It writes ONLY the section's
target file (env_feeds.yaml for environmental, notifications.yaml,
llm.yaml, ...), strips SECRET_FIELDS (traffic.api_key, firms.map_key)
and extracts LOCAL_FIELDS (ducting lat/lon -> local.yaml). The
orchestrator config.yaml and its !include directives are never
re-parsed. Live app.state.config is kept in sync via setattr when
the section isn't restart-required (no disk reload needed).
Also: save_section now tolerates a top-level LIST section (mesh_sources) --
it cleans each item for secrets and writes the list directly instead of
assuming a dict (which would have crashed). Other callers of save_config are
untouched (it remains valid for the monolithic single-file path).
Files: meshai/dashboard/api/config_routes.py (PUT handler + import),
meshai/config_loader.py (save_section list guard),
tests/test_dashboard_config_save.py (new).
Verification:
- (A) py_compile clean on config_routes.py + config_loader.py.
- (C) pytest -q: 272 passed (269 + 3 new -- save_section writes env_feeds,
strips secret fields, handles the mesh_sources list section).
- (D) Rebuilt prod; ran the C.2 round-trip again, now SUCCESS: backup
env_feeds.yaml (md5 dde5d634...), GET then PUT /api/config/environmental ->
{"saved":true,"restart_required":false} (NO !include error); disk reflected
it (feed_source on all 10 adapters + central block written); restored from
backup -> md5 matches original -> DISK_PRISTINE_RESTORED.
- (E) Rule 17 round-trip confirmed: the GUI can now SAVE config that
round-trips to disk in the multi-file !include layout, secrets staying in
.env and local fields in local.yaml.
C.3 (quake -> central flip) is now unblocked: feed_source can be flipped and
saved from the GUI.
Follow-up (non-blocking): mesh_sources per-item secret stripping
(mesh_sources.*.api_token) isn't matched by the section-relative check in the
new list path; mesh_sources files are volume-only (not git) and this was no
worse before, but worth tightening when mesh_sources GUI save is exercised.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8eb0c6468c
commit
a4f23c226e
3 changed files with 67 additions and 17 deletions
|
|
@ -595,10 +595,14 @@ def save_section(
|
||||||
cleaned[key] = value
|
cleaned[key] = value
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
data = check_secrets(data)
|
# List sections (e.g. mesh_sources) have no top-level dict to scan for
|
||||||
|
# local fields; clean each item for secrets and write the list directly.
|
||||||
# Extract local fields
|
if isinstance(data, list):
|
||||||
domain_data, local_updates = _extract_local_fields(section_name, data)
|
domain_data = [check_secrets(item) if isinstance(item, dict) else item for item in data]
|
||||||
|
local_updates = {}
|
||||||
|
else:
|
||||||
|
data = check_secrets(data)
|
||||||
|
domain_data, local_updates = _extract_local_fields(section_name, data)
|
||||||
|
|
||||||
# Load existing target file
|
# Load existing target file
|
||||||
if target_path.exists():
|
if target_path.exists():
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from meshai.config import (
|
||||||
load_config,
|
load_config,
|
||||||
save_config,
|
save_config,
|
||||||
)
|
)
|
||||||
|
from meshai.config_loader import save_section, get_config_dir_from_path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -102,9 +103,6 @@ async def update_config_section(section: str, request: Request):
|
||||||
raise HTTPException(status_code=422, detail=f"Invalid JSON: {e}")
|
raise HTTPException(status_code=422, detail=f"Invalid JSON: {e}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Load fresh config from file to avoid conflicts
|
|
||||||
config = load_config(config_path)
|
|
||||||
|
|
||||||
# Get the section's dataclass type
|
# Get the section's dataclass type
|
||||||
field_info = Config.__dataclass_fields__.get(section)
|
field_info = Config.__dataclass_fields__.get(section)
|
||||||
if not field_info:
|
if not field_info:
|
||||||
|
|
@ -112,31 +110,39 @@ async def update_config_section(section: str, request: Request):
|
||||||
|
|
||||||
field_type = field_info.type
|
field_type = field_info.type
|
||||||
|
|
||||||
# Handle list types (mesh_sources)
|
# Validate by coercing to the dataclass (runs __post_init__ validators),
|
||||||
|
# then persist via the multi-file / !include-aware save_section. The
|
||||||
|
# monolithic save_config cannot parse the !include orchestrator and blew
|
||||||
|
# up on every save in the prod layout (v0.4 C.2.1 fix).
|
||||||
if section == "mesh_sources":
|
if section == "mesh_sources":
|
||||||
from meshai.config import MeshSourceConfig
|
from meshai.config import MeshSourceConfig
|
||||||
new_value = [
|
new_value = [
|
||||||
_dict_to_dataclass(MeshSourceConfig, item) if isinstance(item, dict) else item
|
_dict_to_dataclass(MeshSourceConfig, item) if isinstance(item, dict) else item
|
||||||
for item in body
|
for item in body
|
||||||
]
|
]
|
||||||
# Handle dataclass types
|
data_to_save = [
|
||||||
|
_dataclass_to_dict(v) if hasattr(v, "__dataclass_fields__") else v
|
||||||
|
for v in new_value
|
||||||
|
]
|
||||||
elif hasattr(field_type, "__dataclass_fields__"):
|
elif hasattr(field_type, "__dataclass_fields__"):
|
||||||
new_value = _dict_to_dataclass(field_type, body)
|
new_value = _dict_to_dataclass(field_type, body)
|
||||||
|
data_to_save = _dataclass_to_dict(new_value)
|
||||||
else:
|
else:
|
||||||
new_value = body
|
new_value = body
|
||||||
|
data_to_save = body
|
||||||
|
|
||||||
# Set the section on config
|
config_dir = get_config_dir_from_path(config_path)
|
||||||
setattr(config, section, new_value)
|
save_section(section, data_to_save, config_dir)
|
||||||
|
|
||||||
# Save config to file
|
|
||||||
save_config(config, config_path)
|
|
||||||
|
|
||||||
# Determine if restart is required
|
# Determine if restart is required
|
||||||
restart_required = section in RESTART_REQUIRED_SECTIONS
|
restart_required = section in RESTART_REQUIRED_SECTIONS
|
||||||
|
|
||||||
# Update live config if restart not required
|
# Keep the live config in sync (no disk reload needed) when no restart is required
|
||||||
if not restart_required:
|
if not restart_required and getattr(request.app.state, "config", None) is not None:
|
||||||
request.app.state.config = config
|
try:
|
||||||
|
setattr(request.app.state.config, section, new_value)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
logger.info(f"Config section '{section}' updated, restart_required={restart_required}")
|
logger.info(f"Config section '{section}' updated, restart_required={restart_required}")
|
||||||
|
|
||||||
|
|
|
||||||
40
tests/test_dashboard_config_save.py
Normal file
40
tests/test_dashboard_config_save.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""v0.4 C.2.1: dashboard config save routes through the multi-file save_section
|
||||||
|
(the !include-aware writer), not the monolithic save_config."""
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from meshai.config_loader import save_section
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_section_writes_env_feeds(tmp_path):
|
||||||
|
(tmp_path / "env_feeds.yaml").write_text("enabled: true\nnws:\n enabled: true\n")
|
||||||
|
res = save_section("environmental", {
|
||||||
|
"enabled": True,
|
||||||
|
"nws": {"enabled": True, "feed_source": "native"},
|
||||||
|
"usgs_quake": {"enabled": False, "feed_source": "native"},
|
||||||
|
}, tmp_path)
|
||||||
|
assert res["saved"] is True
|
||||||
|
written = yaml.safe_load((tmp_path / "env_feeds.yaml").read_text())
|
||||||
|
assert written["nws"]["feed_source"] == "native"
|
||||||
|
assert written["usgs_quake"]["feed_source"] == "native"
|
||||||
|
# only env_feeds.yaml was written; no config.yaml / orchestrator touched
|
||||||
|
assert not (tmp_path / "config.yaml").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_section_strips_secret_fields(tmp_path):
|
||||||
|
res = save_section("environmental", {
|
||||||
|
"enabled": True,
|
||||||
|
"traffic": {"enabled": True, "api_key": "SECRET123"},
|
||||||
|
}, tmp_path)
|
||||||
|
written = yaml.safe_load((tmp_path / "env_feeds.yaml").read_text())
|
||||||
|
assert "api_key" not in written.get("traffic", {})
|
||||||
|
assert "traffic.api_key" in res["rejected_secrets"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_section_handles_list_section(tmp_path):
|
||||||
|
# mesh_sources is a top-level list section -- must not crash (C.2.1 guard)
|
||||||
|
res = save_section("mesh_sources", [{"name": "a", "type": "mqtt", "enabled": True}], tmp_path)
|
||||||
|
assert res["saved"] is True
|
||||||
|
written = yaml.safe_load((tmp_path / "mesh_sources.yaml").read_text())
|
||||||
|
assert isinstance(written, list)
|
||||||
|
assert written[0]["name"] == "a"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue