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

@@ -0,0 +1,60 @@
"""castle apply — converge the running system to match config.
The one workhorse verb: renders systemd units + the Caddyfile + tunnel config,
then reconciles the runtime (activate what's enabled and down, restart what
changed, deactivate what's disabled). Replaces the old `deploy && start` plus the
per-kind enable/disable/install/uninstall verbs.
`--plan` computes and prints the diff without writing or touching the runtime.
"""
from __future__ import annotations
import argparse
_C = {
"activate": "\033[32m", # green
"restart": "\033[33m", # yellow
"deactivate": "\033[31m", # red
"reset": "\033[0m",
"dim": "\033[90m",
}
def _line(verb_color: str, verb: str, names: list[str]) -> None:
if not names:
return
print(f" {verb_color}{verb}{_C['reset']} {', '.join(sorted(names))}")
def run_apply(args: argparse.Namespace) -> int:
from castle_core.deploy import apply
target = getattr(args, "name", None)
plan = getattr(args, "plan", False)
result = apply(target_name=target, plan=plan)
# Surface any warnings the render produced (acme prerequisites, tunnel notes).
for msg in result.messages:
if msg.startswith("Warning"):
print(f" {_C['dim']}{msg}{_C['reset']}")
if plan:
print("\n\033[1mPlan\033[0m " + _C["dim"] + "(no changes made)" + _C["reset"])
if not result.changed:
print(f" {_C['dim']}nothing to do — already converged{_C['reset']}")
return 0
_line(_C["activate"], "would activate ", result.activated)
_line(_C["restart"], "would restart ", result.restarted)
_line(_C["deactivate"], "would deactivate", result.deactivated)
return 0
print("\n\033[1mApplied\033[0m")
if not result.changed:
print(f" {_C['dim']}nothing to do — already converged{_C['reset']}")
return 0
_line(_C["activate"], "activated ", result.activated)
_line(_C["restart"], "restarted ", result.restarted)
_line(_C["deactivate"], "deactivated", result.deactivated)
return 0

View File

@@ -158,12 +158,12 @@ def run_create(args: argparse.Namespace) -> int:
if stack == "supabase":
print(" # edit migrations/, functions/, public/ — targets the shared substrate")
print(f" castle program build {name} # apply migrations to the substrate")
print(f" castle deploy && castle gateway reload # serve at /{name}/")
print(f" castle apply # serve at {name}.<gateway.domain>")
elif stack:
print(" uv sync")
if kind == "service":
print(f" uv run {name} # starts on port {port}")
print(f" castle deploy {name}")
print(f" castle apply {name}")
print(f" castle test {name}")
else:
print(" # add code, then declare commands: in castle.yaml")

View File

@@ -116,9 +116,9 @@ def run_delete(args: argparse.Namespace) -> int:
try:
deploy()
print("Reconciled runtime (castle deploy).")
print("Reconciled runtime (castle apply).")
except Exception as e:
print(f"warning: reconcile (castle deploy) failed — run 'castle deploy': {e}")
print(f"warning: reconcile failed — run 'castle apply': {e}")
# Optional: delete the source directory.
if args.source and source_dir:

View File

@@ -1,21 +0,0 @@
"""castle deploy — thin CLI wrapper around castle_core.deploy."""
from __future__ import annotations
import argparse
from castle_core.deploy import deploy
def run_deploy(args: argparse.Namespace) -> int:
"""Deploy from castle.yaml to ~/.castle/."""
target_name = getattr(args, "name", None)
result = deploy(target_name=target_name)
for msg in result.messages:
print(f" {msg}")
print(f"\nDeployed {result.deployed_count} item(s).")
if result.deployed_count > 0:
print("Run 'castle start' to start all services.")
return 0

View File

@@ -88,7 +88,7 @@ def run_service_create(args: argparse.Namespace) -> int:
print(f" port: {args.port}")
if proxy:
print(f" subdomain: {name}.<gateway.domain>")
print(f"\nNext: castle service deploy {name} && castle service start {name}")
print(f"\nNext: castle apply {name}")
return 0
@@ -118,5 +118,5 @@ def run_job_create(args: argparse.Namespace) -> int:
print(f"Created job '{name}'.")
print(f" runs: {args.launcher} ({args.run or args.program or name})")
print(f" schedule: {args.schedule}")
print(f"\nNext: castle job deploy {name} && castle job enable {name}")
print(f"\nNext: castle apply {name}")
return 0

View File

@@ -6,7 +6,7 @@ of checks grouped into Environment, Configuration, Runtime, and TLS & exposure;
each check reports ok / warn / fail with a one-line hint when action is needed.
Exit code: 0 when nothing FAILed (warnings are allowed), 1 otherwise — so it
doubles as a scriptable smoke test after `./install.sh` or `castle deploy`.
doubles as a scriptable smoke test after `./install.sh` or `castle apply`.
"""
from __future__ import annotations
@@ -206,7 +206,7 @@ def _check_runtime(config) -> list[Check]:
Check(
FAIL,
"gateway not running",
hint="castle deploy && castle start",
hint="castle apply",
)
)
@@ -227,7 +227,7 @@ def _check_runtime(config) -> list[Check]:
checks.append(Check(OK, "castle-api running", detail=detail))
else:
checks.append(
Check(FAIL, "castle-api not running", hint="castle deploy && castle start")
Check(FAIL, "castle-api not running", hint="castle apply")
)
# Generated artifacts.
@@ -244,7 +244,7 @@ def _check_runtime(config) -> list[Check]:
FAIL,
"generated specs missing",
detail=", ".join(missing),
hint="castle deploy",
hint="castle apply",
)
)
@@ -347,7 +347,7 @@ def _check_tls_exposure(config) -> list[Check]:
WARN,
"castle-tunnel not running",
detail="public routes are down",
hint="castle service start castle-tunnel",
hint="enable castle-tunnel in its deployment, then: castle apply",
)
)
checks.append(_check_public_dns(config))
@@ -364,7 +364,7 @@ def _check_public_dns(config) -> Check:
records. The required permission is a single DNS:Edit (Cloudflare's 'Edit zone
DNS' template), which also grants the zone lookup — so a correctly-scoped token
passes this probe. Write itself isn't exercised (that would mutate); a
DNS:Read-only token would false-pass here but `castle deploy` then surfaces a
DNS:Read-only token would false-pass here but `castle apply` then surfaces a
403 with the fix. Absent token → WARN (CNAMEs stay manual), not a failure.
"""
import urllib.error

View File

@@ -20,7 +20,7 @@ def _write_generated_files() -> None:
ensure_dirs()
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
print("Error: no registry found. Run 'castle apply' first.")
return
registry = load_registry()
@@ -56,7 +56,7 @@ def run_gateway(args: argparse.Namespace) -> int:
def _gateway_dry_run() -> int:
"""Print generated Caddyfile without applying."""
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
print("Error: no registry found. Run 'castle apply' first.")
return 1
registry = load_registry()
@@ -126,7 +126,7 @@ def _gateway_status() -> int:
print(f"Gateway: {'running' if status == 'active' else status}")
if not REGISTRY_PATH.exists():
print(" (no registry — run 'castle deploy')")
print(" (no registry — run 'castle apply')")
return 0
from castle_core.generators.caddyfile import compute_routes

View File

@@ -147,7 +147,7 @@ def run_info(args: argparse.Namespace) -> int:
if deployed.secret_env_keys:
print(f" {BOLD}secrets{RESET}: {', '.join(deployed.secret_env_keys)}")
else:
print(f"\n {DIM}not deployed (run 'castle deploy'){RESET}")
print(f"\n {DIM}not applied (run 'castle apply'){RESET}")
# Show CLAUDE.md if it exists
source_dir = None

View File

@@ -82,12 +82,12 @@ def run_run(args: argparse.Namespace) -> int:
# Service: deployed command from the registry.
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
print("Error: no registry found. Run 'castle apply' first.")
return 1
registry = load_registry()
if name not in registry.deployed:
print(f"Error: '{name}' is not a deployed service. Run 'castle deploy' first.")
print(f"Error: '{name}' is not a deployed service. Run 'castle apply' first.")
return 1
deployed = registry.deployed[name]

View File

@@ -7,13 +7,10 @@ import subprocess
from castle_core.generators.systemd import (
SYSTEMD_USER_DIR,
generate_timer,
generate_unit_from_deployed,
timer_name,
unit_name,
)
from castle_core.manifest import kind_for
from castle_core.registry import REGISTRY_PATH, load_registry
from castle_cli.config import (
CastleConfig,
@@ -45,44 +42,35 @@ _PAST = {"start": "started", "stop": "stopped", "restart": "restarted"}
def run_service_cmd(args: argparse.Namespace) -> int:
"""`castle service <enable|disable|start|stop|restart> <name>`."""
sub = args.service_command
"""`castle service restart <name>` — the imperative bounce (only verb left).
Lifecycle (deploy/enable/disable/start/stop) is now convergence: `castle apply`.
"""
config = load_config()
if sub == "enable":
if getattr(args, "dry_run", False):
return _service_dry_run(config, args.name)
return _service_enable(config, args.name)
if sub == "disable":
return _service_disable(config, args.name)
if sub in ("start", "stop", "restart"):
return _unit_action(config, args.name, sub, is_job=False)
return 1
return _unit_action(config, args.name, "restart", is_job=False)
def run_job_cmd(args: argparse.Namespace) -> int:
"""`castle job <enable|disable|start|stop|restart> <name>` (acts on the timer)."""
sub = args.job_command
"""`castle job restart <name>` — bounce the job's timer."""
config = load_config()
if sub == "enable":
return _service_enable(config, args.name) # enable_service handles timers
if sub == "disable":
return _service_disable(config, args.name)
if sub in ("start", "stop", "restart"):
return _unit_action(config, args.name, sub, is_job=True)
return 1
return _unit_action(config, args.name, "restart", is_job=True)
def run_platform(args: argparse.Namespace) -> int:
"""Top-level `castle start|stop|restart` — the whole platform."""
def run_restart(args: argparse.Namespace) -> int:
"""Top-level `castle restart [name]` — bounce one deployment, or all of them.
An imperative op: it re-actualizes current desired state, it does not change it
(that's `castle apply`).
"""
config = load_config()
action = args.command
if action == "start":
return _services_start(config)
if action == "stop":
return _services_stop(config)
if action == "restart":
name = getattr(args, "name", None)
if not name:
return _services_restart(config)
return 1
dep = config.deployments.get(name)
if dep is None:
print(f"Error: no deployment '{name}'.")
return 1
return _unit_action(config, name, "restart", is_job=(kind_for(dep) == "job"))
_GATEWAY_NAME = "castle-gateway"
@@ -256,78 +244,3 @@ def _service_status(config: CastleConfig) -> int:
print()
return 0
def _service_dry_run(config: CastleConfig, name: str) -> int:
"""Print the generated systemd unit(s) without installing."""
if REGISTRY_PATH.exists():
registry = load_registry()
if name in registry.deployed:
deployed = registry.deployed[name]
systemd_spec = None
dep = config.deployments.get(name)
manage = getattr(dep, "manage", None)
if manage and manage.systemd:
systemd_spec = manage.systemd
svc_unit = unit_name(name)
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
print(f"# {svc_unit}")
print(svc_content)
if deployed.schedule:
timer_content = generate_timer(
name,
schedule=deployed.schedule,
description=deployed.description,
)
print(f"# {timer_name(name)}")
print(timer_content)
return 0
print(f"Error: '{name}' not found in registry. Run 'castle deploy' first.")
return 1
def _services_start(config: CastleConfig) -> int:
"""Start all managed services and gateway."""
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
return 1
ensure_dirs()
from castle_core.config import SPECS_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
registry = load_registry()
caddyfile_path = SPECS_DIR / "Caddyfile"
caddyfile_path.write_text(generate_caddyfile_from_registry(registry))
print(f"Generated {caddyfile_path}")
# Activate every deployment in its mode: systemd unit / timer, gateway route
# (static), or PATH install (tool). activate() dispatches by manager.
for name in config.deployments:
if name not in registry.deployed:
print(f" {name}: skipped (not in registry, run 'castle deploy')")
continue
_service_enable(config, name)
print(f"\nDashboard: http://localhost:{config.gateway.port}")
return 0
def _services_stop(config: CastleConfig) -> int:
"""Stop all managed services and jobs."""
for name in config.jobs:
tmr_unit = timer_name(name)
subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False)
svc_unit = unit_name(name)
subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False)
print(f" {name}: stopped")
for name in config.services:
svc_unit = unit_name(name)
subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False)
print(f" {name}: stopped")
return 0

View File

@@ -137,7 +137,7 @@ def run_tool_info(args: argparse.Namespace) -> int:
if comp.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(comp.system_dependencies)}")
if not installed:
print(f"\n {DIM}install with: castle tool install {name}{RESET}")
print(f"\n {DIM}enable it in its deployment, then: castle apply{RESET}")
else:
print(f"\n {DIM}run `{exes[0]} --help` for arguments{RESET}")
print()

View File

@@ -70,13 +70,6 @@ def _build_program_group(subparsers: argparse._SubParsersAction) -> None:
_add_name(p, "Program name")
p.add_argument("extra", nargs=argparse.REMAINDER, help="Extra args passed to the program")
sub.add_parser("install", help="Activate a program (tool→PATH, static→served)").add_argument(
"name", nargs="?", help="Program (default: all)"
)
sub.add_parser("uninstall", help="Deactivate a program").add_argument(
"name", nargs="?", help="Program"
)
for verb in DEV_VERBS:
p = sub.add_parser(verb, help=f"Run {verb}")
_add_name(p, "Program (default: all)", optional=True)
@@ -95,11 +88,6 @@ def _build_tool_group(subparsers: argparse._SubParsersAction) -> None:
_add_name(p, "Tool name")
p.add_argument("--json", action="store_true", help="Machine-readable output")
sub.add_parser("install", help="Install a tool on PATH").add_argument("name", help="Tool name")
sub.add_parser("uninstall", help="Remove a tool from PATH").add_argument(
"name", help="Tool name"
)
def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None:
p = sub.add_parser("create", help=f"Create a {kind} in castle.yaml")
@@ -148,16 +136,9 @@ def _build_deployment_group(subparsers: argparse._SubParsersAction, kind: str) -
p.add_argument("--purge-data", action="store_true", help=argparse.SUPPRESS)
cap = f"{kind.capitalize()} name"
_add_name(sub.add_parser("deploy", help=f"Deploy this {kind} (unit + gateway)"), cap)
p = sub.add_parser("enable", help=f"Enable and start the {kind}")
_add_name(p, cap)
if kind == "service":
p.add_argument("--dry-run", action="store_true", help="Print the unit without installing")
_add_name(sub.add_parser("disable", help=f"Stop and disable the {kind}"), cap)
_add_name(sub.add_parser("start", help=f"Start the {kind}"), cap)
_add_name(sub.add_parser("stop", help=f"Stop the {kind}"), cap)
_add_name(sub.add_parser("restart", help=f"Restart the {kind}"), cap)
# Lifecycle is convergence: `castle apply [name]`. `restart` stays as the one
# imperative bounce that doesn't change desired state.
_add_name(sub.add_parser("restart", help=f"Restart the {kind} (imperative bounce)"), cap)
p = sub.add_parser("logs", help=f"View {kind} logs")
_add_name(p, f"{kind.capitalize()} name")
@@ -188,16 +169,23 @@ def build_parser() -> argparse.ArgumentParser:
p.add_argument("--dry-run", action="store_true")
gw_sub.add_parser("status", help="Show gateway status")
# Platform-wide lifecycle (top-level)
subparsers.add_parser("start", help="Start all services and the gateway")
subparsers.add_parser("stop", help="Stop all services and the gateway")
subparsers.add_parser("restart", help="Restart all services and jobs")
# Convergence — the one lifecycle verb. Renders units/Caddyfile/tunnel, then
# reconciles the runtime to match config (activate/restart/deactivate).
p = subparsers.add_parser(
"apply", help="Converge the running system to match config (render + reconcile)"
)
p.add_argument("name", nargs="?", help="Single deployment to converge (default: all)")
p.add_argument(
"--plan", action="store_true", help="Show the diff without changing anything"
)
# Imperative ops (don't change desired state)
p = subparsers.add_parser("restart", help="Restart deployment(s) — an imperative bounce")
p.add_argument("name", nargs="?", help="Deployment to restart (default: all)")
subparsers.add_parser("status", help="Show status across the platform")
subparsers.add_parser(
"doctor", help="Diagnose setup + runtime health, with next-step hints"
)
p = subparsers.add_parser("deploy", help="Apply config to runtime (units + Caddyfile)")
p.add_argument("name", nargs="?", help="Service/job to deploy (default: all)")
# Cross-resource overview
p = subparsers.add_parser("list", help="List programs, services, jobs, and tools")
@@ -215,7 +203,7 @@ def build_parser() -> argparse.ArgumentParser:
def _dispatch_program(args: argparse.Namespace) -> int:
sub = args.program_command
if not sub:
verbs = "list|info|create|add|clone|delete|run|install|uninstall|" + "|".join(DEV_VERBS)
verbs = "list|info|create|add|clone|delete|run|" + "|".join(DEV_VERBS)
print(f"Usage: castle program {{{verbs}}}")
return 1
if sub == "list":
@@ -246,14 +234,6 @@ def _dispatch_program(args: argparse.Namespace) -> int:
from castle_cli.commands.run_cmd import run_run
return run_run(args)
if sub == "install":
from castle_cli.commands.dev import run_install
return run_install(args)
if sub == "uninstall":
from castle_cli.commands.dev import run_uninstall
return run_uninstall(args)
if sub in DEV_VERBS:
from castle_cli.commands.dev import run_verb
@@ -264,7 +244,7 @@ def _dispatch_program(args: argparse.Namespace) -> int:
def _dispatch_tool(args: argparse.Namespace) -> int:
sub = args.tool_command
if not sub:
print("Usage: castle tool {list|info|install|uninstall}")
print("Usage: castle tool {list|info} (install/uninstall → edit config + castle apply)")
return 1
if sub == "list":
from castle_cli.commands.tool import run_tool_list
@@ -274,21 +254,13 @@ def _dispatch_tool(args: argparse.Namespace) -> int:
from castle_cli.commands.tool import run_tool_info
return run_tool_info(args)
if sub == "install":
from castle_cli.commands.dev import run_install
return run_install(args)
if sub == "uninstall":
from castle_cli.commands.dev import run_uninstall
return run_uninstall(args)
return 1
def _dispatch_deployment(args: argparse.Namespace, kind: str) -> int:
sub = getattr(args, f"{kind}_command")
if not sub:
verbs = "list|info|create|delete|deploy|enable|disable|start|stop|restart|logs"
verbs = "list|info|create|delete|restart|logs (deploy/enable/... → castle apply)"
print(f"Usage: castle {kind} {{{verbs}}}")
return 1
if sub == "list":
@@ -307,11 +279,7 @@ def _dispatch_deployment(args: argparse.Namespace, kind: str) -> int:
from castle_cli.commands.delete import run_delete
return run_delete(args)
if sub == "deploy":
from castle_cli.commands.deploy import run_deploy
return run_deploy(args)
if sub in ("enable", "disable", "start", "stop", "restart"):
if sub == "restart":
from castle_cli.commands.service import run_job_cmd, run_service_cmd
return run_service_cmd(args) if kind == "service" else run_job_cmd(args)
@@ -341,10 +309,14 @@ def main() -> int:
from castle_cli.commands.gateway import run_gateway
return run_gateway(args)
if cmd in ("start", "stop", "restart"):
from castle_cli.commands.service import run_platform
if cmd == "apply":
from castle_cli.commands.apply import run_apply
return run_platform(args)
return run_apply(args)
if cmd == "restart":
from castle_cli.commands.service import run_restart
return run_restart(args)
if cmd == "status":
from castle_cli.commands.service import run_status
@@ -353,10 +325,6 @@ def main() -> int:
from castle_cli.commands.doctor import run_doctor
return run_doctor(args)
if cmd == "deploy":
from castle_cli.commands.deploy import run_deploy
return run_deploy(args)
if cmd == "list":
from castle_cli.commands.list_cmd import run_list

View File

@@ -26,7 +26,7 @@ class TestDoctor:
assert "repo: not set" in out
assert "control plane missing" in out
# Every failing check offers a concrete next command.
assert "castle deploy" in out
assert "castle apply" in out
def test_load_failure_is_first_fail(self, capsys: object) -> None:
"""A castle.yaml that won't load is surfaced as a FAIL, not a traceback."""