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

View File

@@ -29,7 +29,7 @@ def _build_program_group(subparsers: argparse._SubParsersAction) -> None:
sub = prog.add_subparsers(dest="program_command")
p = sub.add_parser("list", help="List programs")
p.add_argument("--behavior", choices=["daemon", "tool", "frontend"], help="Filter by behavior")
p.add_argument("--kind", choices=["service", "job", "tool", "static", "reference"], help="Filter by derived kind")
p.add_argument("--stack", help="Filter by stack")
p.add_argument("--json", action="store_true", help="Output as JSON")
@@ -78,7 +78,7 @@ def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None:
p.add_argument("--program", help="Program this deployment runs (convenience ref)")
p.add_argument("--description", default="", help="Description")
p.add_argument("--run", help="Console script / command to run (default: --program or name)")
p.add_argument("--runner", choices=["python", "command"], default="python")
p.add_argument("--launcher", choices=["python", "command"], default="python")
p.add_argument(
"--env",
action="append",
@@ -167,7 +167,7 @@ def build_parser() -> argparse.ArgumentParser:
# Cross-resource overview
p = subparsers.add_parser("list", help="List programs, services, and jobs")
p.add_argument("--behavior", choices=["daemon", "tool", "frontend"], help="Filter by behavior")
p.add_argument("--kind", choices=["service", "job", "tool", "static", "reference"], help="Filter by derived kind")
p.add_argument("--stack", help="Filter by stack")
p.add_argument("--json", action="store_true", help="Output as JSON")

View File

@@ -3,26 +3,30 @@
from castle_core.manifest import * # noqa: F401, F403
from castle_core.manifest import ( # noqa: F401 — explicit re-exports for type checkers
BuildSpec,
CaddyDeployment,
Capability,
CommandsSpec,
DefaultsSpec,
DeploymentBase,
DeploymentSpec,
EnvMap,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
JobSpec,
LaunchBase,
LaunchSpec,
ManageSpec,
PathDeployment,
ProgramSpec,
ReadinessHttpGet,
RemoteDeployment,
RestartPolicy,
RunBase,
RunCommand,
RunCompose,
RunContainer,
RunNode,
RunPython,
RunRemote,
RunSpec,
ServiceSpec,
SystemdDeployment,
SystemdSpec,
kind_for,
)