cli: resolve deployments by (name, kind), not bare name

Thread kind through every CLI resolver so a tool, service, and job can
share a name. Reads go through config.deployment(kind,name) /
deployments_named(name) / all_deployments(); writes target the per-kind
store (config.services/jobs/tools/statics). delete cascades over
(kind,name) tuples; run/info resolve via registry.named().
This commit is contained in:
2026-07-06 02:44:25 -07:00
parent 315afe3f5f
commit 0cb41851cf
16 changed files with 106 additions and 126 deletions

View File

@@ -16,10 +16,7 @@ from castle_cli.manifest import BuildSpec, CommandsSpec, ProgramSpec
def _is_git_url(s: str) -> bool: def _is_git_url(s: str) -> bool:
return ( return s.startswith(("http://", "https://", "git@", "ssh://")) or s.endswith(".git")
s.startswith(("http://", "https://", "git@", "ssh://"))
or s.endswith(".git")
)
def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]]]: def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]]]:
@@ -87,7 +84,7 @@ def run_add(args: argparse.Namespace) -> int:
source = str(src_path) source = str(src_path)
name = args.name or src_path.name name = args.name or src_path.name
if name in config.programs or name in config.deployments: if name in config.programs or config.deployments_named(name):
print(f"Error: '{name}' already exists in castle.yaml") print(f"Error: '{name}' already exists in castle.yaml")
return 1 return 1

View File

@@ -13,9 +13,9 @@ from __future__ import annotations
import argparse import argparse
_C = { _C = {
"activate": "\033[32m", # green "activate": "\033[32m", # green
"restart": "\033[33m", # yellow "restart": "\033[33m", # yellow
"deactivate": "\033[31m", # red "deactivate": "\033[31m", # red
"reset": "\033[0m", "reset": "\033[0m",
"dim": "\033[90m", "dim": "\033[90m",
} }

View File

@@ -51,7 +51,7 @@ STACK_REQUIRES: dict[str, list[Requirement]] = {
def next_available_port(config: object) -> int: def next_available_port(config: object) -> int:
"""Find the next available port starting from 9001 (9000 is reserved for gateway).""" """Find the next available port starting from 9001 (9000 is reserved for gateway)."""
used_ports = set() used_ports = set()
for dep in config.deployments.values(): for _k, _n, dep in config.all_deployments():
expose = getattr(dep, "expose", None) expose = getattr(dep, "expose", None)
if expose and expose.http: if expose and expose.http:
used_ports.add(expose.http.internal.port) used_ports.add(expose.http.internal.port)
@@ -71,7 +71,7 @@ def run_create(args: argparse.Namespace) -> int:
stack = args.stack stack = args.stack
kind = 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.deployments: if name in config.programs or config.deployments_named(name):
print(f"Error: '{name}' already exists in castle.yaml") print(f"Error: '{name}' already exists in castle.yaml")
return 1 return 1
@@ -125,12 +125,12 @@ def run_create(args: argparse.Namespace) -> int:
) )
if kind == "tool": if kind == "tool":
# A PATH-managed deployment: installed via `uv tool install`, no unit/route. # A PATH-managed deployment: installed via `uv tool install`, no unit/route.
config.deployments[name] = PathDeployment( config.tools[name] = PathDeployment(
id=name, manager="path", program=name, description=description id=name, manager="path", program=name, description=description
) )
elif kind == "static": elif kind == "static":
# A caddy-managed static deployment: no systemd unit, served from the build dir. # A caddy-managed static deployment: no systemd unit, served from the build dir.
config.deployments[name] = CaddyDeployment( config.statics[name] = CaddyDeployment(
id=name, id=name,
manager="caddy", manager="caddy",
program=name, program=name,
@@ -139,7 +139,7 @@ def run_create(args: argparse.Namespace) -> int:
) )
elif kind == "service": elif kind == "service":
prefix = name.replace("-", "_").upper() prefix = name.replace("-", "_").upper()
config.deployments[name] = SystemdDeployment( config.services[name] = SystemdDeployment(
id=name, id=name,
manager="systemd", manager="systemd",
program=name, program=name,

View File

@@ -31,7 +31,8 @@ def run_delete(args: argparse.Namespace) -> int:
# single deployments/ collection — the kind is derived, not a separate section. # single deployments/ collection — the kind is derived, not a separate section.
_DEPLOY_RESOURCES = (None, "service", "job", "tool", "static", "deployment") _DEPLOY_RESOURCES = (None, "service", "job", "tool", "static", "deployment")
in_programs = name in config.programs and resource in (None, "program") in_programs = name in config.programs and resource in (None, "program")
in_deployment = name in config.deployments and resource in _DEPLOY_RESOURCES named = config.deployments_named(name) # [(kind, spec), ...] sharing this name
in_deployment = bool(named) and resource in _DEPLOY_RESOURCES
if not (in_programs or in_deployment): if not (in_programs or in_deployment):
where = f" {resource}" if resource else "" where = f" {resource}" if resource else ""
print(f"Error: no{where} '{name}' in castle.yaml") print(f"Error: no{where} '{name}' in castle.yaml")
@@ -44,13 +45,16 @@ def run_delete(args: argparse.Namespace) -> int:
# Cascade: every deployment referencing this program is torn down and removed # Cascade: every deployment referencing this program is torn down and removed
# (a program and its 1:1 service/tool/static are one thing to the user). A # (a program and its 1:1 service/tool/static are one thing to the user). A
# deployment-only delete targets just the co-named deployment. # deployment-only delete targets just the co-named deployment.
deployments_to_remove: list[str] = [] # Each entry is (kind, name) — the deployment's identity.
deployments_to_remove: list[tuple[str, str]] = []
if in_programs: if in_programs:
deployments_to_remove = [ deployments_to_remove = [
d for d, spec in config.deployments.items() if spec.program == name (kind, n) for kind, n, spec in config.all_deployments() if spec.program == name
] ]
if in_deployment and name not in deployments_to_remove: if in_deployment:
deployments_to_remove.append(name) for kind, _spec in named:
if (kind, name) not in deployments_to_remove:
deployments_to_remove.append((kind, name))
# Capture remnant facts BEFORE mutating config: public CNAMEs, and the program # Capture remnant facts BEFORE mutating config: public CNAMEs, and the program
# entry itself — its stack may own persistent data (a DB schema) that survives # entry itself — its stack may own persistent data (a DB schema) that survives
@@ -71,7 +75,10 @@ def run_delete(args: argparse.Namespace) -> int:
purge_data = getattr(args, "purge_data", False) purge_data = getattr(args, "purge_data", False)
print(f"Will remove '{name}' from castle.yaml ({', '.join(where)}).") print(f"Will remove '{name}' from castle.yaml ({', '.join(where)}).")
if deployments_to_remove: if deployments_to_remove:
print(f"Will tear down deployment(s): {', '.join(deployments_to_remove)}") print(
"Will tear down deployment(s): "
+ ", ".join(f"{n} ({k})" for k, n in deployments_to_remove)
)
if args.source and source_dir: if args.source and source_dir:
print(f"Will ALSO delete source directory: {source_dir}") print(f"Will ALSO delete source directory: {source_dir}")
if owns_data and purge_data: if owns_data and purge_data:
@@ -95,14 +102,14 @@ def run_delete(args: argparse.Namespace) -> int:
from castle_core.lifecycle import deactivate from castle_core.lifecycle import deactivate
for d in deployments_to_remove: for kind, d in deployments_to_remove:
try: try:
res = asyncio.run(deactivate(d, config, config.root)) res = asyncio.run(deactivate(d, kind, config, config.root))
if getattr(res, "message", None): if getattr(res, "message", None):
print(f" {res.message}") print(f" {res.message}")
except Exception as e: except Exception as e:
print(f" warning: teardown of '{d}' failed: {e}") print(f" warning: teardown of '{d}' failed: {e}")
del config.deployments[d] config.store_for(kind).pop(d, None)
if in_programs: if in_programs:
del config.programs[name] del config.programs[name]
@@ -155,7 +162,7 @@ def run_delete(args: argparse.Namespace) -> int:
return 0 return 0
def _public_hosts(config, deployment_names: list[str]) -> list[str]: def _public_hosts(config, deployments: list[tuple[str, str]]) -> list[str]:
"""The Cloudflare CNAMEs (<subdomain>.<public_domain>) of any public deployments """The Cloudflare CNAMEs (<subdomain>.<public_domain>) of any public deployments
being removed — surfaced so the operator can clean up DNS.""" being removed — surfaced so the operator can clean up DNS."""
gw = getattr(config, "gateway", None) gw = getattr(config, "gateway", None)
@@ -163,8 +170,8 @@ def _public_hosts(config, deployment_names: list[str]) -> list[str]:
if not public_domain: if not public_domain:
return [] return []
hosts: list[str] = [] hosts: list[str] = []
for d in deployment_names: for kind, d in deployments:
spec = config.deployments.get(d) spec = config.deployment(kind, d)
if spec is None or not getattr(spec, "public", False): if spec is None or not getattr(spec, "public", False):
continue continue
sub = getattr(spec, "subdomain", None) or d sub = getattr(spec, "subdomain", None) or d

View File

@@ -43,7 +43,7 @@ def _run_spec(launcher: str, target: str, name: str) -> RunPython | RunCommand:
def _check_new(config: object, name: str, label: str) -> str | None: def _check_new(config: object, name: str, label: str) -> str | None:
"""Return an error message if the deployment name is taken, else None.""" """Return an error message if the deployment name is taken, else None."""
if name in config.deployments: # type: ignore[attr-defined] if config.deployments_named(name):
return f"Error: {label} '{name}' already exists." return f"Error: {label} '{name}' already exists."
return None return None
@@ -70,7 +70,7 @@ def run_service_create(args: argparse.Namespace) -> int:
# Expose at <name>.<gateway.domain> (the subdomain is the service name). # Expose at <name>.<gateway.domain> (the subdomain is the service name).
reach = Reach.OFF if args.no_proxy else Reach.INTERNAL reach = Reach.OFF if args.no_proxy else Reach.INTERNAL
config.deployments[name] = SystemdDeployment( config.services[name] = SystemdDeployment(
id=name, id=name,
manager="systemd", manager="systemd",
program=args.program, program=args.program,
@@ -104,7 +104,7 @@ def run_job_create(args: argparse.Namespace) -> int:
run = _run_spec(args.launcher, args.run or args.program or name, name) run = _run_spec(args.launcher, args.run or args.program or name, name)
# A job is a systemd deployment with a schedule (→ a .timer). # A job is a systemd deployment with a schedule (→ a .timer).
config.deployments[name] = SystemdDeployment( config.jobs[name] = SystemdDeployment(
id=name, id=name,
manager="systemd", manager="systemd",
program=args.program, program=args.program,

View File

@@ -136,11 +136,9 @@ def _check_configuration(config) -> list[Check]:
) )
) )
missing = [n for n in (_GATEWAY, _API, _DASHBOARD) if n not in config.deployments] missing = [n for n in (_GATEWAY, _API, _DASHBOARD) if not config.deployments_named(n)]
if not missing: if not missing:
checks.append( checks.append(Check(OK, "control plane registered", detail="gateway, api, dashboard"))
Check(OK, "control plane registered", detail="gateway, api, dashboard")
)
else: else:
checks.append( checks.append(
Check( Check(
@@ -158,7 +156,7 @@ def _check_configuration(config) -> list[Check]:
def _check_dashboard_built(config) -> Check: def _check_dashboard_built(config) -> Check:
from castle_core.lifecycle import _static_built from castle_core.lifecycle import _static_built
if _DASHBOARD not in config.deployments: if not config.deployments_named(_DASHBOARD):
return Check(WARN, "dashboard not registered", detail="skipping build check") return Check(WARN, "dashboard not registered", detail="skipping build check")
if _static_built(_DASHBOARD, config): if _static_built(_DASHBOARD, config):
return Check(OK, "dashboard built", detail="app/dist/") return Check(OK, "dashboard built", detail="app/dist/")
@@ -174,7 +172,7 @@ def _check_dashboard_built(config) -> Check:
def _deployment_port(config, name: str) -> int | None: def _deployment_port(config, name: str) -> int | None:
dep = config.deployments.get(name) dep = next((d for _k, d in config.deployments_named(name)), None)
expose = getattr(dep, "expose", None) expose = getattr(dep, "expose", None)
http = getattr(expose, "http", None) http = getattr(expose, "http", None)
internal = getattr(http, "internal", None) internal = getattr(http, "internal", None)
@@ -189,7 +187,7 @@ def _check_runtime(config) -> list[Check]:
# Gateway: active + actually listening on its port. # Gateway: active + actually listening on its port.
gw_port = config.gateway.port gw_port = config.gateway.port
if is_active(_GATEWAY, config): if is_active(_GATEWAY, "service", config):
if _port_open(gw_port): if _port_open(gw_port):
checks.append(Check(OK, "gateway running", detail=f"listening on :{gw_port}")) checks.append(Check(OK, "gateway running", detail=f"listening on :{gw_port}"))
else: else:
@@ -212,7 +210,7 @@ def _check_runtime(config) -> list[Check]:
# API: active + listening on its port. # API: active + listening on its port.
api_port = _deployment_port(config, _API) api_port = _deployment_port(config, _API)
if is_active(_API, config): if is_active(_API, "service", config):
if api_port and not _port_open(api_port): if api_port and not _port_open(api_port):
checks.append( checks.append(
Check( Check(
@@ -226,9 +224,7 @@ def _check_runtime(config) -> list[Check]:
detail = f"listening on :{api_port}" if api_port else "" detail = f"listening on :{api_port}" if api_port else ""
checks.append(Check(OK, "castle-api running", detail=detail)) checks.append(Check(OK, "castle-api running", detail=detail))
else: else:
checks.append( checks.append(Check(FAIL, "castle-api not running", hint="castle apply"))
Check(FAIL, "castle-api not running", hint="castle apply")
)
# Generated artifacts. # Generated artifacts.
registry = SPECS_DIR / "registry.yaml" registry = SPECS_DIR / "registry.yaml"
@@ -236,9 +232,7 @@ def _check_runtime(config) -> list[Check]:
if registry.exists() and caddyfile.exists(): if registry.exists() and caddyfile.exists():
checks.append(Check(OK, "registry + Caddyfile generated")) checks.append(Check(OK, "registry + Caddyfile generated"))
else: else:
missing = [ missing = [p.name for p in (registry, caddyfile) if not p.exists()]
p.name for p in (registry, caddyfile) if not p.exists()
]
checks.append( checks.append(
Check( Check(
FAIL, FAIL,
@@ -319,14 +313,12 @@ def _check_tls_exposure(config) -> list[Check]:
checks.append(_check_privileged_ports()) checks.append(_check_privileged_ports())
# Public exposure (only relevant if a deployment opts in). # Public exposure (only relevant if a deployment opts in).
public = [n for n, d in config.deployments.items() if getattr(d, "public", False)] public = [n for _k, n, d in config.all_deployments() if getattr(d, "public", False)]
if public: if public:
from castle_core.lifecycle import is_active from castle_core.lifecycle import is_active
if gw.public_domain and gw.tunnel_id: if gw.public_domain and gw.tunnel_id:
checks.append( checks.append(Check(OK, "tunnel configured", detail=f"{len(public)} public service(s)"))
Check(OK, "tunnel configured", detail=f"{len(public)} public service(s)")
)
else: else:
missing = [] missing = []
if not gw.public_domain: if not gw.public_domain:
@@ -341,7 +333,7 @@ def _check_tls_exposure(config) -> list[Check]:
hint="see docs/tunnel-setup.md", hint="see docs/tunnel-setup.md",
) )
) )
if not is_active("castle-tunnel", config): if not is_active("castle-tunnel", "service", config):
checks.append( checks.append(
Check( Check(
WARN, WARN,
@@ -418,9 +410,7 @@ def _check_public_dns(config) -> Check:
def _check_privileged_ports() -> Check: def _check_privileged_ports() -> Check:
try: try:
val = int( val = int(Path("/proc/sys/net/ipv4/ip_unprivileged_port_start").read_text().strip())
Path("/proc/sys/net/ipv4/ip_unprivileged_port_start").read_text().strip()
)
except (OSError, ValueError): except (OSError, ValueError):
return Check(WARN, "cannot read unprivileged port floor") return Check(WARN, "cannot read unprivileged port floor")
if val <= 80: if val <= 80:

View File

@@ -23,7 +23,7 @@ def _load_deployed_program(name: str) -> object | None:
from castle_core.registry import load_registry from castle_core.registry import load_registry
registry = load_registry() registry = load_registry()
return registry.deployed.get(name) return next(iter(registry.named(name)), None)
except (FileNotFoundError, ValueError): except (FileNotFoundError, ValueError):
return None return None
@@ -127,8 +127,7 @@ def run_info(args: argparse.Namespace) -> int:
# Raw-TCP reach is internal-only (public TCP is rejected at load, see # Raw-TCP reach is internal-only (public TCP is rejected at load, see
# SystemdDeployment._validate_reach), so there's no "public" state here. # SystemdDeployment._validate_reach), so there's no "public" state here.
print( print(
f" {BOLD}tcp{RESET}: " f" {BOLD}tcp{RESET}: {name}.<gateway.domain>:{service.tcp_port} (internal)"
f"{name}.<gateway.domain>:{service.tcp_port} (internal)"
) )
if service.manage and service.manage.systemd: if service.manage and service.manage.systemd:
sd = service.manage.systemd sd = service.manage.systemd

View File

@@ -7,8 +7,6 @@ import argparse
import json import json
import logging import logging
from castle_core.manifest import kind_for
from castle_cli.config import load_config from castle_cli.config import load_config
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -16,7 +14,8 @@ log = logging.getLogger(__name__)
def _deployments_of_kind(config: object, kind: str) -> dict: def _deployments_of_kind(config: object, kind: str) -> dict:
"""The deployments whose derived kind matches (a lens over config.deployments).""" """The deployments whose derived kind matches (a lens over config.deployments)."""
return {n: d for n, d in config.deployments.items() if kind_for(d) == kind} return config.store_for(kind)
# Terminal colors # Terminal colors
BOLD = "\033[1m" BOLD = "\033[1m"
@@ -87,8 +86,8 @@ def run_list(args: argparse.Namespace) -> int:
if getattr(args, "json", False): if getattr(args, "json", False):
return _list_json(config, filter_kind, filter_stack) return _list_json(config, filter_kind, filter_stack)
def dot(name: str) -> str: def dot(name: str, kind: str = "service") -> str:
return f"{GREEN}{RESET}" if is_active(name, config) else f"{RED}{RESET}" return f"{GREEN}{RESET}" if is_active(name, kind, config) else f"{RED}{RESET}"
any_output = False any_output = False
@@ -114,12 +113,11 @@ def run_list(args: argparse.Namespace) -> int:
print(f"{CYAN}{'' * 40}{RESET}") print(f"{CYAN}{'' * 40}{RESET}")
for name, comp in progs.items(): for name, comp in progs.items():
kinds = prog_kinds(name) kinds = prog_kinds(name)
kinds_str = "".join( kinds_str = "".join(f" {KIND_COLORS.get(k, '')}{k}{RESET}" for k in kinds)
f" {KIND_COLORS.get(k, '')}{k}{RESET}" for k in kinds
)
stack_str = f" {DIM}{comp.stack}{RESET}" if comp.stack else "" stack_str = f" {DIM}{comp.stack}{RESET}" if comp.stack else ""
desc = f" {DIM}{comp.description}{RESET}" if comp.description else "" desc = f" {DIM}{comp.description}{RESET}" if comp.description else ""
print(f" {dot(name)} {BOLD}{name}{RESET}{kinds_str}{stack_str}{desc}") pk = (prog_kinds(name) or ["service"])[0]
print(f" {dot(name, pk)} {BOLD}{name}{RESET}{kinds_str}{stack_str}{desc}")
# Services + Jobs (deployment views) — independent of behavior, so only shown # Services + Jobs (deployment views) — independent of behavior, so only shown
# when no behavior filter is applied. Each gated by its own resource scope. # when no behavior filter is applied. Each gated by its own resource scope.
@@ -137,7 +135,7 @@ def run_list(args: argparse.Namespace) -> int:
stack = _resolve_stack(config, name) stack = _resolve_stack(config, name)
stack_str = f" {DIM}{stack}{RESET}" if stack else "" stack_str = f" {DIM}{stack}{RESET}" if stack else ""
desc = f" {DIM}{svc.description}{RESET}" if svc.description else "" desc = f" {DIM}{svc.description}{RESET}" if svc.description else ""
print(f" {dot(name)} {BOLD}{name}{RESET}{port_str}{stack_str}{desc}") print(f" {dot(name, 'service')} {BOLD}{name}{RESET}{port_str}{stack_str}{desc}")
if not filter_kind and resource in (None, "job"): if not filter_kind and resource in (None, "job"):
jobs = _filter_by_stack(config.jobs, config, filter_stack) jobs = _filter_by_stack(config.jobs, config, filter_stack)
@@ -148,7 +146,7 @@ def run_list(args: argparse.Namespace) -> int:
for name, job in jobs.items(): for name, job in jobs.items():
sched = f" {DIM}[{job.schedule}]{RESET}" sched = f" {DIM}[{job.schedule}]{RESET}"
desc = f" {DIM}{job.description}{RESET}" if job.description else "" desc = f" {DIM}{job.description}{RESET}" if job.description else ""
print(f" {dot(name)} {BOLD}{name}{RESET}{sched}{desc}") print(f" {dot(name, 'job')} {BOLD}{name}{RESET}{sched}{desc}")
if not filter_kind and resource in (None, "tool"): if not filter_kind and resource in (None, "tool"):
tools = _filter_by_stack(_deployments_of_kind(config, "tool"), config, filter_stack) tools = _filter_by_stack(_deployments_of_kind(config, "tool"), config, filter_stack)
@@ -161,7 +159,7 @@ def run_list(args: argparse.Namespace) -> int:
stack = _resolve_stack(config, name) stack = _resolve_stack(config, name)
stack_str = f" {DIM}{stack}{RESET}" if stack else "" stack_str = f" {DIM}{stack}{RESET}" if stack else ""
desc = f" {DIM}{d.description}{RESET}" if d.description else "" desc = f" {DIM}{d.description}{RESET}" if d.description else ""
print(f" {dot(name)} {BOLD}{name}{RESET}{stack_str}{desc}") print(f" {dot(name, 'tool')} {BOLD}{name}{RESET}{stack_str}{desc}")
if not filter_kind and resource in (None, "static"): if not filter_kind and resource in (None, "static"):
statics = _filter_by_stack(_deployments_of_kind(config, "static"), config, filter_stack) statics = _filter_by_stack(_deployments_of_kind(config, "static"), config, filter_stack)
@@ -173,7 +171,7 @@ def run_list(args: argparse.Namespace) -> int:
for name, d in statics.items(): for name, d in statics.items():
sub = f" {DIM}{name}.<domain>{RESET}" sub = f" {DIM}{name}.<domain>{RESET}"
desc = f" {DIM}{d.description}{RESET}" if d.description else "" desc = f" {DIM}{d.description}{RESET}" if d.description else ""
print(f" {dot(name)} {BOLD}{name}{RESET}{sub}{desc}") print(f" {dot(name, 'static')} {BOLD}{name}{RESET}{sub}{desc}")
if not any_output: if not any_output:
print(f"No {resource or 'program'}s found.") print(f"No {resource or 'program'}s found.")
@@ -191,9 +189,7 @@ def _filter_by_stack(
if not filter_stack: if not filter_stack:
return items return items
return { return {
name: item name: item for name, item in items.items() if _resolve_stack(config, name) == filter_stack
for name, item in items.items()
if _resolve_stack(config, name) == filter_stack
} }
@@ -217,7 +213,7 @@ def _list_json(
entry: dict = { entry: dict = {
"name": name, "name": name,
"kinds": kinds, "kinds": kinds,
"active": is_active(name, config), "active": is_active(name, (kinds or ["service"])[0], config),
} }
if comp.stack: if comp.stack:
entry["stack"] = comp.stack entry["stack"] = comp.stack
@@ -231,7 +227,7 @@ def _list_json(
stack = _resolve_stack(config, name) stack = _resolve_stack(config, name)
if filter_stack and stack != filter_stack: if filter_stack and stack != filter_stack:
continue continue
entry = {"name": name, "kind": "service", "active": is_active(name, config)} entry = {"name": name, "kind": "service", "active": is_active(name, "service", config)}
if stack: if stack:
entry["stack"] = stack entry["stack"] = stack
if svc.description: if svc.description:
@@ -247,7 +243,7 @@ def _list_json(
entry = { entry = {
"name": name, "name": name,
"kind": "job", "kind": "job",
"active": is_active(name, config), "active": is_active(name, "job", config),
"schedule": job.schedule, "schedule": job.schedule,
} }
if stack: if stack:
@@ -261,7 +257,7 @@ def _list_json(
stack = _resolve_stack(config, name) stack = _resolve_stack(config, name)
if filter_stack and stack != filter_stack: if filter_stack and stack != filter_stack:
continue continue
entry = {"name": name, "kind": kind, "active": is_active(name, config)} entry = {"name": name, "kind": kind, "active": is_active(name, kind, config)}
if stack: if stack:
entry["stack"] = stack entry["stack"] = stack
if d.description: if d.description:

View File

@@ -14,7 +14,7 @@ def run_logs(args: argparse.Namespace) -> int:
config = load_config() config = load_config()
name = args.name name = args.name
dep = config.deployments.get(name) dep = next((d for _k, d in config.deployments_named(name)), None)
if dep is not None and dep.manager == "systemd": if dep is not None and dep.manager == "systemd":
if dep.run.launcher == "container": if dep.run.launcher == "container":
return _container_logs(name, args) return _container_logs(name, args)

View File

@@ -86,11 +86,13 @@ def run_run(args: argparse.Namespace) -> int:
return 1 return 1
registry = load_registry() registry = load_registry()
if name not in registry.deployed: matches = registry.named(name)
if not matches:
print(f"Error: '{name}' is not a deployed service. Run 'castle apply' first.") print(f"Error: '{name}' is not a deployed service. Run 'castle apply' first.")
return 1 return 1
deployed = registry.deployed[name] # A name may span kinds; run the one that has a run command (service/job).
deployed = next((d for d in matches if d.run_cmd), matches[0])
cmd = list(deployed.run_cmd) + extra_args cmd = list(deployed.run_cmd) + extra_args
env = dict(os.environ) env = dict(os.environ)
env.update(deployed.env) env.update(deployed.env)

View File

@@ -10,7 +10,6 @@ from castle_core.generators.systemd import (
timer_name, timer_name,
unit_name, unit_name,
) )
from castle_core.manifest import kind_for
from castle_cli.config import ( from castle_cli.config import (
CastleConfig, CastleConfig,
@@ -46,50 +45,53 @@ def run_service_cmd(args: argparse.Namespace) -> int:
Lifecycle (deploy/enable/disable/start/stop) is now convergence: `castle apply`. Lifecycle (deploy/enable/disable/start/stop) is now convergence: `castle apply`.
""" """
config = load_config() config = load_config()
return _unit_action(config, args.name, "restart", is_job=False) return _unit_action(config, args.name, "restart", "service")
def run_job_cmd(args: argparse.Namespace) -> int: def run_job_cmd(args: argparse.Namespace) -> int:
"""`castle job restart <name>` — bounce the job's timer.""" """`castle job restart <name>` — bounce the job's timer."""
config = load_config() config = load_config()
return _unit_action(config, args.name, "restart", is_job=True) return _unit_action(config, args.name, "restart", "job")
def run_restart(args: argparse.Namespace) -> int: def run_restart(args: argparse.Namespace) -> int:
"""Top-level `castle restart [name]` — bounce one deployment, or all of them. """Top-level `castle restart [name]` — bounce one deployment, or all of them.
An imperative op: it re-actualizes current desired state, it does not change it An imperative op: it re-actualizes current desired state, it does not change it
(that's `castle apply`). (that's `castle apply`). A bare name bounces every kind sharing it.
""" """
config = load_config() config = load_config()
name = getattr(args, "name", None) name = getattr(args, "name", None)
if not name: if not name:
return _services_restart(config) return _services_restart(config)
dep = config.deployments.get(name) named = config.deployments_named(name)
if dep is None: if not named:
print(f"Error: no deployment '{name}'.") print(f"Error: no deployment '{name}'.")
return 1 return 1
return _unit_action(config, name, "restart", is_job=(kind_for(dep) == "job")) rc = 0
for kind, _spec in named:
rc |= _unit_action(config, name, "restart", kind)
return rc
_GATEWAY_NAME = "castle-gateway" _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, kind: str) -> int:
"""start/stop/restart one service or job, dispatched by its manager. """start/stop/restart one deployment (name, kind), dispatched by its manager.
systemd (a process/timer) → `systemctl`; caddy (static) → reload the gateway; systemd (a process/timer) → `systemctl`; caddy (static) → reload the gateway;
path (a tool) → install/uninstall; none (remote) → nothing to do. path (a tool) → install/uninstall; none (remote) → nothing to do.
""" """
dep = config.deployments.get(name) dep = config.deployment(kind, name)
if dep is None: if dep is None:
print(f"Error: no deployment '{name}'.") print(f"Error: no {kind} '{name}'.")
return 1 return 1
manager = dep.manager manager = dep.manager
if manager != "systemd": if manager != "systemd":
return _managed_lifecycle(config, name, action, manager) return _managed_lifecycle(config, name, action, manager, kind)
# A scheduled systemd deployment (a job) is driven by its .timer. # A scheduled systemd deployment (a job) is driven by its .timer.
unit = timer_name(name) if kind_for(dep) == "job" else unit_name(name) unit = timer_name(name) if kind == "job" else unit_name(name, kind)
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:
print(f"Error: failed to {action} {unit}") print(f"Error: failed to {action} {unit}")
@@ -98,26 +100,26 @@ 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: def _managed_lifecycle(
config: CastleConfig, name: str, action: str, manager: str, kind: str
) -> int:
"""Lifecycle for non-systemd managers (no unit to systemctl).""" """Lifecycle for non-systemd managers (no unit to systemctl)."""
if manager == "caddy": if manager == "caddy":
if action == "stop": if action == "stop":
print(f" {name}: gateway-served — disable or remove it to drop the route.") print(f" {name}: gateway-served — disable or remove it to drop the route.")
return 0 return 0
# start/restart → reload the gateway so current routes take effect. # start/restart → reload the gateway so current routes take effect.
subprocess.run( subprocess.run(["systemctl", "--user", "reload", unit_name(_GATEWAY_NAME)], check=False)
["systemctl", "--user", "reload", unit_name(_GATEWAY_NAME)], check=False
)
print(f" {name}: gateway reloaded ({_PAST[action]}).") print(f" {name}: gateway reloaded ({_PAST[action]}).")
return 0 return 0
if manager == "path": if manager == "path":
return _path_lifecycle(config, name, action) return _path_lifecycle(config, name, action, kind)
# none (remote): external, nothing local to act on. # none (remote): external, nothing local to act on.
print(f" {name}: external ({manager}) — nothing to {action}.") print(f" {name}: external ({manager}) — nothing to {action}.")
return 0 return 0
def _path_lifecycle(config: CastleConfig, name: str, action: str) -> int: def _path_lifecycle(config: CastleConfig, name: str, action: str, kind: str) -> int:
"""A `path` (tool) deployment's lifecycle is install/uninstall on PATH.""" """A `path` (tool) deployment's lifecycle is install/uninstall on PATH."""
import asyncio import asyncio
@@ -125,7 +127,7 @@ def _path_lifecycle(config: CastleConfig, name: str, action: str) -> int:
# stop → uninstall; start/restart → ensure installed (activate skips if on PATH). # stop → uninstall; start/restart → ensure installed (activate skips if on PATH).
coro = deactivate if action == "stop" else activate coro = deactivate if action == "stop" else activate
res = asyncio.run(coro(name, config, config.root)) res = asyncio.run(coro(name, kind, config, config.root))
print(f" {res.output}") print(f" {res.output}")
return 0 if res.status == "ok" else 1 return 0 if res.status == "ok" else 1
@@ -136,14 +138,14 @@ def _services_restart(config: CastleConfig) -> int:
caddy/path/none deployments 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. restart (static) or are stateless (remote), so we don't systemctl them here.
""" """
for name, dep in config.deployments.items(): for kind, name, dep in config.all_deployments():
if dep.manager != "systemd": if dep.manager != "systemd":
continue continue
if kind_for(dep) == "job": if kind == "job":
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)")
else: else:
subprocess.run(["systemctl", "--user", "restart", unit_name(name)], check=False) subprocess.run(["systemctl", "--user", "restart", unit_name(name, kind)], check=False)
print(f" {name}: restarted") print(f" {name}: restarted")
return 0 return 0
@@ -166,8 +168,9 @@ def run_status(args: argparse.Namespace) -> int:
if catalog: if catalog:
print(f"{'' * 50}") print(f"{'' * 50}")
print("Programs") print("Programs")
for name, comp in catalog.items(): for name, _comp in catalog.items():
on = is_active(name, config) _pk = sorted({k for _, k in config.deployments_of(name)})
on = is_active(name, _pk[0] if _pk else "tool", config)
color = "\033[92m" if on else "\033[90m" color = "\033[92m" if on else "\033[90m"
label = "active" if on else "inactive" label = "active" if on else "inactive"
kinds = sorted({k for _, k in config.deployments_of(name)}) kinds = sorted({k for _, k in config.deployments_of(name)})
@@ -185,7 +188,7 @@ def _service_status(config: CastleConfig) -> int:
print("=" * 50) print("=" * 50)
for name, svc in config.services.items(): for name, svc in config.services.items():
active = is_active(name, config) # manager-aware (systemd/caddy/path/none) active = is_active(name, "service", config) # manager-aware
manager = svc.manager manager = svc.manager
color = "\033[92m" if active else "\033[90m" color = "\033[92m" if active else "\033[90m"
reset = "\033[0m" reset = "\033[0m"
@@ -218,4 +221,3 @@ def _service_status(config: CastleConfig) -> int:
print() print()
return 0 return 0

View File

@@ -55,7 +55,7 @@ def _tls_status() -> int:
print(f"wildcard source: *.{domain} " + (f"[{src_fp}]" if src_fp else "(not found)")) print(f"wildcard source: *.{domain} " + (f"[{src_fp}]" if src_fp else "(not found)"))
rows = [] rows = []
for name, dep in sorted(config.deployments.items()): for _kind, name, dep in config.all_deployments():
if _tls_of(dep) is None: if _tls_of(dep) is None:
continue continue
config_key = dep.program or name config_key = dep.program or name

View File

@@ -30,11 +30,7 @@ def _is_tool(config: object, name: str) -> bool:
def _tool_programs(config: object) -> dict: def _tool_programs(config: object) -> dict:
"""Programs with a tool (path) deployment, name-sorted.""" """Programs with a tool (path) deployment, name-sorted."""
return { return {name: comp for name, comp in sorted(config.programs.items()) if _is_tool(config, name)}
name: comp
for name, comp in sorted(config.programs.items())
if _is_tool(config, name)
}
def _executables(comp: object) -> list[str]: def _executables(comp: object) -> list[str]:
@@ -77,8 +73,7 @@ def run_tool_list(args: argparse.Namespace) -> int:
config = load_config() config = load_config()
tools = _tool_programs(config) tools = _tool_programs(config)
records = [ records = [
_tool_record(config, name, comp, tool_installed(name)) _tool_record(config, name, comp, tool_installed(name)) for name, comp in tools.items()
for name, comp in tools.items()
] ]
if getattr(args, "json", False): if getattr(args, "json", False):

View File

@@ -184,17 +184,13 @@ def build_parser() -> argparse.ArgumentParser:
"apply", help="Converge the running system to match config (render + reconcile)" "apply", help="Converge the running system to match config (render + reconcile)"
) )
p.add_argument("name", nargs="?", help="Single deployment to converge (default: all)") p.add_argument("name", nargs="?", help="Single deployment to converge (default: all)")
p.add_argument( p.add_argument("--plan", action="store_true", help="Show the diff without changing anything")
"--plan", action="store_true", help="Show the diff without changing anything"
)
# Imperative ops (don't change desired state) # Imperative ops (don't change desired state)
p = subparsers.add_parser("restart", help="Restart deployment(s) — an imperative bounce") p = subparsers.add_parser("restart", help="Restart deployment(s) — an imperative bounce")
p.add_argument("name", nargs="?", help="Deployment to restart (default: all)") p.add_argument("name", nargs="?", help="Deployment to restart (default: all)")
subparsers.add_parser("status", help="Show status across the platform") subparsers.add_parser("status", help="Show status across the platform")
subparsers.add_parser( subparsers.add_parser("doctor", help="Diagnose setup + runtime health, with next-step hints")
"doctor", help="Diagnose setup + runtime health, with next-step hints"
)
# Relationship model — repos, requires edges, and derived status. # Relationship model — repos, requires edges, and derived status.
p = subparsers.add_parser( p = subparsers.add_parser(

View File

@@ -50,7 +50,7 @@ class TestAdd:
def test_adopt_rust_declares_commands(self, castle_root: Path, tmp_path: Path) -> None: def test_adopt_rust_declares_commands(self, castle_root: Path, tmp_path: Path) -> None:
repo = tmp_path / "rusty" repo = tmp_path / "rusty"
repo.mkdir() repo.mkdir()
(repo / "Cargo.toml").write_text("[package]\nname = \"rusty\"\n") (repo / "Cargo.toml").write_text('[package]\nname = "rusty"\n')
rc, config = _run_add(castle_root, target=str(repo)) rc, config = _run_add(castle_root, target=str(repo))
assert rc == 0 assert rc == 0
prog = config.programs["rusty"] prog = config.programs["rusty"]
@@ -62,9 +62,7 @@ class TestAdd:
assert prog.commands.run == [["cargo", "run"]] assert prog.commands.run == [["cargo", "run"]]
def test_adopt_git_url_records_repo(self, castle_root: Path) -> None: def test_adopt_git_url_records_repo(self, castle_root: Path) -> None:
rc, config = _run_add( rc, config = _run_add(castle_root, target="https://github.com/someone/widget.git")
castle_root, target="https://github.com/someone/widget.git"
)
assert rc == 0 assert rc == 0
prog = config.programs["widget"] prog = config.programs["widget"]
assert prog.repo == "https://github.com/someone/widget.git" assert prog.repo == "https://github.com/someone/widget.git"

View File

@@ -74,7 +74,7 @@ class TestCreateCommand:
assert (project_dir / "CLAUDE.md").exists() assert (project_dir / "CLAUDE.md").exists()
assert "my-tool2" in config.programs assert "my-tool2" in config.programs
# A tool is a PATH deployment: manager=path, derived kind=tool. # A tool is a PATH deployment: manager=path, derived kind=tool.
assert config.deployments["my-tool2"].manager == "path" assert config.tools["my-tool2"].manager == "path"
assert config.deployments_of("my-tool2") == [("my-tool2", "tool")] assert config.deployments_of("my-tool2") == [("my-tool2", "tool")]
def test_create_supabase_app(self, castle_root: Path, tmp_path: Path) -> None: def test_create_supabase_app(self, castle_root: Path, tmp_path: Path) -> None:
@@ -91,9 +91,7 @@ class TestCreateCommand:
from castle_cli.commands.create import run_create from castle_cli.commands.create import run_create
args = Namespace( args = Namespace(name="guestbook", stack="supabase", description="Guestbook", port=None)
name="guestbook", stack="supabase", description="Guestbook", port=None
)
result = run_create(args) result = run_create(args)
assert result == 0 assert result == 0
@@ -110,7 +108,7 @@ class TestCreateCommand:
# The supabase stack seeds a `requires` on the substrate so the graph shows # The supabase stack seeds a `requires` on the substrate so the graph shows
# the dependency (stack stays uncoupled — it just declares the edge once). # the dependency (stack stays uncoupled — it just declares the edge once).
assert [(r.kind, r.ref) for r in comp.requires] == [("deployment", "supabase")] assert [(r.kind, r.ref) for r in comp.requires] == [("deployment", "supabase")]
dep = config.deployments["guestbook"] dep = config.statics["guestbook"]
assert dep.manager == "caddy" assert dep.manager == "caddy"
assert dep.root == "public" assert dep.root == "public"
assert config.deployments_of("guestbook") == [("guestbook", "static")] assert config.deployments_of("guestbook") == [("guestbook", "static")]