Adds custom domains.

This commit is contained in:
2026-07-12 17:29:34 -07:00
parent 964226d671
commit 1767a0a304
19 changed files with 753 additions and 114 deletions

View File

@@ -41,6 +41,7 @@ def _make_registry(
gateway_tls: str | None = None,
gateway_domain: str | None = None,
acme_email: str | None = None,
public_domain: str | None = None,
) -> NodeRegistry:
reg = NodeRegistry(
node=NodeConfig(
@@ -49,6 +50,7 @@ def _make_registry(
gateway_tls=gateway_tls,
gateway_domain=gateway_domain,
acme_email=acme_email,
public_domain=public_domain,
),
)
for name, d in (deployed or {}).items():
@@ -68,9 +70,14 @@ def _dep(port: int, *, expose: bool, name: str | None = None, launcher: str = "p
)
def _acme(deployed: dict[str, Deployment], domain: str | None = "example.com") -> NodeRegistry:
def _acme(
deployed: dict[str, Deployment],
domain: str | None = "example.com",
public_domain: str | None = None,
) -> NodeRegistry:
return _make_registry(
gateway_tls="acme", gateway_domain=domain, acme_email="p@e.com", deployed=deployed
gateway_tls="acme", gateway_domain=domain, acme_email="p@e.com",
public_domain=public_domain, deployed=deployed,
)
@@ -133,6 +140,52 @@ class TestAcmeMode:
assert "file_server" in cf
class TestPublicExposure:
"""Public deployments are served under the public zone; a `public_host`
override (apex / other zone) gets its own standalone site with its own cert."""
def _static_pub(self, name: str, public_host: str | None = None) -> Deployment:
return Deployment(
manager="caddy", run_cmd=[], subdomain=name,
static_root=f"/data/repos/{name}/public", public=True, public_host=public_host,
)
def test_default_public_uses_wildcard_site(self) -> None:
reg = _acme({"blog": self._static_pub("blog")}, public_domain="pub.example.org")
cf = generate_caddyfile_from_registry(reg)
assert "*.pub.example.org {" in cf
assert "@host_blog_pub host blog.pub.example.org" in cf
def test_public_host_override_is_standalone_apex_site(self) -> None:
reg = _acme({"payne-io": self._static_pub("payne-io", "payne.io")},
public_domain="pub.example.org")
cf = generate_caddyfile_from_registry(reg)
# An explicit apex site (not under the *.pub wildcard) so Caddy issues its
# own cert via DNS-01; file_server serves the same local dir directly.
assert "payne.io {" in cf
assert "root * /data/repos/payne-io/public" in cf
# The override host must NOT appear as a *.pub.example.org subdomain.
assert "payne-io.pub.example.org" not in cf
def test_override_and_default_coexist(self) -> None:
reg = _acme(
{"blog": self._static_pub("blog"),
"payne-io": self._static_pub("payne-io", "payne.io")},
public_domain="pub.example.org",
)
cf = generate_caddyfile_from_registry(reg)
assert "*.pub.example.org {" in cf # default wildcard block still present
assert "@host_blog_pub host blog.pub.example.org" in cf
assert "payne.io {" in cf # standalone apex site
def test_public_host_without_default_domain(self) -> None:
# No node-wide public_domain: only the override host gets a site.
reg = _acme({"payne-io": self._static_pub("payne-io", "payne.io")})
cf = generate_caddyfile_from_registry(reg)
assert "payne.io {" in cf
assert "*.None" not in cf
class TestOffMode:
"""No domain → HTTP-only control plane on :<port>: dashboard at / + /api → castle-api.
Other services are port-only (not routed)."""

110
core/tests/test_dns.py Normal file
View File

@@ -0,0 +1,110 @@
"""Tests for multi-zone public DNS (Cloudflare CNAME) reconciliation."""
from __future__ import annotations
import castle_core.generators.dns as dns
from castle_core.generators.dns import _zone_for, reconcile_public_dns
TID = "tid-abc"
TARGET = f"{TID}.cfargotunnel.com"
ZONES = [
{"id": "z_payne", "name": "payne.io"},
{"id": "z_ex", "name": "example.org"},
]
class _FakeCloudflare:
"""A minimal fake of the Cloudflare API used by reconcile_public_dns.
``records`` maps zone_id -> {name: (record_id, content)}. Records POST/DELETE
calls so tests can assert exactly what castle created/removed.
"""
def __init__(self, records: dict[str, dict[str, tuple[str, str]]]):
self.records = records
self.created: list[tuple[str, str]] = [] # (zone_id, name)
self.deleted: list[tuple[str, str]] = [] # (zone_id, record_id)
self._n = 0
def api(self, token: str, method: str, path: str, body: dict | None = None) -> dict:
if method == "GET" and path.startswith("/zones?"):
return {"result": ZONES}
if method == "GET" and "/dns_records" in path:
zone_id = path.split("/zones/")[1].split("/")[0]
recs = self.records.get(zone_id, {})
return {
"result": [
{"id": rid, "name": name, "content": content, "type": "CNAME"}
for name, (rid, content) in recs.items()
]
}
if method == "POST":
zone_id = path.split("/zones/")[1].split("/")[0]
assert body is not None
self.created.append((zone_id, body["name"]))
return {"result": {"id": f"new{self._n}"}}
if method == "DELETE":
zone_id = path.split("/zones/")[1].split("/")[0]
rid = path.rsplit("/", 1)[1]
self.deleted.append((zone_id, rid))
return {"result": {"id": rid}}
raise AssertionError(f"unexpected call {method} {path}")
def _run(fake: _FakeCloudflare, desired: list[str], monkeypatch) -> list[str]:
monkeypatch.setattr(dns, "_api", fake.api)
messages: list[str] = []
ok = reconcile_public_dns(TID, desired, messages, token="tok")
assert ok is True
return messages
def test_longest_suffix_routes_host_to_its_zone() -> None:
assert _zone_for("payne.io", ZONES)["id"] == "z_payne" # apex
assert _zone_for("api.payne.io", ZONES)["id"] == "z_payne" # subdomain
assert _zone_for("app.example.org", ZONES)["id"] == "z_ex"
assert _zone_for("nope.other.net", ZONES) is None # no visible zone
def test_creates_apex_and_subdomain_in_correct_zones(monkeypatch) -> None:
fake = _FakeCloudflare(records={"z_payne": {}, "z_ex": {}})
_run(fake, ["payne.io", "app.example.org"], monkeypatch)
assert set(fake.created) == {("z_payne", "payne.io"), ("z_ex", "app.example.org")}
assert fake.deleted == []
def test_deletes_managed_cname_no_longer_desired(monkeypatch) -> None:
# z_payne has a stale castle-managed CNAME + a hand-managed one pointing elsewhere.
fake = _FakeCloudflare(records={
"z_payne": {
"payne.io": ("r1", TARGET), # castle-managed, still desired
"old.payne.io": ("r2", TARGET), # castle-managed, now stale → delete
"keep.payne.io": ("r3", "other.example.com"), # NOT ours → never touched
},
"z_ex": {},
})
_run(fake, ["payne.io"], monkeypatch)
assert fake.created == []
assert fake.deleted == [("z_payne", "r2")] # only the stale managed one
def test_empty_desired_cleans_all_managed(monkeypatch) -> None:
fake = _FakeCloudflare(records={
"z_payne": {"payne.io": ("r1", TARGET)},
"z_ex": {"a.example.org": ("r2", TARGET)},
})
_run(fake, [], monkeypatch)
assert set(fake.deleted) == {("z_payne", "r1"), ("z_ex", "r2")}
def test_no_token_returns_false(monkeypatch) -> None:
monkeypatch.setattr(dns, "public_dns_token", lambda: None)
assert reconcile_public_dns(TID, ["payne.io"], [], token=None) is False
def test_unresolvable_host_warns_but_still_reconciles_others(monkeypatch) -> None:
fake = _FakeCloudflare(records={"z_payne": {}, "z_ex": {}})
msgs = _run(fake, ["payne.io", "x.unknown.net"], monkeypatch)
assert fake.created == [("z_payne", "payne.io")]
assert any("unknown.net" in m for m in msgs)

View File

@@ -176,6 +176,40 @@ class TestSystemdDeployment:
}
)
def test_public_host_override_accepted_with_reach_public(self) -> None:
base = dict(
manager="systemd",
run=RunCommand(launcher="command", argv=["x"]),
expose={"http": {"internal": {"port": 9001}}},
)
s = SystemdDeployment.model_validate(
{**base, "reach": "public", "public_host": "payne.io"}
)
assert s.public_host == "payne.io"
def test_public_host_requires_reach_public(self) -> None:
"""public_host without reach: public is a no-op; reject it at load."""
base = dict(
manager="systemd",
run=RunCommand(launcher="command", argv=["x"]),
expose={"http": {"internal": {"port": 9001}}},
)
with pytest.raises(ValueError, match="public_host is only valid"):
SystemdDeployment.model_validate(
{**base, "reach": "internal", "public_host": "payne.io"}
)
def test_public_host_must_be_bare_hostname(self) -> None:
base = dict(
manager="systemd",
run=RunCommand(launcher="command", argv=["x"]),
expose={"http": {"internal": {"port": 9001}}},
reach="public",
)
for bad in ("https://payne.io", "payne.io/path", "payne.io:443", "payne .io"):
with pytest.raises(ValueError, match="bare hostname"):
SystemdDeployment.model_validate({**base, "public_host": bad})
def test_no_run_is_invalid(self) -> None:
"""A systemd deployment requires a run (launch) spec."""
with pytest.raises(Exception):

View File

@@ -46,6 +46,31 @@ class TestSupabaseStackResolution:
assert "install" in actions and "uninstall" in actions
class TestHugoStackResolution:
def test_hugo_provides_only_build_verbs(self) -> None:
"""Hugo is build-only: it resolves build/install/uninstall but NOT the
lint/test/type-check/check verbs it has no native tooling for."""
p = ProgramSpec.model_validate({"source": "/tmp/x", "stack": "hugo"})
actions = available_actions(p)
assert "build" in actions
assert "install" in actions and "uninstall" in actions
for absent in ("lint", "test", "type-check", "check"):
assert absent not in actions
def test_declared_command_still_overrides_hugo(self) -> None:
"""A hugo program can still declare a verb the stack doesn't provide —
declared commands are resolved regardless of the handler's `provides`."""
p = ProgramSpec.model_validate(
{
"source": "/tmp/x",
"stack": "hugo",
"commands": {"test": [["htmltest"]]},
}
)
actions = available_actions(p)
assert "test" in actions and "build" in actions
class TestResolution:
def test_stack_only_program_unchanged(self) -> None:
"""A program with a stack and no commands resolves all stack verbs."""
@@ -61,7 +86,11 @@ class TestResolution:
p = ProgramSpec.model_validate(
{
"source": "/tmp/y",
"commands": {"lint": [["make", "lint"]], "test": [["make", "test"]], "run": [["./bin/y"]]},
"commands": {
"lint": [["make", "lint"]],
"test": [["make", "test"]],
"run": [["./bin/y"]],
},
}
)
actions = available_actions(p)
@@ -79,15 +108,24 @@ class TestResolution:
def test_hybrid_override_one_verb(self) -> None:
"""A stack program can override a single verb; the rest fall back to stack."""
p = ProgramSpec.model_validate(
{"source": "/tmp/z", "stack": "python-cli", "commands": {"test": [["pytest", "-x"]]}}
{
"source": "/tmp/z",
"stack": "python-cli",
"commands": {"test": [["pytest", "-x"]]},
}
)
assert _declared_commands(p, "test") == [["pytest", "-x"]]
assert _declared_commands(p, "build") is None # build still comes from the stack
assert (
_declared_commands(p, "build") is None
) # build still comes from the stack
def test_build_declared_via_buildspec(self) -> None:
"""`build` is declared through BuildSpec.commands, not CommandsSpec."""
p = ProgramSpec.model_validate(
{"source": "/tmp/w", "build": {"commands": [["make"]], "outputs": ["dist/"]}}
{
"source": "/tmp/w",
"build": {"commands": [["make"]], "outputs": ["dist/"]},
}
)
assert _declared_commands(p, "build") == [["make"]]
assert "build" in available_actions(p)

View File

@@ -80,3 +80,43 @@ def test_public_static_frontend_gets_ingress() -> None:
hosts = {r["hostname"] for r in yaml.safe_load(generate_tunnel_config(reg))["ingress"]
if "hostname" in r}
assert hosts == {"guestbook.pub.payne.io"}
def test_public_host_override_used_as_ingress_hostname() -> None:
# An apex `public_host` overrides <sub>.<public_domain> for the public name,
# but the origin still bridges to the internal <sub>.<gateway_domain> host.
reg = _registry(deployed={
"payne-io": Deployment(manager="caddy", run_cmd=[], subdomain="payne-io",
static_root="/data/repos/payne-io/public", public=True,
public_host="payne.io"),
})
cfg = yaml.safe_load(generate_tunnel_config(reg))
rules = {r["hostname"]: r for r in cfg["ingress"] if "hostname" in r}
assert set(rules) == {"payne.io"}
assert rules["payne.io"]["originRequest"]["httpHostHeader"] == "payne-io.civil.payne.io"
assert public_hostnames(reg) == ["payne.io"]
def test_public_host_default_and_override_coexist() -> None:
reg = _registry(deployed={
"app": Deployment(manager="systemd", launcher="python", run_cmd=["x"], port=9001,
subdomain="app", public=True),
"payne-io": Deployment(manager="caddy", run_cmd=[], subdomain="payne-io",
static_root="/d/public", public=True, public_host="payne.io"),
})
assert set(public_hostnames(reg)) == {"app.pub.payne.io", "payne.io"}
def test_public_host_works_without_default_public_domain() -> None:
# A deployment with its own public_host publishes even if the node has no
# default public_domain; the plain public service (no override) is skipped.
reg = _registry(public_domain=None, deployed={
"app": Deployment(manager="systemd", launcher="python", run_cmd=["x"], port=9001,
subdomain="app", public=True),
"payne-io": Deployment(manager="caddy", run_cmd=[], subdomain="payne-io",
static_root="/d/public", public=True, public_host="payne.io"),
})
assert public_hostnames(reg) == ["payne.io"]
cfg = yaml.safe_load(generate_tunnel_config(reg))
hosts = {r["hostname"] for r in cfg["ingress"] if "hostname" in r}
assert hosts == {"payne.io"}