Refactor configuration handling to use directory-per-resource layout
- Introduced `_write_castle_config` function to scatter nested config into separate YAML files for programs, services, and jobs. - Updated `castle_root` fixture to utilize the new config writing method. - Added tests for configuration aggregation and scattering in `test_health.py`. - Removed legacy unit handling from `manifest.py` and refactored related code in `config.py`. - Updated documentation to reflect new configuration directory structure. - Removed obsolete unit tests related to unit expansion.
This commit is contained in:
@@ -9,7 +9,14 @@ import yaml
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from castle_core.config import save_config
|
||||
from castle_core.config import (
|
||||
CastleConfig,
|
||||
GatewayConfig,
|
||||
_program_to_yaml_dict,
|
||||
_spec_to_yaml_dict,
|
||||
load_config,
|
||||
save_config,
|
||||
)
|
||||
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
|
||||
|
||||
from castle_api.config import get_castle_root, get_config, get_registry
|
||||
@@ -61,13 +68,31 @@ def _require_repo() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _aggregate_yaml(config: CastleConfig) -> str:
|
||||
"""Build a unified virtual castle.yaml from the directory-per-resource config."""
|
||||
data: dict = {"gateway": {"port": config.gateway.port}}
|
||||
if config.repo:
|
||||
data["repo"] = str(config.repo)
|
||||
if config.programs:
|
||||
data["programs"] = {
|
||||
n: _program_to_yaml_dict(s, config) for n, s in config.programs.items()
|
||||
}
|
||||
if config.services:
|
||||
data["services"] = {
|
||||
n: _spec_to_yaml_dict(s) for n, s in config.services.items()
|
||||
}
|
||||
if config.jobs:
|
||||
data["jobs"] = {n: _spec_to_yaml_dict(s) for n, s in config.jobs.items()}
|
||||
return yaml.dump(data, default_flow_style=False, sort_keys=False)
|
||||
|
||||
|
||||
@router.get("", response_model=ConfigResponse)
|
||||
def get_config_yaml() -> ConfigResponse:
|
||||
"""Get the raw castle.yaml content."""
|
||||
"""Get a unified virtual castle.yaml aggregated from all resource files."""
|
||||
_require_repo()
|
||||
root = get_castle_root()
|
||||
config_path = root / "castle.yaml"
|
||||
return ConfigResponse(yaml_content=config_path.read_text())
|
||||
config = load_config(root)
|
||||
return ConfigResponse(yaml_content=_aggregate_yaml(config))
|
||||
|
||||
|
||||
@router.put("", response_model=ConfigSaveResponse)
|
||||
@@ -92,37 +117,58 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
|
||||
detail="YAML must be a mapping",
|
||||
)
|
||||
|
||||
# repo: drives repo-relative source resolution (fall back to existing config)
|
||||
repo_path = None
|
||||
if data.get("repo"):
|
||||
from pathlib import Path
|
||||
|
||||
repo_path = Path(data["repo"]).expanduser()
|
||||
else:
|
||||
try:
|
||||
repo_path = load_config(root).repo
|
||||
except Exception:
|
||||
repo_path = None
|
||||
|
||||
def _resolve_source(spec: ProgramSpec) -> None:
|
||||
from pathlib import Path
|
||||
|
||||
if not spec.source:
|
||||
return
|
||||
if spec.source.startswith("repo:") and repo_path:
|
||||
spec.source = str(repo_path / spec.source[5:])
|
||||
elif not Path(spec.source).is_absolute():
|
||||
spec.source = str(root / spec.source)
|
||||
|
||||
# Validate programs
|
||||
prog_count = 0
|
||||
programs_data = data.get("programs") or data.get("components") or {}
|
||||
programs: dict[str, ProgramSpec] = {}
|
||||
programs_data = data.get("programs") or {}
|
||||
for name, comp_data in programs_data.items():
|
||||
try:
|
||||
comp_data_copy = dict(comp_data) if comp_data else {}
|
||||
comp_data_copy["id"] = name
|
||||
ProgramSpec.model_validate(comp_data_copy)
|
||||
prog_count += 1
|
||||
spec = ProgramSpec.model_validate(comp_data_copy)
|
||||
_resolve_source(spec)
|
||||
programs[name] = spec
|
||||
except Exception as e:
|
||||
errors.append(f"programs.{name}: {e}")
|
||||
|
||||
# Validate services
|
||||
svc_count = 0
|
||||
services: dict[str, ServiceSpec] = {}
|
||||
for name, svc_data in data.get("services", {}).items():
|
||||
try:
|
||||
svc_data_copy = dict(svc_data) if svc_data else {}
|
||||
svc_data_copy["id"] = name
|
||||
ServiceSpec.model_validate(svc_data_copy)
|
||||
svc_count += 1
|
||||
services[name] = ServiceSpec.model_validate(svc_data_copy)
|
||||
except Exception as e:
|
||||
errors.append(f"services.{name}: {e}")
|
||||
|
||||
# Validate jobs
|
||||
job_count = 0
|
||||
jobs: dict[str, JobSpec] = {}
|
||||
for name, job_data in data.get("jobs", {}).items():
|
||||
try:
|
||||
job_data_copy = dict(job_data) if job_data else {}
|
||||
job_data_copy["id"] = name
|
||||
JobSpec.model_validate(job_data_copy)
|
||||
job_count += 1
|
||||
jobs[name] = JobSpec.model_validate(job_data_copy)
|
||||
except Exception as e:
|
||||
errors.append(f"jobs.{name}: {e}")
|
||||
|
||||
@@ -132,15 +178,27 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
|
||||
detail={"errors": errors},
|
||||
)
|
||||
|
||||
# Backup and save
|
||||
config_path = root / "castle.yaml"
|
||||
backup_path = config_path.with_suffix(".yaml.bak")
|
||||
shutil.copy2(config_path, backup_path)
|
||||
config_path.write_text(request.yaml_content)
|
||||
prog_count = len(programs)
|
||||
svc_count = len(services)
|
||||
job_count = len(jobs)
|
||||
|
||||
gateway_data = data.get("gateway", {})
|
||||
config = CastleConfig(
|
||||
root=root,
|
||||
repo=repo_path,
|
||||
gateway=GatewayConfig(port=gateway_data.get("port", 9000)),
|
||||
programs=programs,
|
||||
services=services,
|
||||
jobs=jobs,
|
||||
)
|
||||
save_config(config)
|
||||
|
||||
return ConfigSaveResponse(
|
||||
ok=True, program_count=prog_count, service_count=svc_count,
|
||||
job_count=job_count, errors=[],
|
||||
ok=True,
|
||||
program_count=prog_count,
|
||||
service_count=svc_count,
|
||||
job_count=job_count,
|
||||
errors=[],
|
||||
)
|
||||
|
||||
|
||||
@@ -160,9 +218,7 @@ def save_program(name: str, request: ProgramConfigRequest) -> dict:
|
||||
)
|
||||
|
||||
config = get_config()
|
||||
config.programs[name] = ProgramSpec.model_validate(
|
||||
{**request.config, "id": name}
|
||||
)
|
||||
config.programs[name] = ProgramSpec.model_validate({**request.config, "id": name})
|
||||
save_config(config)
|
||||
return {"ok": True, "program": name}
|
||||
|
||||
@@ -211,9 +267,7 @@ def save_service(name: str, request: ServiceConfigRequest) -> dict:
|
||||
)
|
||||
|
||||
config = get_config()
|
||||
config.services[name] = ServiceSpec.model_validate(
|
||||
{**request.config, "id": name}
|
||||
)
|
||||
config.services[name] = ServiceSpec.model_validate({**request.config, "id": name})
|
||||
save_config(config)
|
||||
return {"ok": True, "service": name}
|
||||
|
||||
@@ -248,9 +302,7 @@ def save_job(name: str, request: JobConfigRequest) -> dict:
|
||||
)
|
||||
|
||||
config = get_config()
|
||||
config.jobs[name] = JobSpec.model_validate(
|
||||
{**request.config, "id": name}
|
||||
)
|
||||
config.jobs[name] = JobSpec.model_validate({**request.config, "id": name})
|
||||
save_config(config)
|
||||
return {"ok": True, "job": name}
|
||||
|
||||
|
||||
@@ -17,10 +17,25 @@ from castle_core.registry import (
|
||||
)
|
||||
|
||||
|
||||
def _write_castle_config(root: Path, config: dict) -> None:
|
||||
"""Scatter a nested castle config dict into the directory-per-resource layout."""
|
||||
globals_data = {k: v for k, v in config.items() if k in ("gateway", "repo")}
|
||||
(root / "castle.yaml").write_text(yaml.dump(globals_data, default_flow_style=False))
|
||||
for section in ("programs", "services", "jobs"):
|
||||
entries = config.get(section) or {}
|
||||
if not entries:
|
||||
continue
|
||||
section_dir = root / section
|
||||
section_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name, spec in entries.items():
|
||||
(section_dir / f"{name}.yaml").write_text(
|
||||
yaml.dump(spec, default_flow_style=False)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
"""Create a temporary castle root with castle.yaml."""
|
||||
castle_yaml = tmp_path / "castle.yaml"
|
||||
"""Create a temporary castle root with directory-per-resource config."""
|
||||
config = {
|
||||
"gateway": {"port": 9000},
|
||||
"programs": {
|
||||
@@ -77,7 +92,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
},
|
||||
},
|
||||
}
|
||||
castle_yaml.write_text(yaml.dump(config, default_flow_style=False))
|
||||
_write_castle_config(tmp_path, config)
|
||||
yield tmp_path
|
||||
|
||||
|
||||
@@ -143,7 +158,14 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
|
||||
"config_editor.get_castle_root": config_editor_mod.get_castle_root,
|
||||
}
|
||||
|
||||
for mod in [api_config, routes_mod, services_mod, nodes_mod, stream_mod, config_editor_mod]:
|
||||
for mod in [
|
||||
api_config,
|
||||
routes_mod,
|
||||
services_mod,
|
||||
nodes_mod,
|
||||
stream_mod,
|
||||
config_editor_mod,
|
||||
]:
|
||||
if hasattr(mod, "get_registry"):
|
||||
mod.get_registry = _get_registry
|
||||
if hasattr(mod, "get_castle_root"):
|
||||
|
||||
@@ -268,3 +268,36 @@ class TestGateway:
|
||||
assert data["routes"]
|
||||
for r in data["routes"]:
|
||||
assert r["kind"] in ("static", "proxy", "remote")
|
||||
|
||||
|
||||
class TestConfigEditor:
|
||||
"""Virtual castle.yaml aggregation/scatter endpoints."""
|
||||
|
||||
def test_get_aggregates_resources(self, client: TestClient) -> None:
|
||||
"""GET /config returns a unified YAML aggregating all resource files."""
|
||||
import yaml
|
||||
|
||||
response = client.get("/config")
|
||||
assert response.status_code == 200
|
||||
data = yaml.safe_load(response.json()["yaml_content"])
|
||||
assert "test-tool" in data["programs"]
|
||||
assert "test-svc" in data["services"]
|
||||
assert "test-job" in data["jobs"]
|
||||
|
||||
def test_put_scatters_and_prunes(self, client: TestClient, castle_root) -> None:
|
||||
"""PUT /config writes resource files and prunes removed ones."""
|
||||
import yaml
|
||||
|
||||
current = yaml.safe_load(client.get("/config").json()["yaml_content"])
|
||||
current["services"].pop("test-svc")
|
||||
current["programs"]["new-tool"] = {
|
||||
"description": "Brand new",
|
||||
"behavior": "tool",
|
||||
}
|
||||
resp = client.put("/config", json={"yaml_content": yaml.dump(current)})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert not (castle_root / "services" / "test-svc.yaml").exists()
|
||||
assert (castle_root / "programs" / "new-tool.yaml").exists()
|
||||
after = yaml.safe_load(client.get("/config").json()["yaml_content"])
|
||||
assert "new-tool" in after["programs"]
|
||||
assert "test-svc" not in (after.get("services") or {})
|
||||
|
||||
Reference in New Issue
Block a user