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:
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user