From 1bc41ddd2d505b609ee98e8e5ffb6aca044e821a Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Tue, 7 Jul 2026 08:22:28 -0700 Subject: [PATCH] fix(config): save_config must round-trip role + secrets block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit save_config rewrites castle.yaml from scratch and only re-emitted gateway/repo/ roots — so any config save (dashboard deployment edit, config_editor) silently STRIPPED top-level 'role' and the 'secrets:' backend block. Effect: the node reverted to a file-backend follower, the vault stopped resolving, and a subsequent apply baked into service env (hit castle-lakehouse). Now re-emit role (from config) + preserve the secrets block (read from the existing file, since it's not modeled on CastleConfig). Regression test added. --- core/src/castle_core/config.py | 13 +++++++++++++ core/tests/test_fleet_role.py | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index c063b3f..dc3b9ac 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -680,6 +680,19 @@ def save_config(config: CastleConfig) -> None: n: s.model_dump(exclude_none=True, exclude_defaults=True) for n, s in config.agents.items() } + # MUST round-trip (save rewrites from scratch): the fleet role and the + # `secrets:` backend block are not otherwise re-emitted, so they'd be silently + # dropped on the next save — reverting the node to a follower on the file + # backend. `role` lives on the config; `secrets` isn't modeled, so preserve it + # from the existing file. + if config.role and config.role != "follower": + data["role"] = config.role + try: + existing = yaml.safe_load((config.root / "castle.yaml").read_text()) or {} + if existing.get("secrets"): + data["secrets"] = existing["secrets"] + except Exception: + pass config_path = config.root / "castle.yaml" with open(config_path, "w") as f: diff --git a/core/tests/test_fleet_role.py b/core/tests/test_fleet_role.py index 2d440e5..0ab9187 100644 --- a/core/tests/test_fleet_role.py +++ b/core/tests/test_fleet_role.py @@ -31,6 +31,27 @@ def test_role_loaded_from_yaml(tmp_path: Path) -> None: assert load_config(tmp_path).role == "authority" +def test_save_config_round_trips_role_and_secrets(tmp_path: Path) -> None: + """save_config rewrites castle.yaml from scratch — it must re-emit `role` and + preserve the `secrets:` block, or a save reverts the node to a file-backend + follower (the regression this guards).""" + from castle_core.config import load_config, save_config + + (tmp_path / "castle.yaml").write_text( + yaml.safe_dump( + { + "gateway": {"port": 18000}, + "role": "authority", + "secrets": {"backend": "openbao", "addr": "https://v:8200"}, + } + ) + ) + save_config(load_config(tmp_path)) + reloaded = yaml.safe_load((tmp_path / "castle.yaml").read_text()) + assert reloaded.get("role") == "authority" + assert reloaded.get("secrets", {}).get("backend") == "openbao" + + def test_registry_role_round_trip(tmp_path: Path) -> None: reg = NodeRegistry( node=NodeConfig(hostname="civil", role="authority"), deployed={}