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:
return (
s.startswith(("http://", "https://", "git@", "ssh://"))
or s.endswith(".git")
)
return s.startswith(("http://", "https://", "git@", "ssh://")) or s.endswith(".git")
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)
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")
return 1

View File

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

View File

@@ -51,7 +51,7 @@ STACK_REQUIRES: dict[str, list[Requirement]] = {
def next_available_port(config: object) -> int:
"""Find the next available port starting from 9001 (9000 is reserved for gateway)."""
used_ports = set()
for dep in config.deployments.values():
for _k, _n, dep in config.all_deployments():
expose = getattr(dep, "expose", None)
if expose and expose.http:
used_ports.add(expose.http.internal.port)
@@ -71,7 +71,7 @@ def run_create(args: argparse.Namespace) -> int:
stack = args.stack
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")
return 1
@@ -125,12 +125,12 @@ def run_create(args: argparse.Namespace) -> int:
)
if kind == "tool":
# 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
)
elif kind == "static":
# A caddy-managed static deployment: no systemd unit, served from the build dir.
config.deployments[name] = CaddyDeployment(
config.statics[name] = CaddyDeployment(
id=name,
manager="caddy",
program=name,
@@ -139,7 +139,7 @@ def run_create(args: argparse.Namespace) -> int:
)
elif kind == "service":
prefix = name.replace("-", "_").upper()
config.deployments[name] = SystemdDeployment(
config.services[name] = SystemdDeployment(
id=name,
manager="systemd",
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.
_DEPLOY_RESOURCES = (None, "service", "job", "tool", "static", "deployment")
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):
where = f" {resource}" if resource else ""
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
# (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.
deployments_to_remove: list[str] = []
# Each entry is (kind, name) — the deployment's identity.
deployments_to_remove: list[tuple[str, str]] = []
if in_programs:
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:
deployments_to_remove.append(name)
if in_deployment:
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
# 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)
print(f"Will remove '{name}' from castle.yaml ({', '.join(where)}).")
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:
print(f"Will ALSO delete source directory: {source_dir}")
if owns_data and purge_data:
@@ -95,14 +102,14 @@ def run_delete(args: argparse.Namespace) -> int:
from castle_core.lifecycle import deactivate
for d in deployments_to_remove:
for kind, d in deployments_to_remove:
try:
res = asyncio.run(deactivate(d, config, config.root))
res = asyncio.run(deactivate(d, kind, config, config.root))
if getattr(res, "message", None):
print(f" {res.message}")
except Exception as e:
print(f" warning: teardown of '{d}' failed: {e}")
del config.deployments[d]
config.store_for(kind).pop(d, None)
if in_programs:
del config.programs[name]
@@ -155,7 +162,7 @@ def run_delete(args: argparse.Namespace) -> int:
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
being removed — surfaced so the operator can clean up DNS."""
gw = getattr(config, "gateway", None)
@@ -163,8 +170,8 @@ def _public_hosts(config, deployment_names: list[str]) -> list[str]:
if not public_domain:
return []
hosts: list[str] = []
for d in deployment_names:
spec = config.deployments.get(d)
for kind, d in deployments:
spec = config.deployment(kind, d)
if spec is None or not getattr(spec, "public", False):
continue
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:
"""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 None
@@ -70,7 +70,7 @@ def run_service_create(args: argparse.Namespace) -> int:
# Expose at <name>.<gateway.domain> (the subdomain is the service name).
reach = Reach.OFF if args.no_proxy else Reach.INTERNAL
config.deployments[name] = SystemdDeployment(
config.services[name] = SystemdDeployment(
id=name,
manager="systemd",
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)
# A job is a systemd deployment with a schedule (→ a .timer).
config.deployments[name] = SystemdDeployment(
config.jobs[name] = SystemdDeployment(
id=name,
manager="systemd",
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:
checks.append(
Check(OK, "control plane registered", detail="gateway, api, dashboard")
)
checks.append(Check(OK, "control plane registered", detail="gateway, api, dashboard"))
else:
checks.append(
Check(
@@ -158,7 +156,7 @@ def _check_configuration(config) -> list[Check]:
def _check_dashboard_built(config) -> Check:
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")
if _static_built(_DASHBOARD, config):
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:
dep = config.deployments.get(name)
dep = next((d for _k, d in config.deployments_named(name)), None)
expose = getattr(dep, "expose", None)
http = getattr(expose, "http", None)
internal = getattr(http, "internal", None)
@@ -189,7 +187,7 @@ def _check_runtime(config) -> list[Check]:
# Gateway: active + actually listening on its port.
gw_port = config.gateway.port
if is_active(_GATEWAY, config):
if is_active(_GATEWAY, "service", config):
if _port_open(gw_port):
checks.append(Check(OK, "gateway running", detail=f"listening on :{gw_port}"))
else:
@@ -212,7 +210,7 @@ def _check_runtime(config) -> list[Check]:
# API: active + listening on its port.
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):
checks.append(
Check(
@@ -226,9 +224,7 @@ def _check_runtime(config) -> list[Check]:
detail = f"listening on :{api_port}" if api_port else ""
checks.append(Check(OK, "castle-api running", detail=detail))
else:
checks.append(
Check(FAIL, "castle-api not running", hint="castle apply")
)
checks.append(Check(FAIL, "castle-api not running", hint="castle apply"))
# Generated artifacts.
registry = SPECS_DIR / "registry.yaml"
@@ -236,9 +232,7 @@ def _check_runtime(config) -> list[Check]:
if registry.exists() and caddyfile.exists():
checks.append(Check(OK, "registry + Caddyfile generated"))
else:
missing = [
p.name for p in (registry, caddyfile) if not p.exists()
]
missing = [p.name for p in (registry, caddyfile) if not p.exists()]
checks.append(
Check(
FAIL,
@@ -319,14 +313,12 @@ def _check_tls_exposure(config) -> list[Check]:
checks.append(_check_privileged_ports())
# 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:
from castle_core.lifecycle import is_active
if gw.public_domain and gw.tunnel_id:
checks.append(
Check(OK, "tunnel configured", detail=f"{len(public)} public service(s)")
)
checks.append(Check(OK, "tunnel configured", detail=f"{len(public)} public service(s)"))
else:
missing = []
if not gw.public_domain:
@@ -341,7 +333,7 @@ def _check_tls_exposure(config) -> list[Check]:
hint="see docs/tunnel-setup.md",
)
)
if not is_active("castle-tunnel", config):
if not is_active("castle-tunnel", "service", config):
checks.append(
Check(
WARN,
@@ -418,9 +410,7 @@ def _check_public_dns(config) -> Check:
def _check_privileged_ports() -> Check:
try:
val = int(
Path("/proc/sys/net/ipv4/ip_unprivileged_port_start").read_text().strip()
)
val = int(Path("/proc/sys/net/ipv4/ip_unprivileged_port_start").read_text().strip())
except (OSError, ValueError):
return Check(WARN, "cannot read unprivileged port floor")
if val <= 80:

View File

@@ -23,7 +23,7 @@ def _load_deployed_program(name: str) -> object | None:
from castle_core.registry import load_registry
registry = load_registry()
return registry.deployed.get(name)
return next(iter(registry.named(name)), None)
except (FileNotFoundError, ValueError):
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
# SystemdDeployment._validate_reach), so there's no "public" state here.
print(
f" {BOLD}tcp{RESET}: "
f"{name}.<gateway.domain>:{service.tcp_port} (internal)"
f" {BOLD}tcp{RESET}: {name}.<gateway.domain>:{service.tcp_port} (internal)"
)
if service.manage and service.manage.systemd:
sd = service.manage.systemd

View File

@@ -7,8 +7,6 @@ import argparse
import json
import logging
from castle_core.manifest import kind_for
from castle_cli.config import load_config
log = logging.getLogger(__name__)
@@ -16,7 +14,8 @@ log = logging.getLogger(__name__)
def _deployments_of_kind(config: object, kind: str) -> dict:
"""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
BOLD = "\033[1m"
@@ -87,8 +86,8 @@ def run_list(args: argparse.Namespace) -> int:
if getattr(args, "json", False):
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}"
def dot(name: str, kind: str = "service") -> str:
return f"{GREEN}{RESET}" if is_active(name, kind, config) else f"{RED}{RESET}"
any_output = False
@@ -114,12 +113,11 @@ def run_list(args: argparse.Namespace) -> int:
print(f"{CYAN}{'' * 40}{RESET}")
for name, comp in progs.items():
kinds = prog_kinds(name)
kinds_str = "".join(
f" {KIND_COLORS.get(k, '')}{k}{RESET}" for k in kinds
)
kinds_str = "".join(f" {KIND_COLORS.get(k, '')}{k}{RESET}" for k in kinds)
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}{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
# 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_str = f" {DIM}{stack}{RESET}" if stack 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"):
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():
sched = f" {DIM}[{job.schedule}]{RESET}"
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"):
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_str = f" {DIM}{stack}{RESET}" if stack 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"):
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():
sub = f" {DIM}{name}.<domain>{RESET}"
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:
print(f"No {resource or 'program'}s found.")
@@ -191,9 +189,7 @@ def _filter_by_stack(
if not filter_stack:
return items
return {
name: item
for name, item in items.items()
if _resolve_stack(config, name) == filter_stack
name: item for name, item in items.items() if _resolve_stack(config, name) == filter_stack
}
@@ -217,7 +213,7 @@ def _list_json(
entry: dict = {
"name": name,
"kinds": kinds,
"active": is_active(name, config),
"active": is_active(name, (kinds or ["service"])[0], config),
}
if comp.stack:
entry["stack"] = comp.stack
@@ -231,7 +227,7 @@ def _list_json(
stack = _resolve_stack(config, name)
if filter_stack and stack != filter_stack:
continue
entry = {"name": name, "kind": "service", "active": is_active(name, config)}
entry = {"name": name, "kind": "service", "active": is_active(name, "service", config)}
if stack:
entry["stack"] = stack
if svc.description:
@@ -247,7 +243,7 @@ def _list_json(
entry = {
"name": name,
"kind": "job",
"active": is_active(name, config),
"active": is_active(name, "job", config),
"schedule": job.schedule,
}
if stack:
@@ -261,7 +257,7 @@ def _list_json(
stack = _resolve_stack(config, name)
if filter_stack and stack != filter_stack:
continue
entry = {"name": name, "kind": kind, "active": is_active(name, config)}
entry = {"name": name, "kind": kind, "active": is_active(name, kind, config)}
if stack:
entry["stack"] = stack
if d.description:

View File

@@ -14,7 +14,7 @@ def run_logs(args: argparse.Namespace) -> int:
config = load_config()
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.run.launcher == "container":
return _container_logs(name, args)

View File

@@ -86,11 +86,13 @@ def run_run(args: argparse.Namespace) -> int:
return 1
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.")
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
env = dict(os.environ)
env.update(deployed.env)

View File

@@ -10,7 +10,6 @@ from castle_core.generators.systemd import (
timer_name,
unit_name,
)
from castle_core.manifest import kind_for
from castle_cli.config import (
CastleConfig,
@@ -46,50 +45,53 @@ def run_service_cmd(args: argparse.Namespace) -> int:
Lifecycle (deploy/enable/disable/start/stop) is now convergence: `castle apply`.
"""
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:
"""`castle job restart <name>` — bounce the job's timer."""
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:
"""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
(that's `castle apply`).
(that's `castle apply`). A bare name bounces every kind sharing it.
"""
config = load_config()
name = getattr(args, "name", None)
if not name:
return _services_restart(config)
dep = config.deployments.get(name)
if dep is None:
named = config.deployments_named(name)
if not named:
print(f"Error: no deployment '{name}'.")
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"
def _unit_action(config: CastleConfig, name: str, action: str, is_job: bool) -> int:
"""start/stop/restart one service or job, dispatched by its manager.
def _unit_action(config: CastleConfig, name: str, action: str, kind: str) -> int:
"""start/stop/restart one deployment (name, kind), dispatched by its manager.
systemd (a process/timer) → `systemctl`; caddy (static) → reload the gateway;
path (a tool) → install/uninstall; none (remote) → nothing to do.
"""
dep = config.deployments.get(name)
dep = config.deployment(kind, name)
if dep is None:
print(f"Error: no deployment '{name}'.")
print(f"Error: no {kind} '{name}'.")
return 1
manager = dep.manager
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.
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)
if result.returncode != 0:
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
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)."""
if manager == "caddy":
if action == "stop":
print(f" {name}: gateway-served — disable or remove it to drop the route.")
return 0
# start/restart → reload the gateway so current routes take effect.
subprocess.run(
["systemctl", "--user", "reload", unit_name(_GATEWAY_NAME)], check=False
)
subprocess.run(["systemctl", "--user", "reload", unit_name(_GATEWAY_NAME)], check=False)
print(f" {name}: gateway reloaded ({_PAST[action]}).")
return 0
if manager == "path":
return _path_lifecycle(config, name, action)
return _path_lifecycle(config, name, action, kind)
# none (remote): external, nothing local to act on.
print(f" {name}: external ({manager}) — nothing to {action}.")
return 0
def _path_lifecycle(config: CastleConfig, name: str, action: str) -> int:
def _path_lifecycle(config: CastleConfig, name: str, action: str, kind: str) -> int:
"""A `path` (tool) deployment's lifecycle is install/uninstall on PATH."""
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).
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}")
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
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":
continue
if kind_for(dep) == "job":
if kind == "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)
subprocess.run(["systemctl", "--user", "restart", unit_name(name, kind)], check=False)
print(f" {name}: restarted")
return 0
@@ -166,8 +168,9 @@ def run_status(args: argparse.Namespace) -> int:
if catalog:
print(f"{'' * 50}")
print("Programs")
for name, comp in catalog.items():
on = is_active(name, config)
for name, _comp in catalog.items():
_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"
label = "active" if on else "inactive"
kinds = sorted({k for _, k in config.deployments_of(name)})
@@ -185,7 +188,7 @@ def _service_status(config: CastleConfig) -> int:
print("=" * 50)
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
color = "\033[92m" if active else "\033[90m"
reset = "\033[0m"
@@ -218,4 +221,3 @@ def _service_status(config: CastleConfig) -> int:
print()
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)"))
rows = []
for name, dep in sorted(config.deployments.items()):
for _kind, name, dep in config.all_deployments():
if _tls_of(dep) is None:
continue
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:
"""Programs with a tool (path) deployment, name-sorted."""
return {
name: comp
for name, comp in sorted(config.programs.items())
if _is_tool(config, name)
}
return {name: comp for name, comp in sorted(config.programs.items()) if _is_tool(config, name)}
def _executables(comp: object) -> list[str]:
@@ -77,8 +73,7 @@ def run_tool_list(args: argparse.Namespace) -> int:
config = load_config()
tools = _tool_programs(config)
records = [
_tool_record(config, name, comp, tool_installed(name))
for name, comp in tools.items()
_tool_record(config, name, comp, tool_installed(name)) for name, comp in tools.items()
]
if getattr(args, "json", False):