From a4f23c226e0cb1aa6ed1f278e16e727628ec8329 Mon Sep 17 00:00:00 2001 From: K7ZVX Date: Thu, 28 May 2026 03:17:30 +0000 Subject: [PATCH] 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) --- meshai/config_loader.py | 12 +++++--- meshai/dashboard/api/config_routes.py | 32 ++++++++++++--------- tests/test_dashboard_config_save.py | 40 +++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 17 deletions(-) create mode 100644 tests/test_dashboard_config_save.py diff --git a/meshai/config_loader.py b/meshai/config_loader.py index ffb8392..0a5d019 100644 --- a/meshai/config_loader.py +++ b/meshai/config_loader.py @@ -595,10 +595,14 @@ def save_section( cleaned[key] = value return cleaned - data = check_secrets(data) - - # Extract local fields - domain_data, local_updates = _extract_local_fields(section_name, 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. + if isinstance(data, list): + 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 if target_path.exists(): diff --git a/meshai/dashboard/api/config_routes.py b/meshai/dashboard/api/config_routes.py index bebea1a..142943d 100644 --- a/meshai/dashboard/api/config_routes.py +++ b/meshai/dashboard/api/config_routes.py @@ -11,6 +11,7 @@ from meshai.config import ( load_config, save_config, ) +from meshai.config_loader import save_section, get_config_dir_from_path 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}") try: - # Load fresh config from file to avoid conflicts - config = load_config(config_path) - # Get the section's dataclass type field_info = Config.__dataclass_fields__.get(section) if not field_info: @@ -112,31 +110,39 @@ async def update_config_section(section: str, request: Request): 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": from meshai.config import MeshSourceConfig new_value = [ _dict_to_dataclass(MeshSourceConfig, item) if isinstance(item, dict) else item 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__"): new_value = _dict_to_dataclass(field_type, body) + data_to_save = _dataclass_to_dict(new_value) else: new_value = body + data_to_save = body - # Set the section on config - setattr(config, section, new_value) - - # Save config to file - save_config(config, config_path) + config_dir = get_config_dir_from_path(config_path) + save_section(section, data_to_save, config_dir) # Determine if restart is required restart_required = section in RESTART_REQUIRED_SECTIONS - # Update live config if restart not required - if not restart_required: - request.app.state.config = config + # Keep the live config in sync (no disk reload needed) when no restart is required + if not restart_required and getattr(request.app.state, "config", None) is not None: + try: + setattr(request.app.state.config, section, new_value) + except Exception: + pass logger.info(f"Config section '{section}' updated, restart_required={restart_required}") diff --git a/tests/test_dashboard_config_save.py b/tests/test_dashboard_config_save.py new file mode 100644 index 0000000..4ee84f0 --- /dev/null +++ b/tests/test_dashboard_config_save.py @@ -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"