Manager abstraction + manager-dispatched lifecycle; frontends create static services
- 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.
This commit is contained in:
@@ -15,6 +15,7 @@ from castle_cli.manifest import (
|
|||||||
ManageSpec,
|
ManageSpec,
|
||||||
ProgramSpec,
|
ProgramSpec,
|
||||||
RunPython,
|
RunPython,
|
||||||
|
RunStatic,
|
||||||
ServiceSpec,
|
ServiceSpec,
|
||||||
SystemdSpec,
|
SystemdSpec,
|
||||||
)
|
)
|
||||||
@@ -93,11 +94,12 @@ def run_create(args: argparse.Namespace) -> int:
|
|||||||
# Initialize a git repo for the new source.
|
# Initialize a git repo for the new source.
|
||||||
subprocess.run(["git", "init", "-q", str(project_dir)], check=False)
|
subprocess.run(["git", "init", "-q", str(project_dir)], check=False)
|
||||||
|
|
||||||
# Frontend stacks with a static output get a build spec so the gateway emits
|
# Frontend stacks declare a build output; the program builds it, a `static`
|
||||||
# an in-place static route at /<name>/ (no service, no process).
|
# service serves it in place at <name>.<gateway.domain>.
|
||||||
build = None
|
build = None
|
||||||
if stack in STACK_BUILD_OUTPUTS:
|
static_root = STACK_BUILD_OUTPUTS.get(stack)
|
||||||
build = BuildSpec(outputs=[STACK_BUILD_OUTPUTS[stack]])
|
if static_root:
|
||||||
|
build = BuildSpec(outputs=[static_root])
|
||||||
|
|
||||||
config.programs[name] = ProgramSpec(
|
config.programs[name] = ProgramSpec(
|
||||||
id=name,
|
id=name,
|
||||||
@@ -107,7 +109,14 @@ def run_create(args: argparse.Namespace) -> int:
|
|||||||
behavior=behavior,
|
behavior=behavior,
|
||||||
build=build,
|
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()
|
prefix = name.replace("-", "_").upper()
|
||||||
config.services[name] = ServiceSpec(
|
config.services[name] = ServiceSpec(
|
||||||
id=name,
|
id=name,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from castle_core.generators.systemd import (
|
|||||||
timer_name,
|
timer_name,
|
||||||
unit_name,
|
unit_name,
|
||||||
)
|
)
|
||||||
|
from castle_core.manifest import manager_for
|
||||||
from castle_core.registry import REGISTRY_PATH, load_registry
|
from castle_core.registry import REGISTRY_PATH, load_registry
|
||||||
|
|
||||||
from castle_cli.config import (
|
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_dry_run(config, args.name)
|
||||||
return _service_enable(config, args.name)
|
return _service_enable(config, args.name)
|
||||||
if sub == "disable":
|
if sub == "disable":
|
||||||
return _service_disable(args.name)
|
return _service_disable(config, args.name)
|
||||||
if sub in ("start", "stop", "restart"):
|
if sub in ("start", "stop", "restart"):
|
||||||
return _unit_action(config, args.name, sub, is_job=False)
|
return _unit_action(config, args.name, sub, is_job=False)
|
||||||
return 1
|
return 1
|
||||||
@@ -65,7 +66,7 @@ def run_job_cmd(args: argparse.Namespace) -> int:
|
|||||||
if sub == "enable":
|
if sub == "enable":
|
||||||
return _service_enable(config, args.name) # enable_service handles timers
|
return _service_enable(config, args.name) # enable_service handles timers
|
||||||
if sub == "disable":
|
if sub == "disable":
|
||||||
return _service_disable(args.name)
|
return _service_disable(config, args.name)
|
||||||
if sub in ("start", "stop", "restart"):
|
if sub in ("start", "stop", "restart"):
|
||||||
return _unit_action(config, args.name, sub, is_job=True)
|
return _unit_action(config, args.name, sub, is_job=True)
|
||||||
return 1
|
return 1
|
||||||
@@ -84,12 +85,22 @@ def run_platform(args: argparse.Namespace) -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
_GATEWAY_NAME = "castle-gateway"
|
||||||
|
|
||||||
|
|
||||||
def _unit_action(config: CastleConfig, name: str, action: str, is_job: bool) -> int:
|
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
|
section = config.jobs if is_job else config.services
|
||||||
if name not in section:
|
if name not in section:
|
||||||
print(f"Error: no {'job' if is_job else 'service'} '{name}'.")
|
print(f"Error: no {'job' if is_job else 'service'} '{name}'.")
|
||||||
return 1
|
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)
|
unit = timer_name(name) if is_job else unit_name(name)
|
||||||
result = subprocess.run(["systemctl", "--user", action, unit], check=False)
|
result = subprocess.run(["systemctl", "--user", action, unit], check=False)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
@@ -99,12 +110,57 @@ def _unit_action(config: CastleConfig, name: str, action: str, is_job: bool) ->
|
|||||||
return 0
|
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:
|
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:
|
for name in config.jobs:
|
||||||
subprocess.run(["systemctl", "--user", "restart", timer_name(name)], check=False)
|
subprocess.run(["systemctl", "--user", "restart", timer_name(name)], check=False)
|
||||||
print(f" {name}: restarted (timer)")
|
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)
|
subprocess.run(["systemctl", "--user", "restart", unit_name(name)], check=False)
|
||||||
print(f" {name}: restarted")
|
print(f" {name}: restarted")
|
||||||
return 0
|
return 0
|
||||||
@@ -138,21 +194,25 @@ def run_status(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _service_enable(config: CastleConfig, name: str) -> int:
|
def _service_enable(config: CastleConfig, name: str) -> int:
|
||||||
"""Enable and start a single service (or timer for scheduled jobs)."""
|
"""Enable a service in its mode (systemd unit / gateway route / PATH install)."""
|
||||||
from castle_core.lifecycle import enable_service
|
import asyncio
|
||||||
|
|
||||||
|
from castle_core.lifecycle import activate
|
||||||
|
|
||||||
ensure_dirs()
|
ensure_dirs()
|
||||||
result = enable_service(name, config)
|
result = asyncio.run(activate(name, config, config.root))
|
||||||
print(result.output)
|
print(result.output)
|
||||||
return 0 if result.status == "ok" else 1
|
return 0 if result.status == "ok" else 1
|
||||||
|
|
||||||
|
|
||||||
def _service_disable(name: str) -> int:
|
def _service_disable(config: CastleConfig, name: str) -> int:
|
||||||
"""Stop and disable a service (and timer if present)."""
|
"""Disable a service in its mode (stop unit / drop route / uninstall)."""
|
||||||
from castle_core.lifecycle import disable_service
|
import asyncio
|
||||||
|
|
||||||
|
from castle_core.lifecycle import deactivate
|
||||||
|
|
||||||
print(f"Disabling {name}...")
|
print(f"Disabling {name}...")
|
||||||
result = disable_service(name)
|
result = asyncio.run(deactivate(name, config, config.root))
|
||||||
print(f" {result.output}")
|
print(f" {result.output}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -102,12 +102,14 @@ class TestCreateCommand:
|
|||||||
assert (project_dir / "public" / "index.html").exists()
|
assert (project_dir / "public" / "index.html").exists()
|
||||||
assert (project_dir / "supabase.app.yaml").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"]
|
comp = config.programs["guestbook"]
|
||||||
assert comp.behavior == "frontend"
|
assert comp.behavior == "frontend"
|
||||||
assert comp.stack == "supabase"
|
assert comp.stack == "supabase"
|
||||||
assert comp.build is not None and comp.build.outputs == ["public"]
|
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:
|
def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None:
|
||||||
"""Creating a project with existing name fails."""
|
"""Creating a project with existing name fails."""
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ from castle_core.generators.systemd import (
|
|||||||
unit_env_file,
|
unit_env_file,
|
||||||
unit_name,
|
unit_name,
|
||||||
)
|
)
|
||||||
from castle_core.manifest import JobSpec, ServiceSpec
|
from castle_core.manifest import JobSpec, ServiceSpec, manager_for
|
||||||
from castle_core.registry import (
|
from castle_core.registry import (
|
||||||
REGISTRY_PATH,
|
REGISTRY_PATH,
|
||||||
Deployment,
|
Deployment,
|
||||||
@@ -336,9 +336,10 @@ def _build_deployed_service(
|
|||||||
# /data/castle/protonmail). Falls back to the service name.
|
# /data/castle/protonmail). Falls back to the service name.
|
||||||
config_key = svc.program or name
|
config_key = svc.program or name
|
||||||
|
|
||||||
# `remote` and `static` are caddy-managed (or unmanaged) — no local process, no
|
# Only systemd-managed runners get a unit. caddy (static), path (tools), and
|
||||||
# systemd unit. The gateway is their runtime.
|
# none (remote) have no local process — the manager mapping is the single
|
||||||
managed = run.runner not in ("remote", "static")
|
# 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:
|
if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable:
|
||||||
managed = False
|
managed = False
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from castle_core.generators.systemd import (
|
|||||||
unit_env_file,
|
unit_env_file,
|
||||||
unit_name,
|
unit_name,
|
||||||
)
|
)
|
||||||
|
from castle_core.manifest import manager_for
|
||||||
from castle_core.registry import REGISTRY_PATH, load_registry
|
from castle_core.registry import REGISTRY_PATH, load_registry
|
||||||
from castle_core.stacks import ActionResult, run_action
|
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()
|
return (Path.home() / ".local" / "bin" / name).exists()
|
||||||
|
|
||||||
|
|
||||||
def _is_static_frontend(name: str, config: CastleConfig) -> bool:
|
def _svc_manager(name: str, config: CastleConfig) -> str | None:
|
||||||
"""A frontend with no service/job of its own — served as static assets."""
|
"""The manager for a deployed name (service/job), or None if not deployed."""
|
||||||
comp = config.programs.get(name)
|
if name in config.services:
|
||||||
return (
|
return manager_for(config.services[name].run.runner)
|
||||||
comp is not None
|
if name in config.jobs:
|
||||||
and comp.behavior == "frontend"
|
return "systemd"
|
||||||
and name not in config.services
|
return None
|
||||||
and name not in config.jobs
|
|
||||||
)
|
|
||||||
|
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:
|
def is_active(name: str, config: CastleConfig) -> bool:
|
||||||
"""Whether a program is reachable in its mode (uniform across behaviors)."""
|
"""Whether a deployment is available in its mode, dispatched by its manager."""
|
||||||
if name in config.services:
|
manager = _svc_manager(name, config)
|
||||||
return _systemctl_active(unit_name(name))
|
if manager == "systemd":
|
||||||
if name in config.jobs:
|
unit = timer_name(name) if name in config.jobs else unit_name(name)
|
||||||
return _systemctl_active(timer_name(name))
|
return _systemctl_active(unit)
|
||||||
if _is_static_frontend(name, config):
|
if manager == "caddy":
|
||||||
comp = config.programs[name]
|
return _static_built(name, config) # served once its assets exist
|
||||||
if comp.source and comp.build and comp.build.outputs:
|
if manager == "path":
|
||||||
return (Path(comp.source) / comp.build.outputs[0]).is_dir()
|
return _on_path(name)
|
||||||
return False
|
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)
|
comp = config.programs.get(name)
|
||||||
if comp is not None and comp.source:
|
if comp is not None and comp.source:
|
||||||
return _on_path(name)
|
return _on_path(name)
|
||||||
@@ -153,47 +164,72 @@ def disable_service(name: str) -> ActionResult:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
async def activate(name: str, config: CastleConfig, root: Path) -> ActionResult:
|
def _program_for(name: str, config: CastleConfig):
|
||||||
"""Make a program reachable in its mode."""
|
"""The program a deployment runs (its `program` ref, defaulting to the name)."""
|
||||||
comp = config.programs.get(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:
|
async def activate(name: str, config: CastleConfig, root: Path) -> ActionResult:
|
||||||
# Ensure the program's binary is on PATH first (python programs). Skip the
|
"""Make a deployment available in its mode, dispatched by its manager."""
|
||||||
# editable reinstall if it's already there — `castle deploy` installs it,
|
manager = _svc_manager(name, config)
|
||||||
# so re-running it on every activate is wasted work.
|
|
||||||
|
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):
|
if comp is not None and (comp.stack or comp.commands) and not _on_path(name):
|
||||||
res = await run_action("install", name, comp, root)
|
res = await run_action("install", name, comp, root)
|
||||||
if res.status != "ok":
|
if res.status != "ok":
|
||||||
return res
|
return res
|
||||||
return enable_service(name, config)
|
return enable_service(name, config)
|
||||||
|
|
||||||
# Static frontend: build the assets (publish handled by the build/serve path).
|
if manager == "caddy":
|
||||||
if comp is not None and comp.behavior == "frontend":
|
# Served by the gateway — reload it so the route is live. Building the
|
||||||
return await run_action("install", name, comp, root)
|
# assets is `castle program build` (the program's concern), not activation.
|
||||||
|
subprocess.run(
|
||||||
# A daemon with no service can't be "activated" — installing its binary to
|
["systemctl", "--user", "reload", unit_name("castle-gateway")], check=False
|
||||||
# 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 <PORT>' to deploy it.",
|
|
||||||
)
|
)
|
||||||
|
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:
|
if comp is not None:
|
||||||
return await run_action("install", name, comp, root)
|
return await run_action("install", name, comp, root)
|
||||||
return ActionResult(name, "activate", "error", f"'{name}' not found")
|
return ActionResult(name, "activate", "error", f"'{name}' not found")
|
||||||
|
|
||||||
|
|
||||||
async def deactivate(name: str, config: CastleConfig, root: Path) -> ActionResult:
|
async def deactivate(name: str, config: CastleConfig, root: Path) -> ActionResult:
|
||||||
"""Take a program offline in its mode."""
|
"""Take a deployment offline in its mode, dispatched by its manager."""
|
||||||
comp = config.programs.get(name)
|
manager = _svc_manager(name, config)
|
||||||
if name in config.services or name in config.jobs:
|
|
||||||
|
if manager == "systemd":
|
||||||
return disable_service(name)
|
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:
|
if comp is not None:
|
||||||
return await run_action("uninstall", name, comp, root)
|
return await run_action("uninstall", name, comp, root)
|
||||||
return ActionResult(name, "deactivate", "error", f"'{name}' not found")
|
return ActionResult(name, "deactivate", "error", f"'{name}' not found")
|
||||||
|
|||||||
@@ -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
|
# Systemd management
|
||||||
# ---------------------
|
# ---------------------
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from castle_core.generators.caddyfile import (
|
|||||||
generate_caddyfile_from_registry,
|
generate_caddyfile_from_registry,
|
||||||
)
|
)
|
||||||
from castle_core.manifest import (
|
from castle_core.manifest import (
|
||||||
BuildSpec,
|
|
||||||
ExposeSpec,
|
ExposeSpec,
|
||||||
HttpExposeSpec,
|
HttpExposeSpec,
|
||||||
HttpInternal,
|
HttpInternal,
|
||||||
|
|||||||
@@ -34,20 +34,17 @@ class TestIsActive:
|
|||||||
config = load_config(castle_root)
|
config = load_config(castle_root)
|
||||||
assert lifecycle.is_active("does-not-exist", config) is False
|
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:
|
def test_static_service_active_when_dist_built(self, castle_root: Path, tmp_path: Path) -> None:
|
||||||
from castle_core.manifest import BuildSpec
|
# 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)
|
config = load_config(castle_root)
|
||||||
repo = tmp_path / "fe"
|
repo = tmp_path / "fe"
|
||||||
config.programs["fe"] = config.programs["test-tool"].model_copy(
|
config.programs["fe"] = ProgramSpec(id="fe", source=str(repo))
|
||||||
update={
|
config.services["fe"] = ServiceSpec(
|
||||||
"id": "fe",
|
program="fe", run=RunStatic(runner="static", root="dist")
|
||||||
"behavior": "frontend",
|
|
||||||
"source": str(repo),
|
|
||||||
"build": BuildSpec(commands=[["pnpm", "build"]], outputs=["dist"]),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
# No dist yet → inactive
|
# No dist yet → inactive (caddy manager checks the served dir)
|
||||||
assert lifecycle.is_active("fe", config) is False
|
assert lifecycle.is_active("fe", config) is False
|
||||||
# Built dist → served in place → active
|
# Built dist → served in place → active
|
||||||
(repo / "dist").mkdir(parents=True)
|
(repo / "dist").mkdir(parents=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user