From d0a206f7d68684e453c7a37d79fe752d6d0edfd7 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Thu, 2 Jul 2026 11:35:34 -0700 Subject: [PATCH 1/4] =?UTF-8?q?core+cli:=20castle=20apply=20=E2=80=94=20on?= =?UTF-8?q?e=20converge=20verb=20replaces=20deploy/start/enable/=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cli/src/castle_cli/commands/apply.py | 60 +++++ cli/src/castle_cli/commands/create.py | 4 +- cli/src/castle_cli/commands/delete.py | 4 +- cli/src/castle_cli/commands/deploy.py | 21 -- cli/src/castle_cli/commands/deploy_create.py | 4 +- cli/src/castle_cli/commands/doctor.py | 12 +- cli/src/castle_cli/commands/gateway.py | 6 +- cli/src/castle_cli/commands/info.py | 2 +- cli/src/castle_cli/commands/run_cmd.py | 4 +- cli/src/castle_cli/commands/service.py | 127 ++--------- cli/src/castle_cli/commands/tool.py | 2 +- cli/src/castle_cli/main.py | 86 +++----- cli/tests/test_doctor.py | 2 +- core/src/castle_core/deploy.py | 219 ++++++++++++++++--- core/src/castle_core/generators/caddyfile.py | 6 + core/src/castle_core/manifest.py | 5 + core/src/castle_core/registry.py | 8 + core/tests/test_apply.py | 74 +++++++ 18 files changed, 406 insertions(+), 240 deletions(-) create mode 100644 cli/src/castle_cli/commands/apply.py delete mode 100644 cli/src/castle_cli/commands/deploy.py create mode 100644 core/tests/test_apply.py diff --git a/cli/src/castle_cli/commands/apply.py b/cli/src/castle_cli/commands/apply.py new file mode 100644 index 0000000..db1b939 --- /dev/null +++ b/cli/src/castle_cli/commands/apply.py @@ -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 diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index 83e3b69..ff3af6a 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -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}.") 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") diff --git a/cli/src/castle_cli/commands/delete.py b/cli/src/castle_cli/commands/delete.py index 75fd244..9ec1041 100644 --- a/cli/src/castle_cli/commands/delete.py +++ b/cli/src/castle_cli/commands/delete.py @@ -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: diff --git a/cli/src/castle_cli/commands/deploy.py b/cli/src/castle_cli/commands/deploy.py deleted file mode 100644 index 2b905ff..0000000 --- a/cli/src/castle_cli/commands/deploy.py +++ /dev/null @@ -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 diff --git a/cli/src/castle_cli/commands/deploy_create.py b/cli/src/castle_cli/commands/deploy_create.py index 0d17f74..3da1369 100644 --- a/cli/src/castle_cli/commands/deploy_create.py +++ b/cli/src/castle_cli/commands/deploy_create.py @@ -88,7 +88,7 @@ def run_service_create(args: argparse.Namespace) -> int: print(f" port: {args.port}") if proxy: print(f" subdomain: {name}.") - 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 diff --git a/cli/src/castle_cli/commands/doctor.py b/cli/src/castle_cli/commands/doctor.py index 7beb49e..cb9f097 100644 --- a/cli/src/castle_cli/commands/doctor.py +++ b/cli/src/castle_cli/commands/doctor.py @@ -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 diff --git a/cli/src/castle_cli/commands/gateway.py b/cli/src/castle_cli/commands/gateway.py index c950ea0..bb6ea13 100644 --- a/cli/src/castle_cli/commands/gateway.py +++ b/cli/src/castle_cli/commands/gateway.py @@ -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 diff --git a/cli/src/castle_cli/commands/info.py b/cli/src/castle_cli/commands/info.py index f3355ca..633e016 100644 --- a/cli/src/castle_cli/commands/info.py +++ b/cli/src/castle_cli/commands/info.py @@ -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 diff --git a/cli/src/castle_cli/commands/run_cmd.py b/cli/src/castle_cli/commands/run_cmd.py index 988dd9e..1fdb9bd 100644 --- a/cli/src/castle_cli/commands/run_cmd.py +++ b/cli/src/castle_cli/commands/run_cmd.py @@ -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] diff --git a/cli/src/castle_cli/commands/service.py b/cli/src/castle_cli/commands/service.py index c60d81a..fe85287 100644 --- a/cli/src/castle_cli/commands/service.py +++ b/cli/src/castle_cli/commands/service.py @@ -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 `.""" - sub = args.service_command + """`castle service restart ` — 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 ` (acts on the timer).""" - sub = args.job_command + """`castle job restart ` — 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 diff --git a/cli/src/castle_cli/commands/tool.py b/cli/src/castle_cli/commands/tool.py index 4a11b57..3a3cefd 100644 --- a/cli/src/castle_cli/commands/tool.py +++ b/cli/src/castle_cli/commands/tool.py @@ -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() diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py index 0f53a8a..f3dc587 100644 --- a/cli/src/castle_cli/main.py +++ b/cli/src/castle_cli/main.py @@ -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 diff --git a/cli/tests/test_doctor.py b/cli/tests/test_doctor.py index ecc1142..b7a9099 100644 --- a/cli/tests/test_doctor.py +++ b/cli/tests/test_doctor.py @@ -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.""" diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index 18ffbed..b36e84e 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -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) diff --git a/core/src/castle_core/generators/caddyfile.py b/core/src/castle_core/generators/caddyfile.py index f2abf9a..48aadf1 100644 --- a/core/src/castle_core/generators/caddyfile.py +++ b/core/src/castle_core/generators/caddyfile.py @@ -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): diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index e92c8fd..87543c2 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -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): diff --git a/core/src/castle_core/registry.py b/core/src/castle_core/registry.py index 589e7d8..1a1e758 100644 --- a/core/src/castle_core/registry.py +++ b/core/src/castle_core/registry.py @@ -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: diff --git a/core/tests/test_apply.py b/core/tests/test_apply.py new file mode 100644 index 0000000..03c199c --- /dev/null +++ b/core/tests/test_apply.py @@ -0,0 +1,74 @@ +"""Tests for `castle apply` convergence — the diff classification (plan mode). + +Plan mode computes the activate/restart/deactivate/unchanged buckets without +writing or touching the runtime, so it's the deterministic way to test the diff. +`is_active` is patched to control the "before" state; unit bytes come from the +(empty) temp home, so a live systemd service with no prior unit reads as changed. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from castle_core.deploy import _render_unit_preview, apply +from castle_core.registry import Deployment + + +def _plan(castle_root: Path, active: dict[str, bool]): + """Run apply(plan=True) with is_active stubbed to `active` (default False).""" + with patch("castle_core.lifecycle.is_active", side_effect=lambda n, c: active.get(n, False)): + return apply(root=castle_root, plan=True) + + +class TestApplyPlan: + def test_fresh_converge_activates_enabled(self, castle_root: Path) -> None: + """Nothing running → every enabled deployment is 'activate'; no writes.""" + result = _plan(castle_root, active={}) + + assert result.planned is True + assert set(result.activated) == {"test-svc", "test-tool", "test-job"} + assert result.deactivated == [] + assert result.restarted == [] + + def test_disabled_active_deployment_deactivates(self, castle_root: Path) -> None: + """A deployment with enabled:false that's currently up → 'deactivate'.""" + # Turn the tool off in config. + tool = castle_root / "services" / "test-tool.yaml" + tool.write_text(tool.read_text() + "enabled: false\n") + + result = _plan(castle_root, active={"test-tool": True}) + + assert "test-tool" in result.deactivated + assert "test-tool" not in result.activated + + def test_disabled_inactive_is_unchanged(self, castle_root: Path) -> None: + """enabled:false and already down → nothing to do.""" + tool = castle_root / "services" / "test-tool.yaml" + tool.write_text(tool.read_text() + "enabled: false\n") + + result = _plan(castle_root, active={}) + + assert "test-tool" in result.unchanged + assert "test-tool" not in result.deactivated + + def test_active_service_with_changed_unit_restarts(self, castle_root: Path) -> None: + """An up systemd service whose rendered unit differs from disk → 'restart'. + + The temp home has no prior unit file (before-bytes = None), so any live + systemd deployment classifies as changed → restart, not a silent no-op. + """ + result = _plan(castle_root, active={"test-svc": True}) + + assert "test-svc" in result.restarted + assert "test-svc" not in result.activated + assert result.changed is True + + +def test_render_unit_preview_none_for_non_systemd() -> None: + """Non-systemd managers have no unit file — preview is None (never 'restart'). + + A path deployment is unmanaged, so the renderer returns before touching config. + """ + tool = Deployment(manager="path", run_cmd=[], kind="tool") + assert _render_unit_preview(None, "x", tool, is_job=False) is None # type: ignore[arg-type] From 5d15d18e1dcd3ef122e041c9edb4e7fe64915202 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Thu, 2 Jul 2026 11:41:00 -0700 Subject: [PATCH 2/4] api: POST /apply converge endpoint; enabled in summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace POST /deploy with POST /apply (name + plan), returning the enacted diff (activated/restarted/deactivated/unchanged). /config/apply now delegates to core apply() too, so there's one convergence path. - Retire /services/{name}/start and /stop (keep /restart as the imperative bounce). Drop install/uninstall from program actions — tool/ static activation is convergence now. - Surface `enabled` on ServiceSummary/JobSummary/DeploymentSummary so the UI can show and toggle desired state. - conftest: drop the obsolete config_editor.get_registry patch (apply_config no longer reads the registry directly). 59 API tests pass; route table shows /apply (no /deploy), /restart only. --- castle-api/src/castle_api/config_editor.py | 55 +++++++++----------- castle-api/src/castle_api/deploy_routes.py | 58 ++++++++++++++-------- castle-api/src/castle_api/models.py | 3 ++ castle-api/src/castle_api/programs.py | 4 +- castle-api/src/castle_api/routes.py | 8 ++- castle-api/src/castle_api/services.py | 19 +++---- castle-api/tests/conftest.py | 2 - castle-api/tests/test_health.py | 7 +++ 8 files changed, 86 insertions(+), 70 deletions(-) diff --git a/castle-api/src/castle_api/config_editor.py b/castle-api/src/castle_api/config_editor.py index 9fda783..e672ba1 100644 --- a/castle-api/src/castle_api/config_editor.py +++ b/castle-api/src/castle_api/config_editor.py @@ -21,7 +21,7 @@ from castle_core.config import ( ) from castle_core.manifest import ProgramSpec, kind_for -from castle_api.config import get_castle_root, get_config, get_registry +from castle_api.config import get_castle_root, get_config from castle_api.stream import broadcast router = APIRouter(prefix="/config", tags=["config"]) @@ -347,42 +347,33 @@ def delete_job(name: str) -> dict: @router.post("/apply", response_model=ApplyResponse) async def apply_config() -> ApplyResponse: - """Apply config: rebuild runtime from castle.yaml, then restart services. - - Runs a full ``deploy`` so the registry, systemd units, and Caddyfile are all - regenerated from the current castle.yaml (and the gateway reloaded) — this is - what keeps the running config from drifting behind an edit. Then restarts the - managed services so the freshly written units take effect (``deploy`` only - daemon-reloads; a running unit keeps its old ExecStart until restarted). - Scheduled jobs are left alone — applying config shouldn't fire every job. + """Converge the running system to match castle.yaml (a thin wrapper on core + ``apply``). Renders units/Caddyfile/tunnel, then reconciles the runtime — + activating what's enabled and down, restarting only what changed, deactivating + the disabled. Kept as ``/config/apply`` for compatibility; ``/apply`` exposes + the same converge with per-deployment targeting and ``--plan``. """ - from castle_core.deploy import deploy + from castle_core.deploy import apply + + # apply is blocking (systemctl + gateway reload) — run off the event loop. + try: + result = await asyncio.to_thread(apply) + except Exception as e: + return ApplyResponse(ok=False, actions=[], errors=[f"Apply failed: {e}"]) actions: list[str] = [] - errors: list[str] = [] - - # Rebuild registry + units + Caddyfile from castle.yaml off the event loop - # (deploy is blocking: it shells out to systemctl and the gateway). - try: - result = await asyncio.to_thread(deploy) - except Exception as e: - return ApplyResponse(ok=False, actions=actions, errors=[f"Deploy failed: {e}"]) - actions.extend(result.messages) - - # Restart managed services so the new units take effect (skip scheduled jobs). - registry = result.registry or get_registry() - for name, deployed in registry.deployed.items(): - if not deployed.managed or deployed.schedule: - continue - unit = f"castle-{name}.service" - ok, output = await _systemctl("restart", unit) - if ok: - actions.append(f"Restarted {name}") - else: - errors.append(f"Failed to restart {name}: {output}") + for verb, names in ( + ("Activated", result.activated), + ("Restarted", result.restarted), + ("Deactivated", result.deactivated), + ): + if names: + actions.append(f"{verb} {', '.join(sorted(names))}") + if not result.changed: + actions.append("Already converged — nothing to do") await broadcast("config-changed", {"actions": actions}) - return ApplyResponse(ok=len(errors) == 0, actions=actions, errors=errors) + return ApplyResponse(ok=True, actions=actions, errors=[]) async def _systemctl(action: str, unit: str) -> tuple[bool, str]: diff --git a/castle-api/src/castle_api/deploy_routes.py b/castle-api/src/castle_api/deploy_routes.py index 5c44814..2d47901 100644 --- a/castle-api/src/castle_api/deploy_routes.py +++ b/castle-api/src/castle_api/deploy_routes.py @@ -1,49 +1,67 @@ -"""Deploy API endpoint.""" +"""Apply (converge) API endpoint. + +`POST /apply` reconciles the running system to match config — render units/ +Caddyfile/tunnel, then activate/restart/deactivate to match. It replaces the old +`/deploy` (+ separate start/enable calls). `?plan=true` returns the diff without +touching anything. +""" from __future__ import annotations from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from castle_core.deploy import deploy +from castle_core.deploy import apply -router = APIRouter(tags=["deploy"]) +router = APIRouter(tags=["apply"]) -class DeployRequest(BaseModel): - """Optional request body for deploy.""" +class ApplyRequest(BaseModel): + """Optional request body for apply.""" name: str | None = None + plan: bool = False -class DeployResponse(BaseModel): - """Response from a deploy operation.""" +class ApplyResponse(BaseModel): + """The diff a converge enacted (or would enact, for a plan).""" status: str - deployed_count: int + planned: bool + changed: bool + activated: list[str] + restarted: list[str] + deactivated: list[str] + unchanged: list[str] messages: list[str] -@router.post("/deploy", response_model=DeployResponse) -def run_deploy(request: DeployRequest | None = None) -> DeployResponse: - """Deploy services and jobs from castle.yaml to runtime. +@router.post("/apply", response_model=ApplyResponse) +def run_apply(request: ApplyRequest | None = None) -> ApplyResponse: + """Converge the running system to match castle.yaml. - Resolves env vars and secrets, generates systemd units and Caddyfile, - copies frontend build outputs, and reloads systemd. - - Optionally pass a name to deploy a single service or job. + Renders systemd units + the Caddyfile + tunnel config, then reconciles the + runtime: activate enabled deployments that are down, restart any whose unit + changed, deactivate disabled ones. Pass a name to converge one deployment, + or `plan: true` to compute the diff without changing anything. """ - target_name = request.name if request else None + name = request.name if request else None + plan = request.plan if request else False try: - result = deploy(target_name=target_name) + result = apply(target_name=name, plan=plan) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) from e except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e - return DeployResponse( + return ApplyResponse( status="ok", - deployed_count=result.deployed_count, + planned=result.planned, + changed=result.changed, + activated=result.activated, + restarted=result.restarted, + deactivated=result.deactivated, + unchanged=result.unchanged, messages=result.messages, - ) \ No newline at end of file + ) diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py index 24485b7..d2459c3 100644 --- a/castle-api/src/castle_api/models.py +++ b/castle-api/src/castle_api/models.py @@ -35,6 +35,7 @@ class DeploymentSummary(BaseModel): schedule: str | None = None installed: bool | None = None active: bool | None = None # uniform lifecycle state (on PATH / running / served) + enabled: bool = True # declared desired state; `apply` converges to it node: str | None = None @@ -65,6 +66,7 @@ class ServiceSummary(BaseModel): systemd: SystemdInfo | None = None program: str | None = None # the program this deployment references, if any source: str | None = None + enabled: bool = True # declared desired state; `apply` converges to it node: str | None = None @@ -87,6 +89,7 @@ class JobSummary(BaseModel): systemd: SystemdInfo | None = None program: str | None = None # the program this deployment references, if any source: str | None = None + enabled: bool = True # declared desired state; `apply` converges to it node: str | None = None diff --git a/castle-api/src/castle_api/programs.py b/castle-api/src/castle_api/programs.py index ccad9df..77abadd 100644 --- a/castle-api/src/castle_api/programs.py +++ b/castle-api/src/castle_api/programs.py @@ -21,14 +21,14 @@ def list_stacks() -> list[str]: # Unified program action endpoint # --------------------------------------------------------------------------- +# Dev verbs only. Activation (install/uninstall of tools/statics) is convergence: +# it happens through `POST /apply`, not as a program action. _VALID_ACTIONS = { "build", "test", "lint", "type-check", "check", - "install", - "uninstall", } diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index a3bd836..7aefc2f 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import shutil import subprocess from pathlib import Path @@ -104,6 +103,7 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary: schedule=deployed.schedule, installed=installed, active=active, + enabled=deployed.enabled, ) @@ -152,6 +152,7 @@ def _summary_from_service( managed=managed, systemd=systemd_info, source=source, + enabled=svc.enabled, ) @@ -187,6 +188,7 @@ def _summary_from_job(name: str, job: SystemdDeployment, config: object) -> Depl systemd=systemd_info, schedule=job.schedule, source=source, + enabled=job.enabled, ) @@ -276,6 +278,7 @@ def _service_from_deployed(name: str, deployed: object) -> ServiceSummary: subdomain=deployed.subdomain, managed=deployed.managed, systemd=systemd_info, + enabled=deployed.enabled, ) @@ -317,6 +320,7 @@ def _service_from_spec(name: str, svc: SystemdDeployment, config: object) -> Ser systemd=systemd_info, program=svc.program, source=source, + enabled=svc.enabled, ) @@ -333,6 +337,7 @@ def _job_from_deployed(name: str, deployed: object) -> JobSummary: schedule=deployed.schedule, managed=deployed.managed, systemd=systemd_info, + enabled=deployed.enabled, ) @@ -362,6 +367,7 @@ def _job_from_spec(name: str, job: SystemdDeployment, config: object) -> JobSumm systemd=systemd_info, program=job.program, source=source, + enabled=job.enabled, ) diff --git a/castle-api/src/castle_api/services.py b/castle-api/src/castle_api/services.py index f77d2d7..57b2a33 100644 --- a/castle-api/src/castle_api/services.py +++ b/castle-api/src/castle_api/services.py @@ -153,19 +153,12 @@ def get_unit(name: str) -> dict[str, str | None]: return {"service": unit, "timer": timer} -@router.post("/{name}/start") -async def start_service(name: str) -> JSONResponse: - """Start a systemd-managed service.""" - return await _do_action(name, "start") - - -@router.post("/{name}/stop") -async def stop_service(name: str) -> JSONResponse: - """Stop a systemd-managed service.""" - return await _do_action(name, "stop") - - @router.post("/{name}/restart") async def restart_service(name: str) -> JSONResponse: - """Restart a systemd-managed service.""" + """Restart a systemd-managed service — the imperative bounce. + + Lifecycle (start/stop/enable/disable) is convergence now: set `enabled` in the + deployment config and POST /apply. Restart stays as a way to re-actualize the + current desired state without changing it. + """ return await _do_action(name, "restart") diff --git a/castle-api/tests/conftest.py b/castle-api/tests/conftest.py index 907ca96..a5a99a1 100644 --- a/castle-api/tests/conftest.py +++ b/castle-api/tests/conftest.py @@ -164,7 +164,6 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No "services.get_castle_root": services_mod.get_castle_root, "nodes.get_registry": nodes_mod.get_registry, "stream.get_registry": stream_mod.get_registry, - "config_editor.get_registry": config_editor_mod.get_registry, "config_editor.get_castle_root": config_editor_mod.get_castle_root, } @@ -192,7 +191,6 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No services_mod.get_castle_root = originals["services.get_castle_root"] nodes_mod.get_registry = originals["nodes.get_registry"] stream_mod.get_registry = originals["stream.get_registry"] - config_editor_mod.get_registry = originals["config_editor.get_registry"] config_editor_mod.get_castle_root = originals["config_editor.get_castle_root"] diff --git a/castle-api/tests/test_health.py b/castle-api/tests/test_health.py index 0cea79c..2af7c57 100644 --- a/castle-api/tests/test_health.py +++ b/castle-api/tests/test_health.py @@ -99,6 +99,13 @@ class TestServicesList: svc = next(s for s in data if s["id"] == "test-svc") assert "schedule" not in svc + def test_enabled_flows_through(self, client: TestClient) -> None: + """ServiceSummary surfaces the declared `enabled` state (default True).""" + response = client.get("/services") + data = response.json() + svc = next(s for s in data if s["id"] == "test-svc") + assert svc["enabled"] is True + def test_no_installed_field(self, client: TestClient) -> None: """ServiceSummary does not have installed field.""" response = client.get("/services") From f422c1879d92cf7a2bd7434e28910abab6b36d08 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Thu, 2 Jul 2026 11:49:54 -0700 Subject: [PATCH 3/4] =?UTF-8?q?ui:=20Apply=20everywhere=20=E2=80=94=20powe?= =?UTF-8?q?r=20toggles,=20plan=20preview,=20no=20start/stop/install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the dashboard around convergence: - ServiceControls / ServiceCard / JobCard: replace start/restart/stop with a Power toggle (set enabled → apply, i.e. activate/deactivate) plus Restart (the one imperative bounce). - ToolDetail: install/uninstall becomes an Enable/Disable toggle; `installed` stays the live state, `enabled` the desired one. - ConfigPanel Apply and CreateDeploymentForm now POST /apply (one converge that renders + restarts only what changed) instead of /deploy + a separate start/restart. - New ConvergePanel on Overview: a terraform-style Preview (POST /apply plan=true) showing would-activate/restart/deactivate, then Apply. - useApply + useSetEnabled hooks; `enabled` added to Service/Job/ Deployment types; both handle the self-restart connection drop. Backend: PUT /config/deployments/{name}/enabled sets desired on/off (edit config; caller runs apply) — the declarative toggle the UI uses. Dashboard builds clean (tsc + vite); 59 API tests pass; live /apply and /services?enabled verified after an api restart. --- app/src/components/ConvergePanel.tsx | 104 ++++++++++++++++++ app/src/components/JobCard.tsx | 49 ++++----- app/src/components/ServiceCard.tsx | 50 ++++----- app/src/components/detail/ConfigPanel.tsx | 25 +++-- .../detail/CreateDeploymentForm.tsx | 9 +- app/src/components/detail/ServiceControls.tsx | 52 ++++----- app/src/pages/Overview.tsx | 3 + app/src/pages/ScheduledDetail.tsx | 6 +- app/src/pages/ServiceDetail.tsx | 2 +- app/src/pages/ToolDetail.tsx | 28 +++-- app/src/services/api/hooks.ts | 59 ++++++++++ app/src/types/index.ts | 3 + castle-api/src/castle_api/config_editor.py | 23 ++++ 13 files changed, 292 insertions(+), 121 deletions(-) create mode 100644 app/src/components/ConvergePanel.tsx diff --git a/app/src/components/ConvergePanel.tsx b/app/src/components/ConvergePanel.tsx new file mode 100644 index 0000000..639df38 --- /dev/null +++ b/app/src/components/ConvergePanel.tsx @@ -0,0 +1,104 @@ +import { useState } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { Check, GitCompare, Loader2, Play } from "lucide-react" +import { apiClient } from "@/services/api/client" +import type { ApplyResult } from "@/services/api/hooks" + +// A terraform-style "plan then apply" for the whole node: preview the diff the +// converge would enact (activate/restart/deactivate), then apply it. +export function ConvergePanel() { + const qc = useQueryClient() + const [plan, setPlan] = useState(null) + const [busy, setBusy] = useState<"plan" | "apply" | null>(null) + const [msg, setMsg] = useState(null) + + const preview = async () => { + setBusy("plan") + setMsg(null) + try { + setPlan(await apiClient.post("/apply", { plan: true })) + } finally { + setBusy(null) + } + } + + const apply = async () => { + setBusy("apply") + setMsg(null) + try { + await apiClient.post("/apply", {}) + setPlan(null) + setMsg("Applied — the node is converged.") + qc.invalidateQueries() + } catch (e) { + // A self-apply restarts castle-api, dropping the connection — expected. + if (e instanceof TypeError) { + setPlan(null) + setMsg("Applied — services are restarting.") + } else { + setMsg(`Apply failed: ${e instanceof Error ? e.message : String(e)}`) + } + } finally { + setBusy(null) + } + } + + const row = (label: string, names: string[], color: string) => + names.length > 0 && ( +
+ {label} + {names.join(", ")} +
+ ) + + return ( +
+
+
+ + Convergence +
+
+ + {plan?.changed && ( + + )} +
+
+ + {msg && ( +
+ {msg} +
+ )} + + {plan && ( +
+ {plan.changed ? ( + <> + {row("activate", plan.activated, "text-green-400")} + {row("restart", plan.restarted, "text-blue-400")} + {row("deactivate", plan.deactivated, "text-red-400")} + + ) : ( +
In sync — nothing to converge.
+ )} +
+ )} +
+ ) +} diff --git a/app/src/components/JobCard.tsx b/app/src/components/JobCard.tsx index b0f23e5..2110444 100644 --- a/app/src/components/JobCard.tsx +++ b/app/src/components/JobCard.tsx @@ -1,7 +1,7 @@ -import { Clock, Play, RefreshCw, Square, Terminal } from "lucide-react" +import { Clock, Power, RefreshCw, Terminal } from "lucide-react" import { Link } from "react-router-dom" import type { JobSummary, HealthStatus } from "@/types" -import { useServiceAction } from "@/services/api/hooks" +import { useServiceAction, useSetEnabled } from "@/services/api/hooks" import { launcherLabel } from "@/lib/labels" import { StackBadge } from "./StackBadge" @@ -11,12 +11,9 @@ interface JobCardProps { } export function JobCard({ job, health }: JobCardProps) { - const { mutate, isPending } = useServiceAction() - const isDown = health?.status === "down" - - const doAction = (action: string) => { - mutate({ name: job.id, action }) - } + const restart = useServiceAction() + const setEnabled = useSetEnabled() + const busy = restart.isPending || setEnabled.isPending return (
@@ -60,32 +57,26 @@ export function JobCard({ job, health }: JobCardProps) { {job.managed && (
- {isDown && ( - - )} - {!isDown && ( + {job.enabled && ( )}
diff --git a/app/src/components/ServiceCard.tsx b/app/src/components/ServiceCard.tsx index d81605b..f1c1a9b 100644 --- a/app/src/components/ServiceCard.tsx +++ b/app/src/components/ServiceCard.tsx @@ -1,7 +1,7 @@ -import { ExternalLink, Play, RefreshCw, Server, Square, Terminal } from "lucide-react" +import { ExternalLink, Power, RefreshCw, Server, Terminal } from "lucide-react" import { Link } from "react-router-dom" import type { ServiceSummary, HealthStatus } from "@/types" -import { useServiceAction } from "@/services/api/hooks" +import { useServiceAction, useSetEnabled } from "@/services/api/hooks" import { launcherLabel, subdomainUrl } from "@/lib/labels" import { HealthBadge } from "./HealthBadge" import { StackBadge } from "./StackBadge" @@ -14,13 +14,9 @@ interface ServiceCardProps { export function ServiceCard({ service, health }: ServiceCardProps) { const hasHttp = service.port != null - const { mutate, isPending } = useServiceAction() - - const doAction = (action: string) => { - mutate({ name: service.id, action }) - } - - const isDown = health?.status === "down" + const restart = useServiceAction() + const setEnabled = useSetEnabled() + const busy = restart.isPending || setEnabled.isPending return (
@@ -82,32 +78,26 @@ export function ServiceCard({ service, health }: ServiceCardProps) { {service.managed && (
- {isDown && ( - - )} - {!isDown && ( + {service.enabled && ( )}
diff --git a/app/src/components/detail/ConfigPanel.tsx b/app/src/components/detail/ConfigPanel.tsx index 26ea2a2..e455899 100644 --- a/app/src/components/detail/ConfigPanel.tsx +++ b/app/src/components/detail/ConfigPanel.tsx @@ -42,7 +42,7 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane setMessage({ type: "ok", text: isDeployment - ? "Saved to castle.yaml — not live yet; apply to deploy & restart." + ? "Saved to castle.yaml — not live yet; apply to converge." : "Saved to castle.yaml", }) if (isDeployment) setPendingApply(true) @@ -58,16 +58,21 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane setApplying(true) setMessage(null) try { - await apiClient.post(`/deploy`, { name: deployment.id }) - if (configSection === "services") { - await apiClient.post(`/services/${deployment.id}/restart`, {}) - } + // One converge: renders units/routes and reconciles the runtime (restarts + // only what changed). No separate restart call needed. + await apiClient.post(`/apply`, { name: deployment.id }) setPendingApply(false) setMessage({ type: "ok", text: "Applied — the change is now live." }) - qc.invalidateQueries({ queryKey: ["status"] }) - qc.invalidateQueries({ queryKey: [configSection] }) + qc.invalidateQueries() onRefetch() } catch (e: unknown) { + // A self-apply of castle-api restarts it, killing the connection — expected. + if (e instanceof TypeError) { + setPendingApply(false) + setMessage({ type: "ok", text: "Applied — the service is restarting." }) + setApplying(false) + return + } let msg = e instanceof Error ? e.message : String(e) try { msg = JSON.parse((e as Error).message).detail ?? msg @@ -122,11 +127,7 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane className="shrink-0 flex items-center gap-1.5 px-3 py-1 text-xs rounded bg-amber-700 hover:bg-amber-600 text-white transition-colors disabled:opacity-50" > - {applying - ? "Applying…" - : configSection === "services" - ? "Apply (deploy & restart)" - : "Apply (deploy)"} + {applying ? "Applying…" : "Apply"} )}
diff --git a/app/src/components/detail/CreateDeploymentForm.tsx b/app/src/components/detail/CreateDeploymentForm.tsx index 48e98f9..5b38ed3 100644 --- a/app/src/components/detail/CreateDeploymentForm.tsx +++ b/app/src/components/detail/CreateDeploymentForm.tsx @@ -108,12 +108,9 @@ export function CreateDeploymentForm({ try { setBusy("Saving…") await apiClient.put(`/config/deployments/${name}`, { config: buildConfig() }) - setBusy("Deploying…") - await apiClient.post(`/deploy`, { name }) - if (kind === "service") { - setBusy("Starting…") - await apiClient.post(`/services/${name}/start`, {}) - } + // Converge: render + activate the new deployment in one step. + setBusy("Applying…") + await apiClient.post(`/apply`, { name }) qc.invalidateQueries({ queryKey: ["services"] }) qc.invalidateQueries({ queryKey: ["jobs"] }) qc.invalidateQueries({ queryKey: ["programs"] }) diff --git a/app/src/components/detail/ServiceControls.tsx b/app/src/components/detail/ServiceControls.tsx index 5ff3a33..8cfaaa6 100644 --- a/app/src/components/detail/ServiceControls.tsx +++ b/app/src/components/detail/ServiceControls.tsx @@ -1,44 +1,40 @@ -import { Play, RefreshCw, Square } from "lucide-react" -import { useServiceAction } from "@/services/api/hooks" -import type { HealthStatus } from "@/types" +import { Power, RefreshCw } from "lucide-react" +import { useServiceAction, useSetEnabled } from "@/services/api/hooks" interface ServiceControlsProps { name: string - health?: HealthStatus + enabled: boolean } -export function ServiceControls({ name, health }: ServiceControlsProps) { - const { mutate, isPending } = useServiceAction() - const isDown = health?.status === "down" +// Lifecycle is convergence: the Power toggle sets desired on/off state and applies +// (activate/deactivate); Restart is the one imperative bounce. No raw start/stop. +export function ServiceControls({ name, enabled }: ServiceControlsProps) { + const restart = useServiceAction() + const setEnabled = useSetEnabled() + const busy = restart.isPending || setEnabled.isPending return (
- {isDown && ( - - )} - {!isDown && ( + {enabled && ( )}
diff --git a/app/src/pages/Overview.tsx b/app/src/pages/Overview.tsx index a06bdd8..30d2d9b 100644 --- a/app/src/pages/Overview.tsx +++ b/app/src/pages/Overview.tsx @@ -11,6 +11,7 @@ import { } from "@/services/api/hooks" import { NodeBar } from "@/components/NodeBar" import { PageHeader } from "@/components/PageHeader" +import { ConvergePanel } from "@/components/ConvergePanel" export function Overview() { const { data: services } = useServices() @@ -96,6 +97,8 @@ export function Overview() { ))}
+ + ) } diff --git a/app/src/pages/ScheduledDetail.tsx b/app/src/pages/ScheduledDetail.tsx index edccbb9..c38a007 100644 --- a/app/src/pages/ScheduledDetail.tsx +++ b/app/src/pages/ScheduledDetail.tsx @@ -1,6 +1,6 @@ import { useParams } from "react-router-dom" import { Clock } from "lucide-react" -import { useJob, useStatus } from "@/services/api/hooks" +import { useJob } from "@/services/api/hooks" import { LogViewer } from "@/components/LogViewer" import { DetailHeader } from "@/components/detail/DetailHeader" import { ServiceControls } from "@/components/detail/ServiceControls" @@ -10,8 +10,6 @@ import { ConfigPanel } from "@/components/detail/ConfigPanel" export function ScheduledDetailPage() { const { name } = useParams<{ name: string }>() const { data: deployment, isLoading, error, refetch } = useJob(name ?? "") - const { data: statusResp } = useStatus() - const health = statusResp?.statuses.find((s) => s.id === name) if (isLoading) { return ( @@ -38,7 +36,7 @@ export function ScheduledDetailPage() { stack={deployment.stack} source={deployment.source} > - + {deployment.schedule && ( diff --git a/app/src/pages/ServiceDetail.tsx b/app/src/pages/ServiceDetail.tsx index f1a78e6..74882ce 100644 --- a/app/src/pages/ServiceDetail.tsx +++ b/app/src/pages/ServiceDetail.tsx @@ -51,7 +51,7 @@ export function ServiceDetailPage() { {!isStatic && (
{health && } - +
)} diff --git a/app/src/pages/ToolDetail.tsx b/app/src/pages/ToolDetail.tsx index b747dfa..80a0161 100644 --- a/app/src/pages/ToolDetail.tsx +++ b/app/src/pages/ToolDetail.tsx @@ -1,6 +1,6 @@ import { useParams, Link } from "react-router-dom" import { Loader2, Package } from "lucide-react" -import { useDeployment, useProgramAction } from "@/services/api/hooks" +import { useDeployment, useSetEnabled } from "@/services/api/hooks" import { DetailHeader } from "@/components/detail/DetailHeader" import { ConfigPanel } from "@/components/detail/ConfigPanel" @@ -38,9 +38,14 @@ export function ToolDetailPage() {

{deployment.description}

)} - {/* A tool's PATH deployment: install/uninstall is its start/stop. Its - live state is `installed` (on PATH), which the endpoint sets directly. */} - + {/* A tool's PATH deployment: enabling converges it onto PATH, disabling + removes it. `installed` is the live state; `enabled` the desired one. */} +

@@ -63,17 +68,19 @@ export function ToolDetailPage() { ) } -/** Install/uninstall a tool on PATH — the path deployment's lifecycle. */ +/** Enable/disable a tool — convergence installs it onto PATH or removes it. */ function PathLifecycle({ name, + enabled, installed: installedState, onDone, }: { name: string + enabled: boolean installed: boolean | null onDone: () => void }) { - const { mutate, isPending } = useProgramAction() + const { mutate, isPending } = useSetEnabled() const installed = installedState === true const dot = installedState === true @@ -86,21 +93,20 @@ function PathLifecycle({
{installed ? "Installed on PATH" : "Not installed"} + {!enabled && disabled} manager: path

) diff --git a/app/src/services/api/hooks.ts b/app/src/services/api/hooks.ts index 3f282c6..a68e3fa 100644 --- a/app/src/services/api/hooks.ts +++ b/app/src/services/api/hooks.ts @@ -154,6 +154,65 @@ export function useServiceAction() { }) } +export interface ApplyResult { + status: string + planned: boolean + changed: boolean + activated: string[] + restarted: string[] + deactivated: string[] + unchanged: string[] + messages: string[] +} + +// Converge the running system to config. Pass a name to converge one deployment, +// or plan:true for a dry-run diff. Handles the API restarting itself mid-apply. +export function useApply() { + const qc = useQueryClient() + return useMutation({ + mutationFn: async ({ name, plan }: { name?: string; plan?: boolean } = {}) => { + try { + return await apiClient.post("/apply", { name, plan }) + } catch (err) { + if (err instanceof TypeError) { + // Self-restart killed the connection — treat as accepted, wait + refresh. + await waitForApi() + return { + status: "ok", planned: false, changed: true, + activated: [], restarted: name ? [name] : [], deactivated: [], + unchanged: [], messages: [], + } as ApplyResult + } + throw err + } + }, + onSuccess: (data) => { + if (!data.planned) qc.invalidateQueries() + }, + }) +} + +// Set a deployment's desired on/off state, then converge it. One click = "make it +// so": edit config (declarative), then apply that single deployment. +export function useSetEnabled() { + const qc = useQueryClient() + return useMutation({ + mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) => { + await apiClient.put(`/config/deployments/${name}/enabled`, { enabled }) + try { + return await apiClient.post("/apply", { name }) + } catch (err) { + if (err instanceof TypeError) { + await waitForApi() + return null + } + throw err + } + }, + onSuccess: () => qc.invalidateQueries(), + }) +} + export function useProgramAction() { const qc = useQueryClient() return useMutation({ diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 3740b91..c6de932 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -19,6 +19,7 @@ export interface ServiceSummary { systemd: SystemdInfo | null program: string | null source: string | null + enabled: boolean // declared desired state; `apply` converges to it node: string | null } @@ -37,6 +38,7 @@ export interface JobSummary { systemd: SystemdInfo | null program: string | null source: string | null + enabled: boolean // declared desired state; `apply` converges to it node: string | null } @@ -98,6 +100,7 @@ export interface DeploymentSummary { schedule: string | null installed: boolean | null active: boolean | null + enabled: boolean // declared desired state; `apply` converges to it node: string | null } diff --git a/castle-api/src/castle_api/config_editor.py b/castle-api/src/castle_api/config_editor.py index e672ba1..33295b8 100644 --- a/castle-api/src/castle_api/config_editor.py +++ b/castle-api/src/castle_api/config_editor.py @@ -323,6 +323,29 @@ def delete_deployment(name: str) -> dict: return _delete_deployment(name) +class EnabledRequest(BaseModel): + enabled: bool + + +@router.put("/deployments/{name}/enabled") +def set_deployment_enabled(name: str, request: EnabledRequest) -> dict: + """Set a deployment's declared `enabled` state (desired on/off). + + Edits config only — the caller runs `POST /apply` to converge. Keeps the + declarative flow: change what you want, then apply. + """ + config = get_config() + dep = config.deployments.get(name) + if dep is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Deployment '{name}' not found", + ) + dep.enabled = request.enabled + save_config(config) + return {"ok": True, "deployment": name, "enabled": request.enabled} + + @router.put("/services/{name}") def save_service(name: str, request: ServiceConfigRequest) -> dict: """Alias of PUT /deployments/{name} (kept for the existing dashboard).""" From 9abef4ac34954b28318a0ea1f9b81829bfc9edd7 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Thu, 2 Jul 2026 11:54:31 -0700 Subject: [PATCH 4/4] =?UTF-8?q?docs+install:=20sweep=20deploy/start=20?= =?UTF-8?q?=E2=86=92=20castle=20apply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the README, AGENTS.md, install.sh, and docs/ for the converge model: one `castle apply [name] [--plan]` replaces `deploy && start` and the per-kind enable/disable/install/start/stop verbs. CLI references now list apply + restart (the imperative bounce) + logs; recipes use `castle apply`; "turn it off" is documented as `enabled: false` + apply. install.sh's next-steps collapse to a single `castle apply`. Verified: no retired-verb command instructions remain in any markdown; install.sh syntax OK; live `castle apply --plan` reports converged; doctor all-green; core 129 / cli 31 / api 59 pass. --- AGENTS.md | 40 +++++++++++++++++++-------------------- README.md | 37 ++++++++++++++++++++---------------- docs/design.md | 14 +++++++------- docs/developing-castle.md | 2 +- docs/dns-and-tls.md | 4 ++-- docs/registry.md | 23 +++++++++++----------- docs/stacks/react-vite.md | 2 +- docs/stacks/supabase.md | 4 ++-- docs/tunnel-setup.md | 14 +++++++------- install.sh | 15 +++++++-------- 10 files changed, 79 insertions(+), 76 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 64ae9b1..77fa58b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,38 +59,37 @@ castle program add [--name ...] # adopt EXISTIN castle program clone [name] # provision repo: source castle program delete [--source] [-y] castle program run [args...] # declared run command -castle program install|uninstall [name] # activate tools/statics castle program build|test|lint|format|type-check|check [name] # dev verbs # Services — daemons (manager: systemd, no schedule) castle service list|info [--json] castle service create [--program P] [--port N] [--health ...] [--launcher ...] -castle service deploy # generate unit + gateway route -castle service enable|disable # systemd enable/disable (+ boot) -castle service start|stop|restart +castle service restart # imperative bounce castle service logs [-f] [-n 50] -# Jobs — scheduled tasks (manager: systemd + schedule). Same verbs; create takes --schedule +# Jobs — scheduled tasks (manager: systemd + schedule). create takes --schedule castle job create [--program P] --schedule "0 2 * * *" [--launcher ...] -castle job ... +castle job ... # Tools — CLIs on PATH (manager: path) castle tool list [--json] # each tool's executable + description + install state castle tool info [--json] -castle tool install|uninstall # Platform-wide -castle list [--kind ...] [--stack ...] [--json] # all deployments +castle apply [name] [--plan] # converge runtime to config — the workhorse +castle list [--kind ...] [--stack ...] [--json] # all deployments castle status # unified health/status -castle doctor # diagnose setup + runtime, with fix hints -castle deploy [name] # apply config → units + Caddyfile -castle start | stop | restart # all services (+ gateway) -castle gateway start|stop|reload|status # the Caddy gateway +castle doctor # diagnose setup + runtime, with fix hints +castle restart [name] # imperative bounce (one or all) +castle gateway reload|status # the Caddy gateway ``` `castle service`/`job`/`tool` are **views** over the one deployment set, filtered -by derived kind. Bringing everything online is the two honest steps -**`castle deploy && castle start`** (apply config, then start). +by derived kind. Lifecycle is **convergence**: edit config, then **`castle apply`** +renders units + the Caddyfile and reconciles the runtime (activate what's enabled, +restart what changed, deactivate what's disabled). To durably turn a deployment +off, set `enabled: false` and apply — there is no separate start/stop/enable. +`castle restart` is the one imperative bounce. **Dev verbs resolve per-program:** a declared `commands:` entry (or `build:`) overrides the program's stack default, else the stack handler, else the verb is @@ -108,8 +107,7 @@ castle program create my-service --stack python-fastapi --description "Does X" cd /data/repos/my-service && uv sync # implement it castle program test my-service castle service create my-service --program my-service --port 9001 -castle service deploy my-service && castle service enable my-service -castle gateway reload +castle apply my-service # renders the unit + gateway route and starts it ``` The service reads its port/data dir from env vars that `deployments/my-service.yaml` @@ -120,7 +118,7 @@ maps via placeholders (see §6). Stack guide: **`docs/stacks/python-fastapi.md`* ```bash castle program create my-tool --stack python-cli --description "Does Y" cd /data/repos/my-tool && uv sync -castle tool install my-tool # uv tool install → on PATH +castle apply my-tool # installs the path deployment on PATH ``` `castle tool list --json` is the machine-readable tool catalog (each tool's real @@ -134,7 +132,7 @@ A job is a `manager: systemd` deployment with a `schedule` (cron). Generates a ```bash castle job create nightly --program my-tool --schedule "0 2 * * *" --launcher command -castle job deploy nightly && castle job enable nightly +castle apply nightly ``` ### Create a static frontend @@ -143,7 +141,7 @@ castle job deploy nightly && castle job enable nightly # scaffold a Vite/React app under /data/repos/my-frontend (see docs/stacks/react-vite.md) castle program build my-frontend # produces dist/ # deployments/my-frontend.yaml → manager: caddy, root: dist -castle deploy && castle gateway reload # served at my-frontend. +castle apply # served at my-frontend. ``` The gateway serves the build **in place** from `/` — no copy, no @@ -213,7 +211,7 @@ context** (`crypto.subtle`, service workers), which plain-HTTP LAN hosts lack. `acme` operational prerequisites (castle can't do these for you): - **DNS-plugin Caddy** at `/usr/local/bin/caddy` — `./install.sh --with-dns-plugin=cloudflare`. - **Provider token** stored as a secret and mapped into the gateway service env - (`CLOUDFLARE_API_TOKEN`); `castle deploy` warns if missing. + (`CLOUDFLARE_API_TOKEN`); `castle apply` warns if missing. - **Bind :443/:80** — lower the floor once: `net.ipv4.ip_unprivileged_port_start=80` in `/etc/sysctl.d/` (beats `setcap`, which `NoNewPrivileges` would void). - **Stage first**: `CASTLE_ACME_STAGING=1` at deploy, verify issuance, then unset @@ -258,7 +256,7 @@ Roots: **`CASTLE_HOME`** (config/code/artifacts/secrets, default `~/.castle`) an `public: true` (requires `proxy: true`) projects a service to the internet at `.` (a **separate** zone, so internal subdomain names -stay out of public DNS). `castle deploy` generates the cloudflared ingress from the +stay out of public DNS). `castle apply` generates the cloudflared ingress from the set of public services. Needs `gateway.public_domain` + `gateway.tunnel_id` set and the `castle-tunnel` service running. → One-time setup: **`docs/tunnel-setup.md`**. diff --git a/README.md b/README.md index 1716b8c..e908b0f 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,10 @@ A program can have several deployments — a CLI that is both a `tool` on PATH a scheduled `job` — so a program has no single kind of its own; it *has deployments*, each with its own. -Standing everything up is the two honest steps `castle deploy` (regenerate systemd -units and gateway config from your config) then `castle start` (enable/start it). -There is no bundled "up". +Standing everything up is one honest step: `castle apply` renders systemd units and +gateway config from your config, then reconciles the runtime to match — activating +what's enabled, restarting what changed, deactivating what's disabled. Edit config, +`castle apply`; `castle apply --plan` shows the diff first. ## Stacks @@ -112,7 +113,7 @@ git clone ~/castle && cd ~/castle # Postgres), creates ~/.castle, registers Castle's own control plane, builds the UI. ./install.sh -castle deploy && castle start # apply config to the runtime, then bring it up +castle apply # converge the runtime to config (units, routes, run) castle doctor # verify — every check should be green open http://localhost:9000 # the dashboard ``` @@ -124,7 +125,7 @@ looks off — after an install, a deploy, or a config change. ### Exposure: from localhost to your own HTTPS domain Localhost is the first rung; you climb only as far as you need. Each rung is a small -config change plus `castle deploy`, and `castle doctor` tells you what a rung still +config change plus `castle apply`, and `castle doctor` tells you what a rung still needs. | Rung | You get | What it takes | @@ -145,15 +146,15 @@ are still missing, each with its fix. # A service — FastAPI app + a systemd service deployment (health, unit, route) castle program create my-api --stack python-fastapi --description "Does something" castle program test my-api -castle deploy my-api && castle service enable my-api +castle apply my-api # render unit + route, then start it # A tool — a CLI installed on your PATH castle program create my-tool --stack python-cli --description "Does something" -castle tool install my-tool +castle apply my-tool # installs its path deployment on PATH # A static frontend — built once, served by the gateway castle program create my-app --stack react-vite --description "Web interface" -castle program build my-app && castle deploy +castle program build my-app && castle apply # Adopt an existing repo (no stack needed — dev verbs detected or declared) castle program add ~/projects/some-rust-tool @@ -162,28 +163,32 @@ castle program add ~/projects/some-rust-tool ## CLI Operations live under the resource they act on. `program` is the catalog; -`service`, `job`, and `tool` are **views** over the one deployment set; platform -lifecycle is top-level. +`service`, `job`, and `tool` are **views** over the one deployment set. Lifecycle +is convergence — `castle apply` — not a pile of per-kind verbs. ``` # Programs — the software catalog -castle program list|info|create|add|clone|delete|run|install|uninstall +castle program list|info|create|add|clone|delete|run castle program build|test|lint|type-check|check [name] # dev verbs # Deployment lenses (service = systemd, job = systemd + schedule, tool = path) -castle service list|info|create|delete|deploy|enable|disable|start|stop|restart|logs +castle service list|info|create|delete|restart|logs castle job …same verbs; create takes --schedule -castle tool list|info|install|uninstall # CLIs on your PATH +castle tool list|info # CLIs on your PATH # Platform-wide +castle apply [name] [--plan] # converge runtime to config — the workhorse castle list [--kind K] [--stack S] [--json] # catalog + every deployment view castle status # unified runtime status castle doctor # diagnose setup + health, with fix hints -castle deploy [name] # apply config → units + Caddyfile -castle start | stop | restart # all deployments (+ gateway) -castle gateway start|stop|reload|status +castle restart [name] # imperative bounce (one or all) +castle gateway reload|status ``` +To turn a deployment off, set `enabled: false` in its config and `castle apply` — +there's no start/stop/enable/install verb; the manager decides the mechanism +(systemd unit, PATH install, gateway route), the verb is always `apply`. + `castle tool list --json` is the machine-readable tool catalog assistants use to build context — it surfaces each tool's actual **executable** (which can differ from the program name, e.g. `litellm-intent-router` installs `intent-router`), its diff --git a/docs/design.md b/docs/design.md index 82fe24a..244629d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -204,7 +204,7 @@ A service's env is exactly its `defaults.env` — castle injects nothing implicitly. Values may use `${port}`/`${data_dir}`/`${name}`/`${secret:…}` placeholders, which deploy resolves into the registry's flat `env`. -**`$CASTLE_HOME/artifacts/specs/registry.yaml`** (per-node, not in the repo, generated by `castle deploy`) — Node config: +**`$CASTLE_HOME/artifacts/specs/registry.yaml`** (per-node, not in the repo, generated by `castle apply`) — Node config: ```yaml node: @@ -228,7 +228,7 @@ deployed: ``` The node config says what's deployed *here* and with what concrete -values. `castle deploy` reads the spec from the repo, resolves the +values. `castle apply` reads the spec from the repo, resolves the `defaults.env` placeholders and secrets, resolves binary paths, and writes the registry. Systemd units and Caddyfile are then generated from the registry — never from the spec directly. @@ -280,7 +280,7 @@ programs on a single node and across multiple Castle nodes. **MQTT topics:** - `castle/{hostname}/registry` — retained JSON, full NodeRegistry. - Published on connect and after `castle deploy`. + Published on connect and after `castle apply`. - `castle/{hostname}/status` — `"online"` (retained) / `"offline"` (LWT). LWT ensures nodes are marked offline if they disconnect unexpectedly. @@ -457,7 +457,7 @@ $CASTLE_HOME/ ← Config & artifacts (default ~/.castle) ├── code/ ← Program source (your programs) │ └── / ├── artifacts/ -│ ├── specs/ ← Generated by `castle deploy` +│ ├── specs/ ← Generated by `castle apply` │ │ ├── Caddyfile │ │ └── registry.yaml ← Node config (what's deployed here) │ └── content/ ← Built frontend assets @@ -491,7 +491,7 @@ Castle's architecture parallels Erlang/OTP, mapped onto Unix: | Application | Component (independent, self-contained) | | Application resource file | Component spec in `castle.yaml` | | Release config (sys.config) | Node config in `$CASTLE_HOME/artifacts/specs/registry.yaml` | -| Release assembly | `castle deploy` (spec + node config → runtime) | +| Release assembly | `castle apply` (spec + node config → runtime) | | Supervisor | systemd (restart policies, ordering) | | Process | Running service/worker/job | | Application env | Env vars | @@ -527,12 +527,12 @@ What exists today: - **CLI** — `castle` command, installed via `uv tool install --editable cli/` - **Three packages** — `castle-core` (models, config, generators), `castle-cli` (commands), `castle-api` (HTTP API) -- **Source/runtime split** — `castle.yaml` (spec) → `castle deploy` → +- **Source/runtime split** — `castle.yaml` (spec) → `castle apply` → `$CASTLE_HOME/artifacts/specs/registry.yaml` (node config). Systemd units and Caddyfile generated from registry with fully resolved paths. No repo references in runtime artifacts. - **Explicit env with placeholders** — a deployment's env is exactly its - `defaults.env`; `castle deploy` resolves `${port}`/`${data_dir}`/`${name}`/ + `defaults.env`; `castle apply` resolves `${port}`/`${data_dir}`/`${name}`/ `${secret:…}` into concrete values. No hidden convention injection. - **Gateway** — Caddy on port 9000, Caddyfile generated from registry - **API** — `castle-api` on port 9020, reads from registry (optional diff --git a/docs/developing-castle.md b/docs/developing-castle.md index 288b00f..4cd15c4 100644 --- a/docs/developing-castle.md +++ b/docs/developing-castle.md @@ -63,7 +63,7 @@ for tools/libraries. at `/usr/local/bin/caddy` when `tls: acme`. - Systemd user units at `~/.config/systemd/user/castle-*.service` (+ `.timer`); the unit for program `X` is `castle-X.service`. Use drop-in `*.service.d/*.conf` - for extra env `castle deploy` shouldn't overwrite. + for extra env `castle apply` shouldn't overwrite. - The `container` launcher resolves docker via `shutil.which("docker")` (preferred over rootless podman on this box). - Service data at `$CASTLE_DATA_DIR//`; secrets at `~/.castle/secrets/` diff --git a/docs/dns-and-tls.md b/docs/dns-and-tls.md index e746e8b..34e4cad 100644 --- a/docs/dns-and-tls.md +++ b/docs/dns-and-tls.md @@ -122,7 +122,7 @@ origin — moving a service onto HTTPS changes its origin. (§DNS). Verify: `dig +short .` → the node's IP. 3. **Set `gateway.tls: acme`** (with `domain`/`acme_email`), plus the operational prerequisites (below). -4. **Deploy & reload:** `castle deploy` regenerates the Caddyfile and reloads Caddy. +4. **Deploy & reload:** `castle apply` regenerates the Caddyfile and reloads Caddy. 5. **Update the app's origin allowlist** if it has one (§secure context). ## Operational prerequisites @@ -142,7 +142,7 @@ a DNS token. (`~/.castle/secrets/`, scope: the DNS provider's "edit DNS records" permission for your zone) and map it into the gateway service env in `deployments/castle-gateway.yaml` (`defaults.env`), so Caddy reads it as - `{env.}`. `castle deploy` warns if the domain, env var, or secret is + `{env.}`. `castle apply` warns if the domain, env var, or secret is missing. - **`acme` — stage first.** Set `CASTLE_ACME_STAGING=1` at deploy to use Let's Encrypt's staging CA (generous rate limits) while verifying issuance, then unset diff --git a/docs/registry.md b/docs/registry.md index 12c0ae5..5752f85 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -333,7 +333,7 @@ proxy: true # expose at . `public: true` additionally projects a proxied service to the public internet via a Cloudflare tunnel, at **`.`** (a separate zone, so internal subdomain names stay out of public DNS). Defaults to `false` — public is -explicit — and **requires `proxy: true`**. `castle deploy` generates the cloudflared +explicit — and **requires `proxy: true`**. `castle apply` generates the cloudflared ingress from the set of public services. Needs `gateway.public_domain` + `gateway.tunnel_id` set and the `castle-tunnel` service running; see @docs/tunnel-setup.md for the one-time setup. @@ -467,7 +467,7 @@ Setup (the parts castle can't do for you): env: CLOUDFLARE_API_TOKEN: ${secret:CLOUDFLARE_API_TOKEN} ``` - `castle deploy` warns if the domain, this env var, or the secret is missing. + `castle apply` warns if the domain, this env var, or the secret is missing. - **LAN DNS.** Add a wildcard on your LAN's DNS server (usually the router) pointing `*.` at the gateway's private IP — `address=//` (dnsmasq) or the equivalent A record. The public zone gets no A records, so @@ -493,8 +493,9 @@ manage: systemd: {} ``` -Enables `castle service enable/disable` and `castle service logs`. An empty `{}` -uses defaults (enable=true, restart=on-failure, restart_sec=2). +Marks the deployment systemd-managed, so `castle apply` generates and reconciles +its unit (and `castle service logs` tails it). An empty `{}` uses defaults +(enable=true, restart=on-failure, restart_sec=2). Full options: ```yaml @@ -636,17 +637,17 @@ castle program create my-service --stack python-fastapi # 1. Scaffold + regist cd /data/repos/my-service && uv sync # 2. Install deps # ... implement ... castle program test my-service # 3. Run tests -castle service enable my-service # 4. Generate systemd unit, start -castle gateway reload # 5. Update Caddy routes +castle apply my-service # 4. Render unit + routes and reconcile (start it) ``` -After `service enable`, the service starts automatically on boot and restarts -on failure. Manage with: +`castle apply` renders the systemd unit + Caddy route and starts the service +(which then runs on boot and restarts on failure). Manage with: ```bash castle logs my-service -f # Tail logs castle service run my-service # Run in foreground (for debugging) -castle service disable my-service # Stop and remove systemd unit +castle service restart my-service # Imperative bounce +# To stop it durably: set `enabled: false` in its deployment, then `castle apply` ``` ### Tool lifecycle @@ -656,7 +657,7 @@ castle program create my-tool --stack python-cli # 1. Scaffold + register cd /data/repos/my-tool && uv sync # 2. Install deps # ... implement ... castle program test my-tool # 3. Run tests -uv tool install --editable /data/repos/my-tool/ # 4. Install to PATH +castle apply my-tool # 4. Install the path deployment on PATH ``` ### Job lifecycle @@ -676,7 +677,7 @@ manage: systemd: {} ``` -`castle job enable my-job` generates both a `.service` (Type=oneshot) +`castle apply my-job` generates both a `.service` (Type=oneshot) and a `.timer` file. ## Infrastructure paths diff --git a/docs/stacks/react-vite.md b/docs/stacks/react-vite.md index 102a890..a8e7c7d 100644 --- a/docs/stacks/react-vite.md +++ b/docs/stacks/react-vite.md @@ -171,7 +171,7 @@ See @docs/registry.md for the full registry reference. ## Serving with Caddy For production, the static build output is served by Caddy rather than a Node -process. You do **not** write this block by hand — `castle deploy` generates it. +process. You do **not** write this block by hand — `castle apply` generates it. The flow: 1. You build the frontend with `castle program build ` (deploy does **not** diff --git a/docs/stacks/supabase.md b/docs/stacks/supabase.md index 56da107..ec13212 100644 --- a/docs/stacks/supabase.md +++ b/docs/stacks/supabase.md @@ -119,7 +119,7 @@ leaves the schema intact (and says so). To destroy the data too: castle delete my-app --purge-data # drop schema my_app cascade ``` -`castle deploy` then prunes the schema from `PGRST_DB_SCHEMAS`; **restart the +`castle apply` then prunes the schema from `PGRST_DB_SCHEMAS`; **restart the `supabase` service** for PostgREST to pick up the added/removed schema list. ## Auth, RLS & the three privacy layers @@ -161,7 +161,7 @@ service itself is likewise at `supabase.`.) See castle program create my-app --stack supabase --description "..." # scaffold + register castle program build my-app # apply unapplied migrations to the substrate castle program test my-app # deno test over functions/ (if deno present) -castle deploy && castle gateway reload # serve the static UI at /my-app/ +castle apply # serve the static UI at /my-app/ ``` ## Scaffolding diff --git a/docs/tunnel-setup.md b/docs/tunnel-setup.md index 9d48d61..a328ee9 100644 --- a/docs/tunnel-setup.md +++ b/docs/tunnel-setup.md @@ -17,7 +17,7 @@ internet at `.`, via a Cloudflare Tunnel. Cloudflare (no inbound holes, no public IP needed) and forwards each public hostname to the gateway on `:443`, rewriting the Host header and TLS SNI to the *internal* name so Caddy routes it and its wildcard cert validates. The public - surface is exactly the set of `public: true` services — `castle deploy` generates + surface is exactly the set of `public: true` services — `castle apply` generates the ingress from the registry. - **One kill switch.** Stop `castle-tunnel` → instantly nothing is public. @@ -76,8 +76,8 @@ manage: Bring it online: ```bash -castle deploy # writes cloudflared.yml from public services -castle service enable castle-tunnel # start the tunnel +castle apply # writes cloudflared.yml from public services +castle apply castle-tunnel # start the tunnel ``` ## Using the toggle @@ -92,10 +92,10 @@ public: true # also expose at .pub.payne.io via the tunnel Then just deploy: ```bash -castle deploy +castle apply ``` -`castle deploy` regenerates `~/.castle/artifacts/specs/cloudflared.yml` from the +`castle apply` regenerates `~/.castle/artifacts/specs/cloudflared.yml` from the current set of public services and restarts `castle-tunnel`. Flip `public` back to `false` (or remove it) and redeploy to un-expose — the hostname drops out of the ingress immediately. @@ -116,14 +116,14 @@ printf %s "" > ~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN chmod 600 ~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN ``` -With it present, every `castle deploy` **reconciles** the public CNAMEs against the +With it present, every `castle apply` **reconciles** the public CNAMEs against the current public set — creating missing ones and deleting removed ones — and prints a one-line summary. It only ever touches records pointing at *this* tunnel (`.cfargotunnel.com`), so hand-managed records in the same zone are safe. This token is separate from `CLOUDFLARE_API_TOKEN` (which is the ACME token for the internal zone); the public zone is usually a different zone/account. -Without the token, `castle deploy` instead prints the exact command to run per host: +Without the token, `castle apply` instead prints the exact command to run per host: ```bash cloudflared tunnel route dns .pub.payne.io diff --git a/install.sh b/install.sh index f0d9833..105d5c5 100755 --- a/install.sh +++ b/install.sh @@ -22,7 +22,7 @@ CASTLE_HOME="${HOME}/.castle" CASTLE_CONF="${CASTLE_HOME}/infra.conf" # Program data lives on a dedicated volume, decoupled from CASTLE_HOME — must match # castle_core.config._resolve_data_dir (default /data/castle, override CASTLE_DATA_DIR) -# so the data dirs + container mounts created here line up with what castle deploy +# so the data dirs + container mounts created here line up with what castle apply # generates for the mqtt/postgres/neo4j deployments. DATA_DIR="${CASTLE_DATA_DIR:-/data/castle}" # Program source repos (default /data/repos, override CASTLE_REPOS_DIR). @@ -177,7 +177,7 @@ CADDY_DNS_VERSION="${CADDY_DNS_VERSION:-v2.11.4}" # (Let's Encrypt wildcard via DNS-01). Stock apt Caddy has no DNS modules. The # result goes to /usr/local/bin/caddy, which precedes /usr/bin on PATH, so the # gateway (a `command` runner resolving `caddy` via PATH) picks it up on the next -# `castle deploy` with no spec change. Idempotent and opt-in (--with-dns-plugin). +# `castle apply` with no spec change. Idempotent and opt-in (--with-dns-plugin). ensure_caddy_dns_plugin() { local provider="${1:-cloudflare}" local module @@ -211,7 +211,7 @@ ensure_caddy_dns_plugin() { /usr/local/bin/caddy list-modules 2>/dev/null | grep -q "dns.providers.$provider" \ || log_fail "built caddy is missing dns.providers.$provider" - log_info "Built /usr/local/bin/caddy — run 'castle deploy && castle gateway restart' to use it." + log_info "Built /usr/local/bin/caddy — run 'castle apply' to use it." log_ok } @@ -259,7 +259,7 @@ create_directories() { # Program data volume (default /data/castle) lives outside $HOME, so on a fresh # machine its parent may not be user-writable — fall back to sudo + chown so the - # later container mounts (and castle deploy) can write there. + # later container mounts (and castle apply) can write there. if ! mkdir -p "${DATA_DIR}" 2>/dev/null; then log_info "creating ${DATA_DIR} (needs sudo — outside \$HOME)" sudo mkdir -p "${DATA_DIR}" @@ -280,7 +280,7 @@ create_directories() { # --------------------------------------------------------------------------- # Register Castle's own control-plane programs/deployments from bootstrap/ so a -# fresh registry is not empty. Without this, `castle deploy && castle start` +# fresh registry is not empty. Without this, `castle apply` # would bring up nothing. Never clobbers existing entries (idempotent). The # gateway deployment carries a `__SPECS_DIR__` placeholder (the source repo has # no machine-specific paths) that we substitute with this machine's specs dir. @@ -337,7 +337,7 @@ seed_caddyfile() { fi cat > "$caddyfile" << 'EOF' :9000 { - respond "Castle is starting. Run 'castle deploy' to configure." 200 + respond "Castle is starting. Run 'castle apply' to configure." 200 } EOF log_ok @@ -553,8 +553,7 @@ print_summary() { printf "\n" printf "Next steps:\n" - printf " castle deploy # Generate registry, systemd units, Caddyfile\n" - printf " castle start # Start the gateway, API, and all deployments\n" + printf " castle apply # Converge: render units/routes + start everything\n" printf " castle doctor # Verify setup + health (green = good to go)\n" printf " open http://localhost:9000 # the dashboard\n" }