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:
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user