core+cli: castle apply — one converge verb replaces deploy/start/enable/…

Reconcile the running system to declared config in a single operation,
replacing the old deploy && start plus the per-kind enable/disable/
install/uninstall verbs. The mechanism varies by manager (systemd unit /
PATH install / gateway route); the verb never does.

Core (castle_core.deploy.apply):
- render (units, Caddyfile, tunnel) then reconcile: activate every
  enabled deployment that's down, restart any whose unit bytes changed,
  deactivate the disabled ones. Returns ApplyResult (the enacted diff).
- --plan computes the diff and touches nothing.
- restart-on-change is exact: _render_unit_files is the single source of
  truth for unit bytes, used both to write units and to predict restarts,
  so the plan can't drift from what deploy writes.

Desired state:
- DeploymentBase.enabled (default True) — the declarative on/off. apply
  converges to it; a disabled deployment is defined but not running and
  gets no gateway route (else it would 502). Registry omits enabled when
  True, keeping existing registries byte-identical.

CLI:
- add `castle apply [name] [--plan]`; keep `restart` as the one
  imperative bounce, plus status/doctor/logs/gateway/program.
- retire deploy, start, stop (top-level); service/job deploy|enable|
  disable|start|stop; tool install|uninstall; program install|uninstall.
- next-step strings + doctor hints now say `castle apply`.

Verified: core 129 + cli 31 green; live `castle apply` on a converged
node reports "nothing to do" with no spurious restarts; --plan shows an
empty diff; gateway + api stay up.
This commit is contained in:
2026-07-02 11:35:34 -07:00
parent 9028d15ec6
commit d0a206f7d6
18 changed files with 406 additions and 240 deletions

View File

@@ -70,6 +70,30 @@ class DeployResult:
registry: NodeRegistry | None = None
@dataclass
class ApplyResult:
"""Result of a converge (`castle apply`): what actually changed.
`deploy` renders config → artifacts; `apply` renders *and then* reconciles the
running system to match, so the interesting output is the diff it enacted.
"""
activated: list[str] = field(default_factory=list)
restarted: list[str] = field(default_factory=list)
deactivated: list[str] = field(default_factory=list)
unchanged: list[str] = field(default_factory=list)
pruned: list[str] = field(default_factory=list)
messages: list[str] = field(default_factory=list)
registry: NodeRegistry | None = None
# 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
@property
def changed(self) -> bool:
return bool(self.activated or self.restarted or self.deactivated or self.pruned)
def deploy(target_name: str | None = None, root: Path | None = None) -> DeployResult:
"""Deploy from castle.yaml to ~/.castle/.
@@ -86,16 +110,7 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
ensure_dirs()
# Build node config
node = NodeConfig(
castle_root=str(config.root),
gateway_port=config.gateway.port,
gateway_tls=config.gateway.tls,
gateway_domain=config.gateway.domain,
acme_email=config.gateway.acme_email,
acme_dns_provider=config.gateway.acme_dns_provider,
public_domain=config.gateway.public_domain,
tunnel_id=config.gateway.tunnel_id,
)
node = _node_config(config)
# Load existing registry to preserve entries not being redeployed,
# or start fresh if deploying all
@@ -158,6 +173,131 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
return result
def _node_config(config: CastleConfig) -> NodeConfig:
"""The registry NodeConfig derived from a config's gateway settings."""
return NodeConfig(
castle_root=str(config.root),
gateway_port=config.gateway.port,
gateway_tls=config.gateway.tls,
gateway_domain=config.gateway.domain,
acme_email=config.gateway.acme_email,
acme_dns_provider=config.gateway.acme_dns_provider,
public_domain=config.gateway.public_domain,
tunnel_id=config.gateway.tunnel_id,
)
def _unit_file_for(name: str, is_job: bool) -> Path:
"""On-disk systemd unit path for a deployment (timer if it's a job)."""
return SYSTEMD_USER_DIR / (timer_name(name) if is_job else unit_name(name))
def _unit_bytes(name: str, is_job: bool) -> str | None:
"""Current unit-file contents, or None if it isn't written yet."""
path = _unit_file_for(name, is_job)
return path.read_text() if path.exists() else None
def apply(
target_name: str | None = None,
root: Path | None = None,
plan: bool = False,
) -> ApplyResult:
"""Converge the running system to match config — the one honest bring-up.
`apply` = `deploy` (render units/Caddyfile/tunnel) **plus** reconcile: activate
every enabled deployment that isn't live, restart any whose unit changed,
deactivate the disabled ones. It replaces the old two-step ``deploy && start``
and the per-kind enable/disable/install verbs — the mechanism varies by manager
(systemd unit / PATH install / gateway route), the verb never does.
`plan=True` computes and returns the diff **without writing or touching the
runtime** (the ``--plan`` dry run).
"""
import asyncio
from castle_core.lifecycle import activate, deactivate, is_active
config = load_config(root)
names = [n for n in config.deployments if not target_name or n == target_name]
is_job = {n: (n in config.jobs) for n in names}
# Snapshot BEFORE rendering: liveness + current unit bytes (for restart-on-change).
before_active = {n: is_active(n, config) for n in names}
before_unit = {n: _unit_bytes(n, is_job[n]) for n in names}
# Desired state, rendered in memory to classify each deployment. For a real run
# this is recomputed by deploy() below (which also writes it); cheap and keeps
# the plan/apply classification identical.
desired = {n: _build_deployed(config, n, config.deployments[n], []) for n in names}
def _classify(name: str, after_unit: str | None) -> str:
dep = desired[name]
if not dep.enabled:
return "deactivate" if before_active[name] else "unchanged"
if not before_active[name]:
return "activate"
if dep.manager == "systemd" and before_unit[name] != after_unit:
return "restart"
return "unchanged"
result = ApplyResult(registry=NodeRegistry(node=_node_config(config), deployed=desired))
if plan:
# No writes: for systemd, predict the new unit bytes by rendering to a string
# so "would restart" is accurate; other managers never restart.
result.planned = True
for name in names:
after = _render_unit_preview(config, name, desired[name], is_job[name])
_record(result, name, _classify(name, after))
return result
# Real run: render everything (writes units/Caddyfile/tunnel, daemon-reload,
# gateway reload, orphan prune), then reconcile the runtime.
deploy_result = deploy(target_name, root)
result.messages = list(deploy_result.messages)
result.registry = deploy_result.registry
for name in names:
after_unit = _unit_bytes(name, is_job[name])
action = _classify(name, after_unit)
if action == "activate":
asyncio.run(activate(name, config, config.root))
result.activated.append(name)
elif action == "deactivate":
asyncio.run(deactivate(name, config, config.root))
result.deactivated.append(name)
elif action == "restart":
unit = timer_name(name) if is_job[name] else unit_name(name)
subprocess.run(["systemctl", "--user", "restart", unit], check=False)
result.restarted.append(name)
else:
result.unchanged.append(name)
return result
def _record(result: ApplyResult, name: str, action: str) -> None:
{
"activate": result.activated,
"deactivate": result.deactivated,
"restart": result.restarted,
"unchanged": result.unchanged,
}[action].append(name)
def _render_unit_preview(
config: CastleConfig, name: str, dep: Deployment, is_job: bool
) -> str | None:
"""The unit bytes `deploy` would write for the deployment we'd restart (the
.timer for a job, the .service for a service), for --plan restart detection.
None when there's no unit to compare (non-systemd, or unmanaged)."""
files = _render_unit_files(config, name, dep)
if not files:
return None
return files.get(timer_name(name) if is_job else unit_name(name))
# Gateway service name in the registry → its systemd unit (castle-castle-gateway).
_GATEWAY_NAME = "castle-gateway"
@@ -387,6 +527,7 @@ def _build_deployed(
public=bool(dep.public),
static_root=static_root,
managed=False,
enabled=dep.enabled,
)
if isinstance(dep, PathDeployment):
return Deployment(
@@ -396,6 +537,7 @@ def _build_deployed(
kind=kind,
stack=stack,
managed=False,
enabled=dep.enabled,
)
if isinstance(dep, RemoteDeployment):
return Deployment(
@@ -406,6 +548,7 @@ def _build_deployed(
stack=stack,
base_url=dep.base_url,
managed=False,
enabled=dep.enabled,
)
# systemd: a supervised process (a service, or a job when scheduled).
@@ -465,6 +608,7 @@ def _build_deployed(
public=bool(dep.public and expose),
schedule=getattr(dep, "schedule", None),
managed=managed,
enabled=dep.enabled,
)
@@ -711,31 +855,40 @@ def _prune_orphans(registry: NodeRegistry, messages: list[str]) -> None:
_teardown_unit(path.name, messages)
def _render_unit_files(
config: CastleConfig, name: str, deployed: Deployment
) -> dict[str, str]:
"""The exact unit files `deploy` would write for a deployment: {filename: content}.
Empty for a non-systemd-managed deployment (caddy/path/none have no unit). The
single source of truth for unit bytes — used both to write units and to predict
restart-on-change in `apply`/`--plan`, so the prediction can never drift from
what actually gets written.
"""
if not deployed.managed:
return {}
systemd_spec = None
dep = config.deployments.get(name)
manage = getattr(dep, "manage", None)
if manage and manage.systemd:
systemd_spec = manage.systemd
files = {
unit_name(name): generate_unit_from_deployed(
name, deployed, systemd_spec, env_file=unit_env_file(deployed, name)
)
}
if deployed.schedule:
files[timer_name(name)] = generate_timer(
name, schedule=deployed.schedule, description=deployed.description
)
return files
def _generate_systemd_units(config: CastleConfig, registry: NodeRegistry) -> None:
"""Generate systemd units from the registry."""
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
for name, deployed in registry.deployed.items():
if not deployed.managed:
continue
systemd_spec = None
dep = config.deployments.get(name)
manage = getattr(dep, "manage", None)
if manage and manage.systemd:
systemd_spec = manage.systemd
svc_name = unit_name(name)
svc_content = generate_unit_from_deployed(
name, deployed, systemd_spec, env_file=unit_env_file(deployed, name)
)
(SYSTEMD_USER_DIR / svc_name).write_text(svc_content)
if deployed.schedule:
timer_content = generate_timer(
name,
schedule=deployed.schedule,
description=deployed.description,
)
tmr_name = timer_name(name)
(SYSTEMD_USER_DIR / tmr_name).write_text(timer_content)
for fname, content in _render_unit_files(config, name, deployed).items():
(SYSTEMD_USER_DIR / fname).write_text(content)

View File

@@ -77,6 +77,10 @@ def _local_routes(
deployments = getattr(config, "deployments", None)
if deployments is not None:
for name, dep in sorted(deployments.items()):
# A disabled deployment is defined but not running — no route (else it
# would 502). `castle apply` converges it off.
if not dep.enabled:
continue
if isinstance(dep, CaddyDeployment):
src = _program_source(config, dep.program)
if src is not None:
@@ -88,6 +92,8 @@ def _local_routes(
return out
# No config → route from the deployed registry snapshot.
for name, d in sorted(registry.deployed.items()):
if not d.enabled:
continue
if d.static_root:
out.append((name, "static", d.static_root))
elif d.subdomain and (d.port or d.base_url):

View File

@@ -311,6 +311,11 @@ class DeploymentBase(BaseModel):
)
description: str | None = None
defaults: DefaultsSpec | None = None
# Declared on/off state. `castle apply` converges reality to this: enabled
# deployments are activated (service started, tool installed, route served),
# disabled ones are deactivated but kept in the catalog. This is *desired
# state*, not a runtime toggle — the only way to durably stop something.
enabled: bool = True
class SystemdDeployment(DeploymentBase):

View File

@@ -72,6 +72,9 @@ class Deployment:
base_url: str | None = None
schedule: str | None = None
managed: bool = False
# Declared desired state (from the deployment's `enabled:`). `castle apply`
# activates enabled deployments and deactivates disabled ones. Default True.
enabled: bool = True
@dataclass
@@ -155,6 +158,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
base_url=comp_data.get("base_url"),
schedule=comp_data.get("schedule"),
managed=comp_data.get("managed", False),
enabled=comp_data.get("enabled", True),
)
return NodeRegistry(node=node, deployed=deployed)
@@ -225,6 +229,10 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
entry["schedule"] = comp.schedule
if comp.managed:
entry["managed"] = comp.managed
# Only emit when disabled — default-True omission keeps existing
# registries byte-identical and matches the load-side default.
if not comp.enabled:
entry["enabled"] = comp.enabled
data["deployed"][name] = entry
with open(path, "w") as f: