Both machines (civil, primer) are on the current on-disk layout, so the read-compat for older formats is dead weight. Remove it: - config loader: only deployments/<kind>/<name>.yaml (drop flat deployments/*.yaml and the services/+jobs/ split); drop the run.runner→manager normalizer. - registry loader: require composite <kind>/<name> keys + manager/kind (registry.yaml is regenerated every apply, so it's always current). - manifest: drop the proxy/public→reach legacy input and the component→program alias (the derived proxy/public read accessors stay). reach field defaults already match. - config_editor: parse incoming specs directly (frontend sends the current shape). - save_config: drop the flat-file migration cleanup. Bootstrap seeds move to bootstrap/deployments/<kind>/ and install.sh seeds that per-kind tree directly. The legacy→current translation for terse test fixtures moves into each test conftest (out of production). All suites green (362).
103 lines
3.9 KiB
Python
103 lines
3.9 KiB
Python
"""A tool, a service, and a job may share a name — kind-scoped endpoints must
|
|
address (and mutate) exactly one twin.
|
|
|
|
These guard the collision case the per-kind identity refactor enables: a
|
|
`backup` service + job + tool coexisting. The risk is a save/delete against one
|
|
kind bleeding into a same-named twin of another kind. We assert against the
|
|
on-disk config (load_config) so we're testing the persisted invariant, not a
|
|
response echo.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from castle_core.config import load_config
|
|
|
|
# Minimal, valid specs for each kind — all named "backup", all referencing the
|
|
# same program. Only the service is HTTP-exposed (none claims a subdomain here),
|
|
# so the trio passes subdomain-uniqueness validation.
|
|
_SVC = {
|
|
"program": "backup",
|
|
"manager": "systemd",
|
|
"run": {"launcher": "python", "program": "backup"},
|
|
"manage": {"systemd": {}},
|
|
}
|
|
_JOB = {
|
|
"program": "backup",
|
|
"manager": "systemd",
|
|
"run": {"launcher": "command", "argv": ["backup"]},
|
|
"schedule": "0 3 * * *",
|
|
}
|
|
_TOOL = {"program": "backup", "manager": "path"}
|
|
|
|
|
|
def _put(client: TestClient, section: str, name: str, cfg: dict) -> None:
|
|
r = client.put(f"/config/{section}/{name}", json={"config": cfg})
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
|
class TestKindScopedTwins:
|
|
def test_same_name_across_kinds_coexist(
|
|
self, client: TestClient, castle_root: Path
|
|
) -> None:
|
|
"""Creating a service, job, and tool all named `backup` yields three
|
|
distinct deployments — one per kind, none overwriting another."""
|
|
_put(client, "services", "backup", _SVC)
|
|
_put(client, "jobs", "backup", _JOB)
|
|
_put(client, "tools", "backup", _TOOL)
|
|
|
|
cfg = load_config(castle_root)
|
|
assert "backup" in cfg.services
|
|
assert "backup" in cfg.jobs
|
|
assert "backup" in cfg.tools
|
|
|
|
def test_detail_endpoints_resolve_the_right_twin(
|
|
self, client: TestClient, castle_root: Path
|
|
) -> None:
|
|
"""`/services/backup` and `/jobs/backup` each return their own kind, not
|
|
whichever twin happens to sort first."""
|
|
_put(client, "services", "backup", _SVC)
|
|
_put(client, "jobs", "backup", _JOB)
|
|
|
|
svc = client.get("/services/backup").json()
|
|
job = client.get("/jobs/backup").json()
|
|
assert svc["kind"] == "service"
|
|
# Each endpoint returns its own twin: the job carries the schedule, the
|
|
# service does not — so neither resolved to the other.
|
|
assert job["manifest"].get("schedule") == "0 3 * * *"
|
|
assert "schedule" not in svc["manifest"]
|
|
|
|
def test_kind_scoped_save_does_not_touch_the_twin(
|
|
self, client: TestClient, castle_root: Path
|
|
) -> None:
|
|
"""A partial patch to the *service* backup must leave the *job* backup
|
|
(and its schedule) untouched — the wrong-twin bleed this refactor closes."""
|
|
_put(client, "services", "backup", _SVC)
|
|
_put(client, "jobs", "backup", _JOB)
|
|
|
|
_put(client, "services", "backup", {"reach": "off"})
|
|
|
|
cfg = load_config(castle_root)
|
|
assert cfg.jobs["backup"].schedule == "0 3 * * *" # job untouched
|
|
assert "backup" in cfg.services # service still there
|
|
|
|
def test_kind_scoped_delete_removes_only_that_twin(
|
|
self, client: TestClient, castle_root: Path
|
|
) -> None:
|
|
"""Deleting `/config/tools/backup` drops only the tool; the service and
|
|
job twins survive."""
|
|
_put(client, "services", "backup", _SVC)
|
|
_put(client, "jobs", "backup", _JOB)
|
|
_put(client, "tools", "backup", _TOOL)
|
|
|
|
r = client.delete("/config/tools/backup")
|
|
assert r.status_code == 200, r.text
|
|
|
|
cfg = load_config(castle_root)
|
|
assert "backup" not in cfg.tools # tool gone
|
|
assert "backup" in cfg.services # twins survive
|
|
assert "backup" in cfg.jobs
|