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