Manager-first deployment model: split runner, merge service/job, frontend→static

Replace the conflated `runner` axis with two orthogonal ones: `manager`
(systemd|caddy|path|none) — who supervises/realizes a deployment — and, for
systemd only, a nested `launcher` (python|command|container|compose|node) — how
the process starts. ServiceSpec and JobSpec collapse into one manager-
discriminated DeploymentSpec union (Systemd/Caddy/Path/Remote); the services/
and jobs/ config dirs collapse into one deployments/ dir. The human "kind"
(service|job|tool|static|reference) is fully derived (kind_for), never stored —
the frontend kind is renamed static. behavior is gone.

- core: DeploymentSpec union + LaunchSpec + kind_for; legacy-aware loader
  normalizes old runner shapes; CastleConfig.deployments with derived
  services/jobs/tools views; registry.Deployment carries manager/launcher/kind.
- cli: service/job/tool as filtered views + a deployment group; --behavior→--kind,
  create --runner→--launcher; lifecycle dispatches over config.deployments.
- castle-api: /deployments primary with /services,/jobs as views; summaries
  derive kind; PUT/DELETE /config/deployments/{name} (services/jobs aliased).
- app: KindBadge, frontend→static everywhere, pick-a-kind creation wizard,
  per-kind config editors.
- docs: single deployments/ layout, manager/launcher, static kind throughout.

Live migration verified byte-identical: regenerated Caddyfile and every unit
ExecStart line unchanged, so nothing restarted. Suites: core 124, cli 25,
castle-api 55; dashboard build + type-check clean.
This commit is contained in:
2026-07-01 10:23:03 -07:00
parent 00e8d58c6a
commit 317232ca6a
73 changed files with 1525 additions and 1442 deletions

View File

@@ -91,16 +91,17 @@ def run_add(args: argparse.Namespace) -> int:
source = str(src_path)
name = args.name or src_path.name
if name in config.programs or name in config.services or name in config.jobs:
if name in config.programs or name in config.deployments:
print(f"Error: '{name}' already exists in castle.yaml")
return 1
# Detect verbs from the working copy if we have one on disk.
# Detect verbs from the working copy if we have one on disk. `kind` is derived
# from a deployment, not stored on the program — so `castle add` adopts the
# source only; declare a deployment separately (castle service/job create).
stack: str | None = None
detected_commands: dict[str, list[list[str]]] = {}
behavior = "tool"
if src_path.exists():
stack, detected_commands, behavior = _detect(src_path)
stack, detected_commands, _ = _detect(src_path)
prog = ProgramSpec(
id=name,
@@ -108,7 +109,6 @@ def run_add(args: argparse.Namespace) -> int:
source=source,
stack=stack,
repo=repo_url,
behavior=behavior,
)
# `build` is declared via BuildSpec; every other verb via CommandsSpec.
if detected_commands:

View File

@@ -8,31 +8,31 @@ import subprocess
from castle_cli.config import REPOS_DIR, load_config, save_config
from castle_cli.manifest import (
BuildSpec,
CaddyDeployment,
DefaultsSpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
ManageSpec,
PathDeployment,
ProgramSpec,
RunPath,
RunPython,
RunStatic,
ServiceSpec,
SystemdDeployment,
SystemdSpec,
)
from castle_cli.templates.scaffold import scaffold_project
# Stack determines default behavior and scaffold template
# Stack determines the default deployment kind + scaffold template.
STACK_DEFAULTS: dict[str, str] = {
"python-fastapi": "daemon",
"python-fastapi": "service",
"python-cli": "tool",
"react-vite": "frontend",
"supabase": "frontend",
"react-vite": "static",
"supabase": "static",
}
# Static build output per stack, for `behavior: frontend` programs. The gateway
# serves this dir in place at /<name>/ (no service, no process). A supabase app
# ships a raw `public/`; react-vite builds to `dist/`.
# Static build output per stack, for `static` (caddy) deployments. The gateway
# serves this dir in place at <name>.<gateway.domain> (no service, no process).
# A supabase app ships a raw `public/`; react-vite builds to `dist/`.
STACK_BUILD_OUTPUTS: dict[str, str] = {
"supabase": "public",
"react-vite": "dist",
@@ -42,9 +42,10 @@ STACK_BUILD_OUTPUTS: dict[str, str] = {
def next_available_port(config: object) -> int:
"""Find the next available port starting from 9001 (9000 is reserved for gateway)."""
used_ports = set()
for svc in config.services.values():
if svc.expose and svc.expose.http:
used_ports.add(svc.expose.http.internal.port)
for dep in config.deployments.values():
expose = getattr(dep, "expose", None)
if expose and expose.http:
used_ports.add(expose.http.internal.port)
# Also reserve gateway port
used_ports.add(config.gateway.port)
@@ -59,9 +60,9 @@ def run_create(args: argparse.Namespace) -> int:
config = load_config()
name = args.name
stack = args.stack
behavior = STACK_DEFAULTS.get(stack) if stack else None
kind = STACK_DEFAULTS.get(stack) if stack else None
if name in config.programs or name in config.services or name in config.jobs:
if name in config.programs or name in config.deployments:
print(f"Error: '{name}' already exists in castle.yaml")
return 1
@@ -71,9 +72,9 @@ def run_create(args: argparse.Namespace) -> int:
print(f"Error: directory already exists: {project_dir}")
return 1
# Determine port for daemons
# Determine port for service (daemon) deployments
port = args.port
if behavior == "daemon" and port is None:
if kind == "service" and port is None:
port = next_available_port(config)
package_name = name.replace("-", "_")
@@ -102,32 +103,32 @@ def run_create(args: argparse.Namespace) -> int:
if static_root:
build = BuildSpec(outputs=[static_root])
# `kind` (and thus behavior) is derived from the deployment below — never
# stored on the program.
config.programs[name] = ProgramSpec(
id=name,
description=description,
source=str(project_dir),
stack=stack,
behavior=behavior,
build=build,
)
if behavior == "tool":
if kind == "tool":
# A PATH-managed deployment: installed via `uv tool install`, no unit/route.
config.services[name] = ServiceSpec(
id=name, program=name, run=RunPath(runner="path")
config.deployments[name] = PathDeployment(
id=name, manager="path", program=name
)
elif 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 kind == "static":
# A caddy-managed static deployment: no systemd unit, served from the build dir.
config.deployments[name] = CaddyDeployment(
id=name, manager="caddy", program=name, root=static_root or "dist"
)
elif behavior == "daemon":
elif kind == "service":
prefix = name.replace("-", "_").upper()
config.services[name] = ServiceSpec(
config.deployments[name] = SystemdDeployment(
id=name,
manager="systemd",
program=name,
run=RunPython(runner="python", program=name),
run=RunPython(launcher="python", program=name),
expose=ExposeSpec(
http=HttpExposeSpec(
internal=HttpInternal(port=port),
@@ -143,6 +144,10 @@ def run_create(args: argparse.Namespace) -> int:
),
)
# Populate the derived kind on the in-memory program so readers see the live
# value immediately (it's excluded from disk — kind_of recomputes on load).
config.programs[name].kind = config.kind_of(name)
save_config(config)
label = f"{stack} program" if stack else "bare program"
@@ -158,7 +163,7 @@ def run_create(args: argparse.Namespace) -> int:
print(f" castle deploy && castle gateway reload # serve at /{name}/")
elif stack:
print(" uv sync")
if behavior == "daemon":
if kind == "service":
print(f" uv run {name} # starts on port {port}")
print(f" castle deploy {name}")
print(f" castle test {name}")

View File

@@ -18,29 +18,27 @@ def run_delete(args: argparse.Namespace) -> int:
name = args.name
resource = getattr(args, "resource", None) # "program" | "service" | "job"
# Resolve which sections this delete touches (scoped to one resource).
# Resolve which sections this delete touches (scoped to one resource). Any
# deployment resource name (service/job/tool/static/deployment) targets the
# single deployments/ collection — the kind is derived, not a separate section.
_DEPLOY_RESOURCES = (None, "service", "job", "tool", "static", "deployment")
in_programs = name in config.programs and resource in (None, "program")
in_services = name in config.services and resource in (None, "service")
in_jobs = name in config.jobs and resource in (None, "job")
if not (in_programs or in_services or in_jobs):
in_deployment = name in config.deployments and resource in _DEPLOY_RESOURCES
if not (in_programs or in_deployment):
where = f" {resource}" if resource else ""
print(f"Error: no{where} '{name}' in castle.yaml")
return 1
where = [s for s, present in
(("program", in_programs), ("service", in_services), ("job", in_jobs)) if present]
(("program", in_programs), ("deployment", in_deployment)) if present]
# A program can't be removed while a deployment still references it. Refs
# named the same are removed in this call; any other referencing deployment
# A program can't be removed while a deployment still references it. A ref
# named the same is removed in this call; any other referencing deployment
# would be left dangling, so refuse.
if in_programs:
dangling = [
s for s, spec in config.services.items()
if spec.program == name and not (s == name and in_services)
]
dangling += [
j for j, spec in config.jobs.items()
if spec.program == name and not (j == name and in_jobs)
d for d, spec in config.deployments.items()
if spec.program == name and not (d == name and in_deployment)
]
if dangling:
print(
@@ -72,10 +70,8 @@ def run_delete(args: argparse.Namespace) -> int:
# Remove registry entries.
if in_programs:
del config.programs[name]
if in_services:
del config.services[name]
if in_jobs:
del config.jobs[name]
if in_deployment:
del config.deployments[name]
save_config(config)
print(f"Removed '{name}' from castle.yaml ({', '.join(where)}).")
@@ -88,7 +84,7 @@ def run_delete(args: argparse.Namespace) -> int:
print(f"Source directory not found (already gone): {source_dir}")
# Warn about runtime artifacts we did NOT touch.
if in_services or in_jobs:
if in_deployment:
print(
f"\nNote: the systemd unit for '{name}' (if deployed) was NOT removed.\n"
f" Run: castle service disable {name}"

View File

@@ -15,11 +15,10 @@ from castle_cli.manifest import (
ExposeSpec,
HttpExposeSpec,
HttpInternal,
JobSpec,
ManageSpec,
RunCommand,
RunPython,
ServiceSpec,
SystemdDeployment,
SystemdSpec,
)
@@ -35,17 +34,16 @@ def _defaults(env_args: list[str] | None) -> DefaultsSpec | None:
return DefaultsSpec(env=env)
def _run_spec(runner: str, target: str, name: str) -> RunPython | RunCommand:
if runner == "command":
return RunCommand(runner="command", argv=target.split() or [name])
return RunPython(runner="python", program=target or name)
def _run_spec(launcher: str, target: str, name: str) -> RunPython | RunCommand:
if launcher == "command":
return RunCommand(launcher="command", argv=target.split() or [name])
return RunPython(launcher="python", program=target or name)
def _check_new(config: object, name: str, section: str) -> str | None:
"""Return an error message if the name can't be created, else None."""
existing = getattr(config, section)
if name in existing:
return f"Error: {section[:-1]} '{name}' already exists."
def _check_new(config: object, name: str, label: str) -> str | None:
"""Return an error message if the deployment name is taken, else None."""
if name in config.deployments: # type: ignore[attr-defined]
return f"Error: {label} '{name}' already exists."
return None
@@ -53,11 +51,11 @@ def run_service_create(args: argparse.Namespace) -> int:
"""Create a service entry in castle.yaml."""
config = load_config()
name = args.name
if err := _check_new(config, name, "services"):
if err := _check_new(config, name, "service"):
print(err)
return 1
run = _run_spec(args.runner, args.run or args.program or name, name)
run = _run_spec(args.launcher, args.run or args.program or name, name)
expose = None
proxy = False
@@ -71,8 +69,9 @@ def run_service_create(args: argparse.Namespace) -> int:
# Expose at <name>.<gateway.domain> (the subdomain is the service name).
proxy = not args.no_proxy
config.services[name] = ServiceSpec(
config.deployments[name] = SystemdDeployment(
id=name,
manager="systemd",
program=args.program,
description=args.description or None,
run=run,
@@ -84,7 +83,7 @@ def run_service_create(args: argparse.Namespace) -> int:
save_config(config)
print(f"Created service '{name}'.")
print(f" runs: {args.runner} ({args.run or args.program or name})")
print(f" runs: {args.launcher} ({args.run or args.program or name})")
if expose:
print(f" port: {args.port}")
if proxy:
@@ -97,14 +96,16 @@ def run_job_create(args: argparse.Namespace) -> int:
"""Create a job entry in castle.yaml."""
config = load_config()
name = args.name
if err := _check_new(config, name, "jobs"):
if err := _check_new(config, name, "job"):
print(err)
return 1
run = _run_spec(args.runner, args.run or args.program or name, name)
run = _run_spec(args.launcher, args.run or args.program or name, name)
config.jobs[name] = JobSpec(
# A job is a systemd deployment with a schedule (→ a .timer).
config.deployments[name] = SystemdDeployment(
id=name,
manager="systemd",
program=args.program,
description=args.description or None,
run=run,
@@ -115,7 +116,7 @@ def run_job_create(args: argparse.Namespace) -> int:
save_config(config)
print(f"Created job '{name}'.")
print(f" runs: {args.runner} ({args.run or args.program or 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}")
return 0

View File

@@ -53,16 +53,16 @@ def run_info(args: argparse.Namespace) -> int:
print(f"\n{BOLD}{name}{RESET}")
print(f"{'' * 40}")
# Determine behavior
behavior = None
if program and program.behavior:
behavior = program.behavior
# Determine kind (derived)
kind = None
if program and program.kind:
kind = program.kind
elif service:
behavior = "daemon"
kind = "service"
elif job:
behavior = "tool"
if behavior:
print(f" {BOLD}behavior{RESET}: {behavior}")
kind = "job"
if kind:
print(f" {BOLD}kind{RESET}: {kind}")
# Show stack
stack = None
@@ -97,8 +97,8 @@ def run_info(args: argparse.Namespace) -> int:
if spec.program:
print(f" {BOLD}program{RESET}: {spec.program}")
# Run spec
print(f" {BOLD}runner{RESET}: {spec.run.runner}")
# Launch spec
print(f" {BOLD}launcher{RESET}: {spec.run.launcher}")
if hasattr(spec.run, "program"):
print(f" {BOLD}program{RESET}: {spec.run.program}")
elif hasattr(spec.run, "argv"):
@@ -182,12 +182,12 @@ def _info_json(
data["service"] = service.model_dump(exclude_none=True, exclude={"id"})
if job:
data["job"] = job.model_dump(exclude_none=True, exclude={"id"})
if program and program.behavior:
data["behavior"] = program.behavior
if program and program.kind:
data["kind"] = program.kind
elif service:
data["behavior"] = "daemon"
data["kind"] = "service"
elif job:
data["behavior"] = "tool"
data["kind"] = "job"
# Resolve stack
stack = None
@@ -202,7 +202,8 @@ def _info_json(
if deployed:
data["deployed"] = {
"runner": deployed.runner,
"manager": deployed.manager,
"launcher": deployed.launcher,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"secret_env_keys": deployed.secret_env_keys,

View File

@@ -20,10 +20,12 @@ CYAN = "\033[96m"
MAGENTA = "\033[95m"
YELLOW = "\033[93m"
BEHAVIOR_COLORS: dict[str, str] = {
"daemon": GREEN,
KIND_COLORS: dict[str, str] = {
"service": GREEN,
"job": MAGENTA,
"tool": CYAN,
"frontend": YELLOW,
"static": YELLOW,
"reference": DIM,
}
STACK_DISPLAY: dict[str, str] = {
@@ -62,20 +64,20 @@ def _resolve_stack(config: object, name: str) -> str | None:
def run_list(args: argparse.Namespace) -> int:
"""List all programs, services, and jobs.
Two orthogonal axes: the **Programs** catalog (filtered by real `behavior`)
and the **Services**/**Jobs** deployment views. `--behavior` filters the
catalog only — it's a property of a program, not of a deployment.
Two orthogonal axes: the **Programs** catalog (filtered by derived `kind`)
and the **Services**/**Jobs** deployment views. `--kind` filters the catalog
by a program's derived kind (service/job/tool/static/reference).
"""
from castle_core.lifecycle import is_active
config = load_config()
filter_behavior = getattr(args, "behavior", None)
filter_kind = getattr(args, "kind", None)
filter_stack = getattr(args, "stack", None)
resource = getattr(args, "resource", None) # scope to one section, or all
if getattr(args, "json", False):
return _list_json(config, filter_behavior, filter_stack)
return _list_json(config, filter_kind, filter_stack)
def dot(name: str) -> str:
return f"{GREEN}{RESET}" if is_active(name, config) else f"{RED}{RESET}"
@@ -87,7 +89,7 @@ def run_list(args: argparse.Namespace) -> int:
{
name: comp
for name, comp in config.programs.items()
if (not filter_behavior or comp.behavior == filter_behavior)
if (not filter_kind or comp.kind == filter_kind)
and (not filter_stack or comp.stack == filter_stack)
}
if resource in (None, "program")
@@ -98,20 +100,20 @@ def run_list(args: argparse.Namespace) -> int:
print(f"\n{BOLD}{CYAN}Programs{RESET}")
print(f"{CYAN}{'' * 40}{RESET}")
for name, comp in progs.items():
behavior = comp.behavior or "program"
bcolor = BEHAVIOR_COLORS.get(behavior, "")
behavior_str = f" {bcolor}{behavior}{RESET}"
kind = comp.kind or "program"
bcolor = KIND_COLORS.get(kind, "")
behavior_str = f" {bcolor}{kind}{RESET}"
stack_str = f" {DIM}{comp.stack}{RESET}" if comp.stack else ""
desc = f" {DIM}{comp.description}{RESET}" if comp.description else ""
print(f" {dot(name)} {BOLD}{name}{RESET}{behavior_str}{stack_str}{desc}")
# Services + Jobs (deployment views) — independent of behavior, so only shown
# when no behavior filter is applied. Each gated by its own resource scope.
if not filter_behavior and resource in (None, "service"):
if not filter_kind and resource in (None, "service"):
services = _filter_by_stack(config.services, config, filter_stack)
if services:
any_output = True
color = BEHAVIOR_COLORS["daemon"]
color = KIND_COLORS["service"]
print(f"\n{BOLD}{color}Services{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, svc in services.items():
@@ -123,7 +125,7 @@ def run_list(args: argparse.Namespace) -> int:
desc = f" {DIM}{svc.description}{RESET}" if svc.description else ""
print(f" {dot(name)} {BOLD}{name}{RESET}{port_str}{stack_str}{desc}")
if not filter_behavior and resource in (None, "job"):
if not filter_kind and resource in (None, "job"):
jobs = _filter_by_stack(config.jobs, config, filter_stack)
if jobs:
any_output = True
@@ -158,24 +160,23 @@ def _filter_by_stack(
def _list_json(
config: object,
filter_behavior: str | None,
filter_kind: str | None,
filter_stack: str | None,
) -> int:
"""Output JSON: the program catalog (behavior-filterable) plus deployments."""
"""Output JSON: the program catalog (kind-filterable) plus deployments."""
from castle_core.lifecycle import is_active
output = []
# Programs (catalog) — filtered by real behavior + stack
# Programs (catalog) — filtered by derived kind + stack
for name, comp in config.programs.items():
if filter_behavior and comp.behavior != filter_behavior:
if filter_kind and comp.kind != filter_kind:
continue
if filter_stack and comp.stack != filter_stack:
continue
entry: dict = {
"name": name,
"kind": "program",
"behavior": comp.behavior,
"kind": comp.kind,
"active": is_active(name, config),
}
if comp.stack:
@@ -184,8 +185,8 @@ def _list_json(
entry["description"] = comp.description
output.append(entry)
# Services + Jobs (deployments) — only when not filtering by behavior
if not filter_behavior:
# Services + Jobs (deployments) — only when not filtering by kind
if not filter_kind:
for name, svc in config.services.items():
stack = _resolve_stack(config, name)
if filter_stack and stack != filter_stack:

View File

@@ -14,20 +14,19 @@ def run_logs(args: argparse.Namespace) -> int:
config = load_config()
name = args.name
# Check services
if name in config.services:
svc = config.services[name]
if svc.run.runner == "container":
dep = config.deployments.get(name)
if dep is not None and dep.manager == "systemd":
if dep.run.launcher == "container":
return _container_logs(name, args)
if svc.run.runner == "compose":
return _compose_logs(name, svc, args)
if dep.run.launcher == "compose":
return _compose_logs(name, dep, args)
return _systemd_logs(name, args)
# Check jobs
if name in config.jobs:
return _systemd_logs(name, args)
if dep is not None:
print(f"Error: '{name}' has no logs (manager: {dep.manager}).")
return 1
print(f"Error: '{name}' not found in services or jobs")
print(f"Error: '{name}' not found in deployments")
return 1

View File

@@ -12,7 +12,7 @@ from castle_core.generators.systemd import (
timer_name,
unit_name,
)
from castle_core.manifest import manager_for
from castle_core.manifest import kind_for
from castle_core.registry import REGISTRY_PATH, load_registry
from castle_cli.config import (
@@ -94,14 +94,15 @@ def _unit_action(config: CastleConfig, name: str, action: str, is_job: bool) ->
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}'.")
dep = config.deployments.get(name)
if dep is None:
print(f"Error: no deployment '{name}'.")
return 1
manager = "systemd" if is_job else manager_for(section[name].run.runner)
manager = dep.manager
if manager != "systemd":
return _managed_lifecycle(config, name, action, manager)
unit = timer_name(name) if is_job else unit_name(name)
# A scheduled systemd deployment (a job) is driven by its .timer.
unit = timer_name(name) if kind_for(dep) == "job" else unit_name(name)
result = subprocess.run(["systemctl", "--user", action, unit], check=False)
if result.returncode != 0:
print(f"Error: failed to {action} {unit}")
@@ -143,19 +144,20 @@ def _path_lifecycle(config: CastleConfig, name: str, action: str) -> int:
def _services_restart(config: CastleConfig) -> int:
"""Restart every systemd-managed service and job unit.
"""Restart every systemd-managed deployment (service or job) unit.
caddy/path/none runners have no unit — they ride along with the gateway
caddy/path/none deployments 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, svc in config.services.items():
if manager_for(svc.run.runner) != "systemd":
for name, dep in config.deployments.items():
if dep.manager != "systemd":
continue
subprocess.run(["systemctl", "--user", "restart", unit_name(name)], check=False)
print(f" {name}: restarted")
if kind_for(dep) == "job":
subprocess.run(["systemctl", "--user", "restart", timer_name(name)], check=False)
print(f" {name}: restarted (timer)")
else:
subprocess.run(["systemctl", "--user", "restart", unit_name(name)], check=False)
print(f" {name}: restarted")
return 0
@@ -181,7 +183,7 @@ def run_status(args: argparse.Namespace) -> int:
on = is_active(name, config)
color = "\033[92m" if on else "\033[90m"
label = "active" if on else "inactive"
print(f" {color}{label:10s}\033[0m {name} ({comp.behavior or 'program'})")
print(f" {color}{label:10s}\033[0m {name} ({comp.kind or 'program'})")
print()
return 0
@@ -219,7 +221,7 @@ def _service_status(config: CastleConfig) -> int:
for name, svc in config.services.items():
active = is_active(name, config) # manager-aware (systemd/caddy/path/none)
manager = manager_for(svc.run.runner)
manager = svc.manager
color = "\033[92m" if active else "\033[90m"
reset = "\033[0m"
label = "active" if active else "inactive"
@@ -260,14 +262,10 @@ def _service_dry_run(config: CastleConfig, name: str) -> int:
if name in registry.deployed:
deployed = registry.deployed[name]
systemd_spec = None
if name in config.services:
svc = config.services[name]
if svc.manage and svc.manage.systemd:
systemd_spec = svc.manage.systemd
elif name in config.jobs:
job = config.jobs[name]
if job.manage and job.manage.systemd:
systemd_spec = job.manage.systemd
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)
@@ -304,13 +302,9 @@ def _services_start(config: CastleConfig) -> int:
caddyfile_path.write_text(generate_caddyfile_from_registry(registry))
print(f"Generated {caddyfile_path}")
for name in config.services:
if name not in registry.deployed:
print(f" {name}: skipped (not in registry, run 'castle deploy')")
continue
_service_enable(config, name)
for name in config.jobs:
# 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