From 57861e58c12871d6312c10895da60de714d4f60f Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Wed, 1 Jul 2026 06:51:31 -0700 Subject: [PATCH] Manager abstraction + manager-dispatched lifecycle; frontends create static services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - manifest: `manager_for(runner)` — the single source of truth mapping a runner to its manager (systemd | caddy | path | none). deploy's `managed` now derives from it. - lifecycle: is_active/activate/deactivate dispatch by manager instead of behavior (removed the stale `_is_static_frontend`, which broke once frontends became services). caddy → gateway reload; path → install/uninstall; none → no-op. - cli/service: start/stop/restart and enable/disable route through the manager (systemctl only for systemd; static reloads the gateway; remote is a no-op); platform restart skips non-systemd runners. - create: a frontend stack (react-vite/supabase) now emits a `runner: static` service, not a bare frontend program. --- cli/src/castle_cli/commands/create.py | 19 +++- cli/src/castle_cli/commands/service.py | 84 ++++++++++++++--- cli/tests/test_create.py | 6 +- core/src/castle_core/deploy.py | 9 +- core/src/castle_core/lifecycle.py | 124 ++++++++++++++++--------- core/src/castle_core/manifest.py | 24 +++++ core/tests/test_caddyfile.py | 1 - core/tests/test_lifecycle.py | 17 ++-- 8 files changed, 206 insertions(+), 78 deletions(-) diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index 47d18d7..be1828f 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -15,6 +15,7 @@ from castle_cli.manifest import ( ManageSpec, ProgramSpec, RunPython, + RunStatic, ServiceSpec, SystemdSpec, ) @@ -93,11 +94,12 @@ def run_create(args: argparse.Namespace) -> int: # Initialize a git repo for the new source. subprocess.run(["git", "init", "-q", str(project_dir)], check=False) - # Frontend stacks with a static output get a build spec so the gateway emits - # an in-place static route at // (no service, no process). + # Frontend stacks declare a build output; the program builds it, a `static` + # service serves it in place at .. build = None - if stack in STACK_BUILD_OUTPUTS: - build = BuildSpec(outputs=[STACK_BUILD_OUTPUTS[stack]]) + static_root = STACK_BUILD_OUTPUTS.get(stack) + if static_root: + build = BuildSpec(outputs=[static_root]) config.programs[name] = ProgramSpec( id=name, @@ -107,7 +109,14 @@ def run_create(args: argparse.Namespace) -> int: behavior=behavior, build=build, ) - if behavior == "daemon": + if behavior == "frontend": + # A caddy-managed static service: no systemd unit, served from the build dir. + config.services[name] = ServiceSpec( + id=name, + program=name, + run=RunStatic(runner="static", root=static_root or "dist"), + ) + elif behavior == "daemon": prefix = name.replace("-", "_").upper() config.services[name] = ServiceSpec( id=name, diff --git a/cli/src/castle_cli/commands/service.py b/cli/src/castle_cli/commands/service.py index 67652ea..e3b5f10 100644 --- a/cli/src/castle_cli/commands/service.py +++ b/cli/src/castle_cli/commands/service.py @@ -12,6 +12,7 @@ from castle_core.generators.systemd import ( timer_name, unit_name, ) +from castle_core.manifest import manager_for from castle_core.registry import REGISTRY_PATH, load_registry from castle_cli.config import ( @@ -52,7 +53,7 @@ def run_service_cmd(args: argparse.Namespace) -> int: return _service_dry_run(config, args.name) return _service_enable(config, args.name) if sub == "disable": - return _service_disable(args.name) + return _service_disable(config, args.name) if sub in ("start", "stop", "restart"): return _unit_action(config, args.name, sub, is_job=False) return 1 @@ -65,7 +66,7 @@ def run_job_cmd(args: argparse.Namespace) -> int: if sub == "enable": return _service_enable(config, args.name) # enable_service handles timers if sub == "disable": - return _service_disable(args.name) + return _service_disable(config, args.name) if sub in ("start", "stop", "restart"): return _unit_action(config, args.name, sub, is_job=True) return 1 @@ -84,12 +85,22 @@ def run_platform(args: argparse.Namespace) -> int: return 1 +_GATEWAY_NAME = "castle-gateway" + + def _unit_action(config: CastleConfig, name: str, action: str, is_job: bool) -> int: - """systemctl start/stop/restart one service (unit) or job (timer).""" + """start/stop/restart one service or job, dispatched by its manager. + + systemd (a process/timer) → `systemctl`; caddy (static) → reload the gateway; + path (a tool) → install/uninstall; none (remote) → nothing to do. + """ section = config.jobs if is_job else config.services if name not in section: print(f"Error: no {'job' if is_job else 'service'} '{name}'.") return 1 + manager = "systemd" if is_job else manager_for(section[name].run.runner) + if manager != "systemd": + return _managed_lifecycle(config, name, action, manager) unit = timer_name(name) if is_job else unit_name(name) result = subprocess.run(["systemctl", "--user", action, unit], check=False) if result.returncode != 0: @@ -99,12 +110,57 @@ def _unit_action(config: CastleConfig, name: str, action: str, is_job: bool) -> return 0 +def _managed_lifecycle(config: CastleConfig, name: str, action: str, manager: str) -> int: + """Lifecycle for non-systemd managers (no unit to systemctl).""" + if manager == "caddy": + if action == "stop": + print(f" {name}: gateway-served — disable or remove it to drop the route.") + return 0 + # start/restart → reload the gateway so current routes take effect. + subprocess.run( + ["systemctl", "--user", "reload", unit_name(_GATEWAY_NAME)], check=False + ) + print(f" {name}: gateway reloaded ({_PAST[action]}).") + return 0 + if manager == "path": + return _path_lifecycle(config, name, action) + # none (remote): external, nothing local to act on. + print(f" {name}: external ({manager}) — nothing to {action}.") + return 0 + + +def _path_lifecycle(config: CastleConfig, name: str, action: str) -> int: + """A `path` (tool) deployment's lifecycle is install/uninstall on PATH.""" + import asyncio + + from castle_core.stacks import run_action + + program = config.services[name].program or name + comp = config.programs.get(program) + if comp is None: + print(f"Error: path service '{name}' references unknown program '{program}'.") + return 1 + verb = {"start": "install", "stop": "uninstall", "restart": "install"}[action] + res = asyncio.run(run_action(verb, program, comp, config.root)) + ok = res.status == "ok" + print(f" {name}: {verb + 'ed' if ok else verb + ' FAILED'}") + if not ok and res.output: + print(res.output) + return 0 if ok else 1 + + def _services_restart(config: CastleConfig) -> int: - """Restart every managed service and job unit.""" + """Restart every systemd-managed service and job unit. + + caddy/path/none runners have no unit — they ride along with the gateway + restart (static) or are stateless (remote), so we don't systemctl them here. + """ for name in config.jobs: subprocess.run(["systemctl", "--user", "restart", timer_name(name)], check=False) print(f" {name}: restarted (timer)") - for name in config.services: + for name, svc in config.services.items(): + if manager_for(svc.run.runner) != "systemd": + continue subprocess.run(["systemctl", "--user", "restart", unit_name(name)], check=False) print(f" {name}: restarted") return 0 @@ -138,21 +194,25 @@ def run_status(args: argparse.Namespace) -> int: def _service_enable(config: CastleConfig, name: str) -> int: - """Enable and start a single service (or timer for scheduled jobs).""" - from castle_core.lifecycle import enable_service + """Enable a service in its mode (systemd unit / gateway route / PATH install).""" + import asyncio + + from castle_core.lifecycle import activate ensure_dirs() - result = enable_service(name, config) + result = asyncio.run(activate(name, config, config.root)) print(result.output) return 0 if result.status == "ok" else 1 -def _service_disable(name: str) -> int: - """Stop and disable a service (and timer if present).""" - from castle_core.lifecycle import disable_service +def _service_disable(config: CastleConfig, name: str) -> int: + """Disable a service in its mode (stop unit / drop route / uninstall).""" + import asyncio + + from castle_core.lifecycle import deactivate print(f"Disabling {name}...") - result = disable_service(name) + result = asyncio.run(deactivate(name, config, config.root)) print(f" {result.output}") return 0 diff --git a/cli/tests/test_create.py b/cli/tests/test_create.py index 1828cff..3fea24f 100644 --- a/cli/tests/test_create.py +++ b/cli/tests/test_create.py @@ -102,12 +102,14 @@ class TestCreateCommand: assert (project_dir / "public" / "index.html").exists() assert (project_dir / "supabase.app.yaml").exists() - # Registered as a static frontend, no service, build output = public/ + # Registered as a program + a `static` service serving public/ comp = config.programs["guestbook"] assert comp.behavior == "frontend" assert comp.stack == "supabase" assert comp.build is not None and comp.build.outputs == ["public"] - assert "guestbook" not in config.services + svc = config.services["guestbook"] + assert svc.run.runner == "static" + assert svc.run.root == "public" def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None: """Creating a project with existing name fails.""" diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index 408ec98..3f0d678 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -41,7 +41,7 @@ from castle_core.generators.systemd import ( unit_env_file, unit_name, ) -from castle_core.manifest import JobSpec, ServiceSpec +from castle_core.manifest import JobSpec, ServiceSpec, manager_for from castle_core.registry import ( REGISTRY_PATH, Deployment, @@ -336,9 +336,10 @@ def _build_deployed_service( # /data/castle/protonmail). Falls back to the service name. config_key = svc.program or name - # `remote` and `static` are caddy-managed (or unmanaged) — no local process, no - # systemd unit. The gateway is their runtime. - managed = run.runner not in ("remote", "static") + # Only systemd-managed runners get a unit. caddy (static), path (tools), and + # none (remote) have no local process — the manager mapping is the single + # source of truth, so there's no runner special-casing here. + managed = manager_for(run.runner) == "systemd" if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable: managed = False diff --git a/core/src/castle_core/lifecycle.py b/core/src/castle_core/lifecycle.py index 9e27f17..019000b 100644 --- a/core/src/castle_core/lifecycle.py +++ b/core/src/castle_core/lifecycle.py @@ -25,6 +25,7 @@ from castle_core.generators.systemd import ( unit_env_file, unit_name, ) +from castle_core.manifest import manager_for from castle_core.registry import REGISTRY_PATH, load_registry from castle_core.stacks import ActionResult, run_action @@ -47,15 +48,23 @@ def _on_path(name: str) -> bool: return (Path.home() / ".local" / "bin" / name).exists() -def _is_static_frontend(name: str, config: CastleConfig) -> bool: - """A frontend with no service/job of its own — served as static assets.""" - comp = config.programs.get(name) - return ( - comp is not None - and comp.behavior == "frontend" - and name not in config.services - and name not in config.jobs - ) +def _svc_manager(name: str, config: CastleConfig) -> str | None: + """The manager for a deployed name (service/job), or None if not deployed.""" + if name in config.services: + return manager_for(config.services[name].run.runner) + if name in config.jobs: + return "systemd" + return None + + +def _static_built(name: str, config: CastleConfig) -> bool: + """Whether a static service's served dir exists (assets are built).""" + svc = config.services.get(name) + if svc is None: + return False + comp = config.programs.get(svc.program or name) + root = getattr(svc.run, "root", "dist") + return bool(comp and comp.source and (Path(comp.source) / root).is_dir()) # --------------------------------------------------------------------------- @@ -64,16 +73,18 @@ def _is_static_frontend(name: str, config: CastleConfig) -> bool: def is_active(name: str, config: CastleConfig) -> bool: - """Whether a program is reachable in its mode (uniform across behaviors).""" - if name in config.services: - return _systemctl_active(unit_name(name)) - if name in config.jobs: - return _systemctl_active(timer_name(name)) - if _is_static_frontend(name, config): - comp = config.programs[name] - if comp.source and comp.build and comp.build.outputs: - return (Path(comp.source) / comp.build.outputs[0]).is_dir() - return False + """Whether a deployment is available in its mode, dispatched by its manager.""" + manager = _svc_manager(name, config) + if manager == "systemd": + unit = timer_name(name) if name in config.jobs else unit_name(name) + return _systemctl_active(unit) + if manager == "caddy": + return _static_built(name, config) # served once its assets exist + if manager == "path": + return _on_path(name) + if manager == "none": + return True # remote: external, treated as available + # No deployment — a bare program (e.g. a tool not yet given a path service). comp = config.programs.get(name) if comp is not None and comp.source: return _on_path(name) @@ -153,47 +164,72 @@ def disable_service(name: str) -> ActionResult: # --------------------------------------------------------------------------- -async def activate(name: str, config: CastleConfig, root: Path) -> ActionResult: - """Make a program reachable in its mode.""" - comp = config.programs.get(name) +def _program_for(name: str, config: CastleConfig): + """The program a deployment runs (its `program` ref, defaulting to the name).""" + prog = name + if name in config.services: + prog = config.services[name].program or name + return prog, config.programs.get(prog) - # Process-backed: daemon, self-serving frontend, or job. - if name in config.services or name in config.jobs: - # Ensure the program's binary is on PATH first (python programs). Skip the - # editable reinstall if it's already there — `castle deploy` installs it, - # so re-running it on every activate is wasted work. + +async def activate(name: str, config: CastleConfig, root: Path) -> ActionResult: + """Make a deployment available in its mode, dispatched by its manager.""" + manager = _svc_manager(name, config) + + if manager == "systemd": + # Ensure the program's binary is on PATH first (python), then enable the + # unit. Skip the editable reinstall if it's already there. + comp = config.programs.get(name) if comp is not None and (comp.stack or comp.commands) and not _on_path(name): res = await run_action("install", name, comp, root) if res.status != "ok": return res return enable_service(name, config) - # Static frontend: build the assets (publish handled by the build/serve path). - if comp is not None and comp.behavior == "frontend": - return await run_action("install", name, comp, root) - - # A daemon with no service can't be "activated" — installing its binary to - # PATH doesn't run it. Direct the user to declare a service instead. - if comp is not None and comp.behavior == "daemon": - return ActionResult( - name, - "activate", - "error", - f"'{name}' is a daemon with no service. Run " - f"'castle service create {name} --program {name} --port ' to deploy it.", + if manager == "caddy": + # Served by the gateway — reload it so the route is live. Building the + # assets is `castle program build` (the program's concern), not activation. + subprocess.run( + ["systemctl", "--user", "reload", unit_name("castle-gateway")], check=False ) + return ActionResult(name, "activate", "ok", f"{name}: served via gateway") - # Tool: install to PATH. + if manager == "path": + prog, comp = _program_for(name, config) + if comp is None: + return ActionResult(name, "activate", "error", f"unknown program '{prog}'") + return await run_action("install", prog, comp, root) + + if manager == "none": + return ActionResult(name, "activate", "ok", f"{name}: external") + + # No deployment — a bare tool program: install to PATH. + comp = config.programs.get(name) if comp is not None: return await run_action("install", name, comp, root) return ActionResult(name, "activate", "error", f"'{name}' not found") async def deactivate(name: str, config: CastleConfig, root: Path) -> ActionResult: - """Take a program offline in its mode.""" - comp = config.programs.get(name) - if name in config.services or name in config.jobs: + """Take a deployment offline in its mode, dispatched by its manager.""" + manager = _svc_manager(name, config) + + if manager == "systemd": return disable_service(name) + if manager == "caddy": + return ActionResult( + name, "deactivate", "ok", + f"{name}: gateway-served — remove/disable the service to drop the route.", + ) + if manager == "path": + prog, comp = _program_for(name, config) + if comp is None: + return ActionResult(name, "deactivate", "error", f"unknown program '{prog}'") + return await run_action("uninstall", prog, comp, root) + if manager == "none": + return ActionResult(name, "deactivate", "ok", f"{name}: external") + + comp = config.programs.get(name) if comp is not None: return await run_action("uninstall", name, comp, root) return ActionResult(name, "deactivate", "error", f"'{name}' not found") diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index 878bcb8..9e93506 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -97,6 +97,30 @@ RunSpec = Annotated[ ] +# A deployment's *manager* — who makes the program available and supervises it — +# is determined by its runner. This is the single source of truth; lifecycle, +# deploy, and status all dispatch on it rather than special-casing runners. +# systemd — a long-running process (or a timer, for jobs) +# caddy — a gateway route (file_server for static; reverse_proxy for a port) +# path — an installed CLI on PATH (via `uv tool install`) +# none — external; we only reference/route it (remote) +_RUNNER_MANAGER: dict[str, str] = { + "python": "systemd", + "command": "systemd", + "container": "systemd", + "compose": "systemd", + "node": "systemd", + "static": "caddy", + "path": "path", + "remote": "none", +} + + +def manager_for(runner: str) -> str: + """The manager (`systemd`|`caddy`|`path`|`none`) that supervises a runner.""" + return _RUNNER_MANAGER.get(runner, "systemd") + + # --------------------- # Systemd management # --------------------- diff --git a/core/tests/test_caddyfile.py b/core/tests/test_caddyfile.py index d3fa569..2a9e3e5 100644 --- a/core/tests/test_caddyfile.py +++ b/core/tests/test_caddyfile.py @@ -10,7 +10,6 @@ from castle_core.generators.caddyfile import ( generate_caddyfile_from_registry, ) from castle_core.manifest import ( - BuildSpec, ExposeSpec, HttpExposeSpec, HttpInternal, diff --git a/core/tests/test_lifecycle.py b/core/tests/test_lifecycle.py index 55157f2..0b7fdc4 100644 --- a/core/tests/test_lifecycle.py +++ b/core/tests/test_lifecycle.py @@ -34,20 +34,17 @@ class TestIsActive: config = load_config(castle_root) assert lifecycle.is_active("does-not-exist", config) is False - def test_static_frontend_active_when_dist_built(self, castle_root: Path, tmp_path: Path) -> None: - from castle_core.manifest import BuildSpec + def test_static_service_active_when_dist_built(self, castle_root: Path, tmp_path: Path) -> None: + # A frontend is a `runner: static` service; active once its served dir exists. + from castle_core.manifest import ProgramSpec, RunStatic, ServiceSpec config = load_config(castle_root) repo = tmp_path / "fe" - config.programs["fe"] = config.programs["test-tool"].model_copy( - update={ - "id": "fe", - "behavior": "frontend", - "source": str(repo), - "build": BuildSpec(commands=[["pnpm", "build"]], outputs=["dist"]), - } + config.programs["fe"] = ProgramSpec(id="fe", source=str(repo)) + config.services["fe"] = ServiceSpec( + program="fe", run=RunStatic(runner="static", root="dist") ) - # No dist yet → inactive + # No dist yet → inactive (caddy manager checks the served dir) assert lifecycle.is_active("fe", config) is False # Built dist → served in place → active (repo / "dist").mkdir(parents=True)