Files
wild-pc/castle-api/tests/test_kind_twins.py
Paul Payne 5beebfd739 core: drop pre-migration read-compat now both nodes are current-format
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).
2026-07-07 09:16:03 -07:00

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