Compare commits

..

2 Commits

Author SHA1 Message Date
Paul Payne
afc623ec58 Add castle secret CLI and fail-loud unresolved-secret apply gate
Writing a secret by hand meant knowing the active backend and its layout;
getting it wrong (a file write on an OpenBao fleet) left the value where the
resolver never reads it, so ${secret:NAME} silently degraded to the literal
<MISSING_SECRET:NAME> placeholder that a service then used as its credential.

- `castle secret {list|set|get|rm}` reads/writes the ACTIVE backend, so there's
  no wrong store to pick. `set NAME` with no value reads a hidden prompt / stdin.
- `castle apply` (and --plan) now refuses to converge any deployment whose
  ${secret:...} refs don't resolve in the active backend: exits non-zero, writes
  nothing, and names each deployment + secret + the `castle secret set` fix.
- New helpers in core/config.py: active_secret_backend(), active_backend_name(),
  secret_refs(). Gate impl: deploy._unresolved_secrets() + ApplyResult.blocked.
- Tests: test_deploy_secret_gate.py, TestSecretRefs, test_secret.py. Docs: AGENTS.md.
2026-07-14 07:17:17 -07:00
Paul Payne
d152e79cee Add spa flag for static deployments; fix Hugo navigation
Static (manager: caddy) deployments always emitted the SPA fallback
`try_files {path} /index.html`, which serves the root index.html for
every unmatched path. That's correct for React/Vite apps but swallows a
multi-page content site's in-page links back to the homepage, so a Hugo
site (payne.io) couldn't navigate off its landing page.

Add an explicit `spa: bool` on CaddyDeployment (default True, preserving
existing SPA behavior). When False, the gateway serves plain file_server
— resolving directory indexes (/posts/ -> /posts/index.html) and 404ing
missing paths. Threaded through the registry snapshot + mesh and the
route computation so both the internal wildcard and public apex blocks
honor it. `castle program create --stack hugo` now sets spa: false.
2026-07-12 23:41:47 -07:00
14 changed files with 437 additions and 26 deletions

View File

@@ -75,6 +75,12 @@ castle job <list|info|delete|restart|logs> ...
castle tool list [--json] # each tool's executable + description + install state
castle tool info <name> [--json]
# Secrets — read/write the ACTIVE backend (file or openbao); never hand-write the store
castle secret list # names in the active backend
castle secret set <NAME> [VALUE] # VALUE omitted → hidden prompt / stdin
castle secret get <NAME> # print a value
castle secret rm <NAME> [-y] # delete
# Platform-wide
castle apply [name] [--plan] # converge runtime to config — the workhorse
castle list [--kind ...] [--stack ...] [--json] # all deployments
@@ -271,6 +277,14 @@ secrets:
via a least-privilege `castle-read` token. Only the bootstrap tier stays as
files (`OPENBAO_ROOT_TOKEN`/`OPENBAO_UNSEAL_KEY`, the `cloudflared` creds dir).
**Write secrets with `castle secret set NAME`, never by hand.** It targets the
*active* backend, so a value can't land in a store the resolver never reads — the
failure mode where a file-written secret is silently ignored on an openbao fleet
and `${secret:NAME}` degrades to a literal `<MISSING_SECRET:NAME>` that a service
then uses as its credential. `castle apply` **refuses to converge** (exits
non-zero, writes nothing) any deployment whose `${secret:…}` refs don't resolve in
the active backend, naming the fix.
**Never** put secret *values* in `castle.yaml` or project dirs — use `${secret:…}`.
Roots: **`CASTLE_HOME`** (config/code/artifacts/secrets, default `~/.castle`,
env-only — it *contains* castle.yaml) and **program data** (base of `${data_dir}`,

View File

@@ -35,6 +35,14 @@ def run_apply(args: argparse.Namespace) -> int:
result = apply(target_name=target, plan=plan)
# Unresolved secrets abort the run before any change — print the error block
# (which names each deployment, the missing ${secret:…}, and the fix) and exit
# non-zero so a broken credential never slips through as a bogus placeholder.
if result.blocked:
for msg in result.messages:
print(f" {_C['deactivate']}{msg}{_C['reset']}")
return 1
# Surface any warnings the render produced (acme prerequisites, tunnel notes).
for msg in result.messages:
if msg.startswith("Warning"):

View File

@@ -41,6 +41,11 @@ STACK_BUILD_OUTPUTS: dict[str, str] = {
"hugo": "public",
}
# Stacks whose static output is a multi-page content site (not a single-page app):
# the gateway resolves directory indexes and 404s missing paths instead of falling
# back to the root index.html. See CaddyDeployment.spa.
CONTENT_SITE_STACKS: set[str] = {"hugo"}
# Substrate a stack's apps depend on — seeded as a deployment `requires` at creation
# so the relationship graph shows it. This keeps `stack` uncoupled from the runtime
# model: the stack declares the edge once here; the graph only ever reads the encoded
@@ -142,6 +147,7 @@ def run_create(args: argparse.Namespace) -> int:
manager="caddy",
program=name,
root=static_root or "dist",
spa=stack not in CONTENT_SITE_STACKS,
description=description,
requires=seeded_requires,
)

View File

@@ -0,0 +1,93 @@
"""castle secret — read/write secrets in the *active* backend.
The active backend (file or openbao) is selected by the ``secrets:`` block of
castle.yaml (env overrides). Writing a secret by hand means knowing that choice
and the backend's storage layout — get it wrong and the value lands somewhere the
resolver never reads, so ``${secret:NAME}`` silently degrades to a
``<MISSING_SECRET:NAME>`` placeholder that a service then uses as if it were the
real credential (exactly how immich's DB password went to a file on an OpenBao
fleet). This command routes every read/write through
:func:`castle_core.config.active_secret_backend`, so there's no wrong store to
pick. ``castle apply`` refuses to converge a deployment whose secrets don't
resolve here.
"""
from __future__ import annotations
import argparse
import getpass
import sys
from castle_core.config import active_backend_name, active_secret_backend
def run_secret(args: argparse.Namespace) -> int:
sub = getattr(args, "secret_command", None)
if not sub:
print("Usage: castle secret {list|set|get|rm}")
return 1
if sub == "list":
return _list()
if sub == "set":
return _set(args.name, args.value)
if sub == "get":
return _get(args.name)
if sub == "rm":
return _rm(args.name, getattr(args, "yes", False))
return 1
def _list() -> int:
backend = active_backend_name()
names = active_secret_backend().list_names()
if not names:
print(f"No secrets in the active '{backend}' backend.")
return 0
print(f"Secrets in the active '{backend}' backend ({len(names)}):")
for n in names:
print(f" {n}")
return 0
def _set(name: str, value: str | None) -> int:
backend = active_backend_name()
if value is None:
# No value on the argv (keeps it out of shell history / ps). Read from a
# hidden prompt when interactive, else from stdin (pipe-friendly).
if sys.stdin.isatty():
value = getpass.getpass(f"Value for {name}: ")
else:
value = sys.stdin.read().strip()
if not value:
print("Error: empty value — refusing to set.", file=sys.stderr)
return 1
active_secret_backend().write(name, value)
print(f"Set '{name}' in the active '{backend}' backend.")
return 0
def _get(name: str) -> int:
value = active_secret_backend().read(name)
if value is None:
print(
f"Secret '{name}' not found in the active '{active_backend_name()}' backend.",
file=sys.stderr,
)
return 1
print(value)
return 0
def _rm(name: str, yes: bool) -> int:
backend = active_backend_name()
if active_secret_backend().read(name) is None:
print(f"Secret '{name}' not found in the active '{backend}' backend.", file=sys.stderr)
return 1
if not yes:
reply = input(f"Delete secret '{name}' from the '{backend}' backend? [y/N] ")
if reply.strip().lower() not in ("y", "yes"):
print("Aborted.")
return 1
active_secret_backend().delete(name)
print(f"Deleted '{name}' from the active '{backend}' backend.")
return 0

View File

@@ -212,6 +212,22 @@ def build_parser() -> argparse.ArgumentParser:
)
tls_sub.add_parser("status", help="Show each TLS service's cert fingerprint + expiry")
# Secrets — read/write the ACTIVE backend (file or openbao), so a value never
# gets hand-written to the wrong store (the mistake that silently shadows an
# OpenBao fleet's secret with a stray file the vault never reads).
sec = subparsers.add_parser("secret", help="Manage secrets in the active backend")
sec.set_defaults(resource="secret")
sec_sub = sec.add_subparsers(dest="secret_command")
sec_sub.add_parser("list", help="List secret names in the active backend")
p = sec_sub.add_parser("set", help="Set a secret (prompts if VALUE omitted)")
p.add_argument("name", help="Secret name")
p.add_argument("value", nargs="?", help="Value (omit to read from a hidden prompt / stdin)")
p = sec_sub.add_parser("get", help="Read a secret's value")
p.add_argument("name", help="Secret name")
p = sec_sub.add_parser("rm", help="Delete a secret from the active backend")
p.add_argument("name", help="Secret name")
p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
# Convergence — the one lifecycle verb. Renders units/Caddyfile/tunnel, then
# reconciles the runtime to match config (activate/restart/deactivate).
p = subparsers.add_parser(
@@ -380,6 +396,10 @@ def main() -> int:
from castle_cli.commands.tls import run_tls
return run_tls(args)
if cmd == "secret":
from castle_cli.commands.secret import run_secret
return run_secret(args)
if cmd == "apply":
from castle_cli.commands.apply import run_apply

56
cli/tests/test_secret.py Normal file
View File

@@ -0,0 +1,56 @@
"""Tests for the `castle secret` command (reads/writes the active backend)."""
from __future__ import annotations
from argparse import Namespace
from pathlib import Path
import pytest
@pytest.fixture
def file_secrets(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Force the active backend to a temp file store."""
secrets = tmp_path / "secrets"
secrets.mkdir()
monkeypatch.setenv("CASTLE_SECRET_BACKEND", "file")
monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets)
return secrets
def _run(**kw: object) -> int:
from castle_cli.commands.secret import run_secret
return run_secret(Namespace(**kw))
class TestSecretRoundtrip:
def test_set_get_list_rm(self, file_secrets: Path, capsys: object) -> None:
assert _run(secret_command="set", name="MY_KEY", value="s3cret") == 0
capsys.readouterr() # type: ignore[attr-defined] # drain the "Set …" line
assert _run(secret_command="get", name="MY_KEY") == 0
assert capsys.readouterr().out.strip() == "s3cret" # type: ignore[attr-defined]
assert _run(secret_command="list") == 0
assert "MY_KEY" in capsys.readouterr().out # type: ignore[attr-defined]
assert _run(secret_command="rm", name="MY_KEY", yes=True) == 0
# Gone → non-zero.
assert _run(secret_command="get", name="MY_KEY") == 1
class TestSecretEdges:
def test_get_missing_is_nonzero(self, file_secrets: Path) -> None:
assert _run(secret_command="get", name="ABSENT") == 1
def test_empty_value_refused(self, file_secrets: Path) -> None:
# Explicit empty string on argv is refused (not silently stored).
assert _run(secret_command="set", name="K", value="") == 1
def test_rm_missing_is_nonzero(self, file_secrets: Path) -> None:
assert _run(secret_command="rm", name="ABSENT", yes=True) == 1
def test_no_subcommand_usage(self, file_secrets: Path, capsys: object) -> None:
assert _run(secret_command=None) == 1
assert "Usage" in capsys.readouterr().out # type: ignore[attr-defined]

View File

@@ -6,10 +6,14 @@ import os
import re
from dataclasses import InitVar, dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
import yaml
from pydantic import BaseModel, TypeAdapter
if TYPE_CHECKING:
from castle_core.secret_backends import SecretBackend
from castle_core.manifest import (
AgentSpec,
DeploymentSpec,
@@ -328,15 +332,46 @@ def _secrets_settings() -> dict:
return {}
def active_secret_backend() -> SecretBackend:
"""The active secret backend (file or openbao), per ``secrets:`` + env.
The one place to get a handle for reading/writing/listing secrets, so callers
(the ``castle secret`` CLI, preflight checks) act on the *active* backend
rather than guessing the filesystem — the mistake that silently shadows an
OpenBao fleet's secret with a stray file.
"""
from castle_core.secret_backends import build_backend
return build_backend(SECRETS_DIR, _secrets_settings())
def active_backend_name() -> str:
"""Human name of the active backend (``openbao`` | ``file``) for messages."""
return (os.environ.get("CASTLE_SECRET_BACKEND") or _secrets_settings().get("backend") or "file").lower()
def read_secret(name: str) -> str | None:
"""Resolve a secret via the active backend, or None if unset.
The public helper for code that reads secrets directly (DNS/supabase/etc.) so
everything goes through the one backend rather than the filesystem.
"""
from castle_core.secret_backends import build_backend
return active_secret_backend().read(name)
return build_backend(SECRETS_DIR, _secrets_settings()).read(name)
# ``${secret:NAME}`` references embedded in an env value (the grammar
# :func:`resolve_env_split` resolves). Shared by the deploy-time preflight that
# refuses to converge a deployment whose secrets the active backend can't resolve.
_SECRET_REF_RE = re.compile(r"\$\{secret:([^}]+)\}")
def secret_refs(value: str) -> list[str]:
"""The secret names a value references via ``${secret:NAME}`` (order-preserving,
deduped)."""
seen: dict[str, None] = {}
for m in _SECRET_REF_RE.finditer(value):
seen.setdefault(m.group(1), None)
return list(seen)
def _read_secret(name: str) -> str:

View File

@@ -97,6 +97,11 @@ class ApplyResult:
# True for a `--plan` run: the diff was computed but nothing was written or
# activated. Lets callers render "would activate…" vs "activated…".
planned: bool = False
# Deployments refused because their ``${secret:NAME}`` references don't resolve
# in the active backend. A non-empty list means apply made NO changes and the
# caller should exit non-zero — better than starting a service with a bogus
# ``<MISSING_SECRET:…>`` value silently standing in for the real credential.
blocked: list[str] = field(default_factory=list)
@property
def changed(self) -> bool:
@@ -279,6 +284,7 @@ def apply(
"""
import asyncio
from castle_core.config import active_backend_name
from castle_core.lifecycle import activate, deactivate, is_active
config = load_config(root)
@@ -291,6 +297,28 @@ def apply(
]
names = [n for _k, n, _d in items]
# Fail loud on unresolved secrets BEFORE any render/write. Building a deployment
# writes its secret env file, so this gate runs first — a service is never
# rendered or (re)started with a bogus `<MISSING_SECRET:…>` value silently
# standing in for a real credential (the failure that shipped immich's DB
# password to the wrong backend). Aborts the whole run with a fix hint; nothing
# is written or reconciled.
blocked = _unresolved_secrets(items)
if blocked:
backend = active_backend_name()
result = ApplyResult(planned=plan)
result.blocked = [n for n, _ in blocked]
result.messages.append(
f"Error: unresolved secrets in the active '{backend}' backend — "
f"nothing was {'planned' if plan else 'applied'}."
)
for n, secrets in blocked:
for s in secrets:
result.messages.append(
f" {n}: ${{secret:{s}}} → not found. Fix: castle secret set {s}"
)
return result
# Snapshot BEFORE rendering: liveness + current unit bytes (for restart-on-change).
before_active = {(k, n): is_active(n, k, config) for k, n, _ in items}
before_unit = {(k, n): _unit_bytes(n, k) for k, n, _ in items}
@@ -401,6 +429,47 @@ def _stack_preflight(
)
def _unresolved_secrets(
items: Sequence[tuple[str, str, object]],
) -> list[tuple[str, list[str]]]:
"""Enabled deployments whose ``${secret:NAME}`` env references the active backend
can't resolve, as ``[(deployment, [missing_secret, ...]), ...]``.
This is the fail-loud gate for the footgun that shipped a service with a bogus
``<MISSING_SECRET:…>`` string standing in for a real credential: a secret
written to the *wrong* backend (a file on an OpenBao fleet) never resolves, yet
the old path substituted the marker and started the service anyway. Checked
against the active backend so it's correct whichever backend is configured.
"""
from castle_core.config import active_secret_backend, secret_refs
backend = active_secret_backend()
resolved: dict[str, bool] = {} # cache: one backend read per distinct secret
def _missing(name: str) -> bool:
if name not in resolved:
try:
resolved[name] = backend.read(name) is not None
except Exception:
resolved[name] = False
return not resolved[name]
out: list[tuple[str, list[str]]] = []
for _k, n, spec in items:
if not getattr(spec, "enabled", True):
continue
defaults = getattr(spec, "defaults", None)
env = dict(defaults.env) if defaults and defaults.env else {}
missing: list[str] = []
for value in env.values():
for ref in secret_refs(str(value)):
if _missing(ref) and ref not in missing:
missing.append(ref)
if missing:
out.append((n, missing))
return out
def _record(result: ApplyResult, name: str, action: str) -> None:
{
"activate": result.activated,
@@ -743,6 +812,7 @@ def _build_deployed(
public=bool(dep.public),
public_host=(dep.public_host if dep.public else None),
static_root=static_root,
spa=dep.spa,
managed=False,
enabled=dep.enabled,
requires=_registry_requires(dep),

View File

@@ -44,6 +44,8 @@ class GatewayRoute:
# Optional exact public-facing FQDN override (apex allowed). When set, the
# public name is this instead of the derived <address>.<public_domain>.
public_host: str | None = None
# static routes only: SPA fallback (True) vs content-site serving (False).
spa: bool = True
@property
def is_host(self) -> bool:
@@ -70,15 +72,15 @@ def service_proxy_targets(name: str, dep: SystemdDeployment) -> ProxyTargets:
def _local_routes(
config: CastleConfig | None, registry: NodeRegistry
) -> list[tuple[str, str, str, bool, str | None]]:
"""Each local deployment's route as ``(name, kind, target, public, public_host)``.
) -> list[tuple[str, str, str, bool, str | None, bool]]:
"""Each local deployment's route as ``(name, kind, target, public, public_host, spa)``.
``kind`` is ``static`` (a caddy deployment — file-serve a built dir) or
``proxy`` (a proxied systemd process). Prefers ``castle.yaml``
(``config.deployments``) so a regenerated Caddyfile reflects the current spec;
falls back to the deployed registry snapshot when config isn't available.
"""
out: list[tuple[str, str, str, bool, str | None]] = []
out: list[tuple[str, str, str, bool, str | None, bool]] = []
if config is not None:
# Only HTTP-exposed kinds route: static (file-serve) and service (proxy).
# jobs/tools/references never do.
@@ -91,21 +93,21 @@ def _local_routes(
src = _program_source(config, dep.program)
if src is not None:
pub_host = dep.public_host if dep.public else None
out.append((name, "static", str(src / dep.root), bool(dep.public), pub_host))
out.append((name, "static", str(src / dep.root), bool(dep.public), pub_host, dep.spa))
elif kind == "service" and isinstance(dep, SystemdDeployment):
expose, port, base_url = service_proxy_targets(name, dep)
if expose and (port or base_url):
pub_host = dep.public_host if dep.public else None
out.append((name, "proxy", base_url or f"localhost:{port}", bool(dep.public), pub_host))
out.append((name, "proxy", base_url or f"localhost:{port}", bool(dep.public), pub_host, True))
return out
# No config → route from the deployed registry snapshot.
for _kind, name, d in registry.all():
if not d.enabled:
continue
if d.static_root:
out.append((name, "static", d.static_root, d.public, d.public_host))
out.append((name, "static", d.static_root, d.public, d.public_host, d.spa))
elif d.subdomain and (d.port or d.base_url):
out.append((name, "proxy", d.base_url or f"localhost:{d.port}", d.public, d.public_host))
out.append((name, "proxy", d.base_url or f"localhost:{d.port}", d.public, d.public_host, True))
return out
@@ -146,8 +148,8 @@ def compute_routes(
# built dir; everything else that's exposed reverse-proxies its port/base_url.
# (Static frontends are `runner: static` services now — no separate program
# branch, so routing derives from one place.)
for name, kind, target, is_public, pub_host in _local_routes(config, registry):
routes.append(GatewayRoute(name, kind, target, name, node, is_public, pub_host))
for name, kind, target, is_public, pub_host, spa in _local_routes(config, registry):
routes.append(GatewayRoute(name, kind, target, name, node, is_public, pub_host, spa))
if remote_registries:
routes.extend(_remote_routes(config, registry, remote_registries))
@@ -229,21 +231,30 @@ def _host_remote_block(label: str, host: str, target: str) -> list[str]:
]
def _host_static_block(label: str, host: str, serve_dir: str) -> list[str]:
"""A host matcher that file-serves a frontend's dist (with SPA fallback)."""
def _static_serve_lines(serve_dir: str, spa: bool, indent: str) -> list[str]:
"""The `root`/`file_server` body for a static site. SPA sites get a fallback to
the root `index.html` (client-side routing); content sites (Hugo) get plain
`file_server`, which resolves directory indexes and 404s missing paths."""
lines = [f"{indent}root * {serve_dir}"]
if spa:
lines.append(indent + "try_files {path} /index.html")
lines.append(f"{indent}file_server")
return lines
def _host_static_block(label: str, host: str, serve_dir: str, spa: bool) -> list[str]:
"""A host matcher that file-serves a frontend's dist."""
matcher = f"@host_{label.replace('-', '_').replace('.', '_')}"
return [
f" {matcher} host {host}",
f" handle {matcher} {{",
f" root * {serve_dir}",
" try_files {path} /index.html",
" file_server",
*_static_serve_lines(serve_dir, spa, " "),
" }",
"",
]
def _public_site_block(host: str, kind: str, target: str) -> list[str]:
def _public_site_block(host: str, kind: str, target: str, spa: bool = True) -> list[str]:
"""A standalone Caddy site for a custom ``public_host`` (apex or another zone).
Unlike the ``*.<public_domain>`` wildcard site, an apex/foreign host isn't
@@ -251,11 +262,7 @@ def _public_site_block(host: str, kind: str, target: str) -> list[str]:
host's cert via the global ``acme_dns`` (DNS-01) — provided the gateway's
Cloudflare token can edit the host's zone."""
if kind == "static":
body = [
f" root * {target}",
" try_files {path} /index.html",
" file_server",
]
body = _static_serve_lines(target, spa, " ")
elif kind == "remote":
body = [
f" reverse_proxy {target} {{",
@@ -334,7 +341,7 @@ def generate_caddyfile_from_registry(
for r in routes:
host = f"{r.address}.{domain}"
if r.kind == "static":
lines += _host_static_block(r.name or r.address, host, r.target)
lines += _host_static_block(r.name or r.address, host, r.target, r.spa)
elif r.kind == "remote":
lines += _host_remote_block(r.name or r.address, host, r.target)
else:
@@ -359,7 +366,7 @@ def generate_caddyfile_from_registry(
host = f"{r.address}.{public_domain}"
label = f"{r.name or r.address}_pub"
if r.kind == "static":
lines += _host_static_block(label, host, r.target)
lines += _host_static_block(label, host, r.target, r.spa)
elif r.kind == "remote":
lines += _host_remote_block(label, host, r.target)
else:
@@ -368,7 +375,7 @@ def generate_caddyfile_from_registry(
lines.append("")
for r in custom_pub:
if r.public_host: # always true (custom_pub filter); narrows the type
lines += _public_site_block(r.public_host, r.kind, r.target)
lines += _public_site_block(r.public_host, r.kind, r.target, r.spa)
# Redirect the bare gateway port to the dashboard subdomain.
lines += [
f":{gw_port} {{",

View File

@@ -513,6 +513,13 @@ class CaddyDeployment(DeploymentBase):
manager: Literal["caddy"]
root: str = "dist"
# Serving strategy. `True` (default) → SPA fallback: any unmatched path serves
# the root `index.html` so a client-side router (React/Vite) takes over. `False`
# → content-site serving: the gateway resolves directory indexes (`/posts/` →
# `/posts/index.html`) and 404s genuinely-missing paths — correct for Hugo and
# other multi-page static sites, where the SPA fallback would swallow every
# in-site link back to the homepage.
spa: bool = True
# A static site is inherently served at its subdomain, so `reach` is
# `internal` or `public` (never `off`). `public` = also project via the tunnel.
reach: Reach = Reach.INTERNAL

View File

@@ -92,6 +92,9 @@ class Deployment:
# For `static` runner services: the absolute dir the gateway file_servers.
# Set → the route is `static` (file_server) rather than `proxy` (reverse_proxy).
static_root: str | None = None
# For `static` routes: SPA fallback (True, default) vs content-site serving
# (False, Hugo/multi-page). See CaddyDeployment.spa.
spa: bool = True
base_url: str | None = None
schedule: str | None = None
managed: bool = False
@@ -192,6 +195,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
public_host=comp_data.get("public_host"),
tcp_port=comp_data.get("tcp_port"),
static_root=comp_data.get("static_root"),
spa=comp_data.get("spa", True),
base_url=comp_data.get("base_url"),
schedule=comp_data.get("schedule"),
managed=comp_data.get("managed", False),
@@ -273,6 +277,10 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
entry["tcp_port"] = comp.tcp_port
if comp.static_root:
entry["static_root"] = comp.static_root
# Only emit when non-default (content-site) — default-True omission keeps
# existing registries byte-identical and matches the load-side default.
if not comp.spa:
entry["spa"] = comp.spa
if comp.base_url:
entry["base_url"] = comp.base_url
if comp.schedule:

View File

@@ -12,6 +12,7 @@ from castle_core.config import (
resolve_env_split,
resolve_env_vars,
save_config,
secret_refs,
)
from castle_core.manifest import ProgramSpec, SystemdDeployment
@@ -182,6 +183,22 @@ class TestResolveEnvVars:
assert resolved["API_KEY"] == "<MISSING_SECRET:NONEXISTENT>"
class TestSecretRefs:
"""Tests for extracting ${secret:NAME} references (the apply-gate input)."""
def test_none_when_no_refs(self) -> None:
assert secret_refs("plain") == []
assert secret_refs("${port}/${data_dir}") == []
def test_single_and_composite(self) -> None:
assert secret_refs("${secret:API_KEY}") == ["API_KEY"]
assert secret_refs("neo4j/${secret:NEO4J_PW}") == ["NEO4J_PW"]
def test_multiple_deduped_in_order(self) -> None:
value = "${secret:A}:${secret:B}:${secret:A}"
assert secret_refs(value) == ["A", "B"]
class TestResolveEnvSplit:
"""Tests for the secret/plain partition used to keep secrets out of units."""

View File

@@ -0,0 +1,65 @@
"""Tests for the apply-time unresolved-secret gate.
`castle apply` must refuse to converge a deployment whose ``${secret:NAME}`` env
references the active backend can't resolve — otherwise the value silently
degrades to a ``<MISSING_SECRET:NAME>`` placeholder and the service starts with a
bogus credential (the immich-DB-password-to-the-wrong-backend failure).
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import pytest
from castle_core.deploy import _unresolved_secrets
def _spec(env: dict[str, str], enabled: bool = True) -> SimpleNamespace:
return SimpleNamespace(enabled=enabled, defaults=SimpleNamespace(env=env))
@pytest.fixture
def file_backend(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Force the active backend to a temp file store with a known secret."""
secrets = tmp_path / "secrets"
secrets.mkdir()
(secrets / "PRESENT").write_text("value\n")
monkeypatch.setenv("CASTLE_SECRET_BACKEND", "file")
monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets)
return secrets
def test_all_resolved_returns_empty(file_backend: Path) -> None:
items = [("service", "svc", _spec({"TOKEN": "${secret:PRESENT}"}))]
assert _unresolved_secrets(items) == []
def test_missing_secret_is_reported(file_backend: Path) -> None:
items = [("service", "svc", _spec({"TOKEN": "${secret:ABSENT}"}))]
assert _unresolved_secrets(items) == [("svc", ["ABSENT"])]
def test_disabled_deployment_skipped(file_backend: Path) -> None:
items = [("service", "svc", _spec({"TOKEN": "${secret:ABSENT}"}, enabled=False))]
assert _unresolved_secrets(items) == []
def test_non_secret_placeholders_ignored(file_backend: Path) -> None:
items = [("service", "svc", _spec({"PORT": "${port}", "DIR": "${data_dir}"}))]
assert _unresolved_secrets(items) == []
def test_composite_value_partial_missing(file_backend: Path) -> None:
# A URL mixing a present and an absent secret still flags the absent one.
env = {"URL": "postgres://u:${secret:PRESENT}@h/db?k=${secret:ABSENT}"}
assert _unresolved_secrets([("service", "svc", _spec(env))]) == [("svc", ["ABSENT"])]
def test_multiple_deployments_only_broken_flagged(file_backend: Path) -> None:
items = [
("service", "ok", _spec({"T": "${secret:PRESENT}"})),
("service", "bad", _spec({"T": "${secret:ABSENT}"})),
]
assert _unresolved_secrets(items) == [("bad", ["ABSENT"])]

View File

@@ -13,7 +13,12 @@ How to build, serve, and manage [Hugo](https://gohugo.io) sites as castle progra
- Generator: Hugo (extended recommended — needed for SCSS/asset processing)
- Build: `hugo --gc --minify``public/`
- Served: `manager: caddy` static deployment, in place at `<name>.<gateway.domain>`
- Served: `manager: caddy` static deployment, in place at `<name>.<gateway.domain>`,
with `spa: false` — the gateway resolves directory indexes (`/posts/`
`/posts/index.html`) and 404s missing paths. (The default `spa: true` is a
single-page-app fallback that serves the root `index.html` for every unmatched
path — correct for React/Vite, but it swallows a Hugo site's in-page links back
to the homepage. `castle program create --stack hugo` sets `spa: false` for you.)
- Package manager (only if a theme needs an asset pipeline): pnpm
Hugo has one meaningful dev verb — **build**. It has no native lint/test/type-check,