fix(core): reload gateway on apply under acme+cloudflare

_reload_gateway validated the generated Caddyfile in castle-api's own
process env, which lacks CLOUDFLARE_API_TOKEN. Under acme the Caddyfile
references {env.CLOUDFLARE_API_TOKEN}, so `caddy validate` failed on an
empty token and the reload was silently skipped — every API-driven apply
left the running Caddy stale (route changes never took effect until a
manual reload). Validate with the gateway service's own resolved env so
the DNS-provider token is present.
This commit is contained in:
2026-07-06 13:12:15 -07:00
parent e438f45b54
commit a018a21587

View File

@@ -172,7 +172,7 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
# Reload the gateway so the freshly written Caddyfile takes effect. Without # Reload the gateway so the freshly written Caddyfile takes effect. Without
# this, new/changed proxy routes sit on disk but the running Caddy keeps the # this, new/changed proxy routes sit on disk but the running Caddy keeps the
# old config (a deployed service's route is silently dead until reload). # old config (a deployed service's route is silently dead until reload).
_reload_gateway(result.messages) _reload_gateway(config, result.messages)
result.registry = registry result.registry = registry
return result return result
@@ -418,7 +418,23 @@ def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
) )
def _reload_gateway(messages: list[str]) -> None: def _gateway_env(config: CastleConfig) -> dict[str, str]:
"""The process env for validating/handling the gateway's Caddyfile — the
current environment plus the castle-gateway service's own resolved env.
Under acme the Caddyfile references ``{env.CLOUDFLARE_API_TOKEN}`` (or another
DNS-provider token). `caddy validate` provisions the acme module and rejects an
empty token, so validating in castle-api's bare environment always fails and the
reload is skipped. Injecting the gateway service's env (secrets resolved) gives
validate the same token the running service starts with."""
svc = config.services.get(_GATEWAY_NAME)
raw = dict(svc.defaults.env) if (svc and svc.defaults and svc.defaults.env) else {}
plain, secret = resolve_env_split(raw, None)
resolved = {k: secret.get(k, plain.get(k, "")) for k in raw}
return {**os.environ, **resolved}
def _reload_gateway(config: CastleConfig, messages: list[str]) -> None:
"""Reload Caddy if the gateway is running, so new routes take effect.""" """Reload Caddy if the gateway is running, so new routes take effect."""
gw_unit = unit_name(_GATEWAY_NAME) gw_unit = unit_name(_GATEWAY_NAME)
# Validate the generated Caddyfile before reloading. An invalid config (most # Validate the generated Caddyfile before reloading. An invalid config (most
@@ -426,6 +442,8 @@ def _reload_gateway(messages: list[str]) -> None:
# plugin, so the `events {}` block fails to adapt) must not be pushed: a bad # plugin, so the `events {}` block fails to adapt) must not be pushed: a bad
# reload leaves stale routing and a later cold start would refuse to load. Skip # reload leaves stale routing and a later cold start would refuse to load. Skip
# the reload and point at the likely cause instead of silently degrading. # the reload and point at the likely cause instead of silently degrading.
# Validate with the gateway's own env so acme's DNS-provider token resolves —
# otherwise validation fails on an empty token and every reload is skipped.
caddyfile = SPECS_DIR / "Caddyfile" caddyfile = SPECS_DIR / "Caddyfile"
caddy = shutil.which("caddy") caddy = shutil.which("caddy")
if caddy and caddyfile.exists(): if caddy and caddyfile.exists():
@@ -433,6 +451,7 @@ def _reload_gateway(messages: list[str]) -> None:
[caddy, "validate", "--adapter", "caddyfile", "--config", str(caddyfile)], [caddy, "validate", "--adapter", "caddyfile", "--config", str(caddyfile)],
capture_output=True, capture_output=True,
text=True, text=True,
env=_gateway_env(config),
) )
if check.returncode != 0: if check.returncode != 0:
messages.append( messages.append(