fix(config): save_config must round-trip role + secrets block
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 <MISSING_SECRET> 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.
This commit is contained in:
@@ -680,6 +680,19 @@ def save_config(config: CastleConfig) -> None:
|
|||||||
n: s.model_dump(exclude_none=True, exclude_defaults=True)
|
n: s.model_dump(exclude_none=True, exclude_defaults=True)
|
||||||
for n, s in config.agents.items()
|
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"
|
config_path = config.root / "castle.yaml"
|
||||||
with open(config_path, "w") as f:
|
with open(config_path, "w") as f:
|
||||||
|
|||||||
@@ -31,6 +31,27 @@ def test_role_loaded_from_yaml(tmp_path: Path) -> None:
|
|||||||
assert load_config(tmp_path).role == "authority"
|
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:
|
def test_registry_role_round_trip(tmp_path: Path) -> None:
|
||||||
reg = NodeRegistry(
|
reg = NodeRegistry(
|
||||||
node=NodeConfig(hostname="civil", role="authority"), deployed={}
|
node=NodeConfig(hostname="civil", role="authority"), deployed={}
|
||||||
|
|||||||
Reference in New Issue
Block a user