refactor(cli): resource-first command surface

Names collide across resource types (a program and a service can share a name),
so the CLI no longer resolves bare names. Operations live under the resource
they act on; platform lifecycle and the cross-resource overview stay top-level.

  castle program  list|info|create|add|clone|delete|run|install|uninstall|
                  build|test|lint|format|type-check|check
  castle service  list|info|create|delete|deploy|enable|disable|start|stop|restart|logs
  castle job      <same verbs>   (create takes --schedule)
  castle gateway  start|stop|reload|status
  castle list | status | deploy [name]        # cross-cutting
  castle start | stop | restart               # whole platform (systemd verbs)

Key changes:
- info/delete/list/run are resource-scoped (no more cross-section name match).
- delete only removes the named resource's entry; program delete still blocks
  when a deployment references it.
- per-resource start/stop/restart (systemctl on the unit/timer); top-level
  start/stop/restart act on the whole platform.
- expose → 'service create' (more general: --program ref, --runner); gained
  'job create' (the CLI never had one).
- dropped up/down — bringing things online is the honest 'deploy && start'.

Docs (CLAUDE.md, registry.md, stack guides) and user-facing messages updated.
core 94 / cli 24 / api 52 green; ruff clean. Verified live: program/service/job
list+info scope correctly, service create+scoped-delete round-trips.
This commit is contained in:
2026-06-14 16:25:34 -07:00
parent 82f12c9d61
commit 5c6d8ec6f1
16 changed files with 556 additions and 511 deletions

View File

@@ -0,0 +1,119 @@
"""castle service create / castle job create — declare a deployment.
A service or job can run anything (a castle program or not). `--program`
records a convenience reference for description fallthrough; the run target is
the console script (python) or argv (command) to execute.
"""
from __future__ import annotations
import argparse
from castle_cli.config import load_config, save_config
from castle_cli.manifest import (
CaddySpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
JobSpec,
ManageSpec,
ProxySpec,
RunCommand,
RunPython,
ServiceSpec,
SystemdSpec,
)
def _run_spec(runner: str, target: str, name: str) -> RunPython | RunCommand:
if runner == "command":
return RunCommand(runner="command", argv=target.split() or [name])
return RunPython(runner="python", program=target or name)
def _check_new(config: object, name: str, section: str) -> str | None:
"""Return an error message if the name can't be created, else None."""
existing = getattr(config, section)
if name in existing:
return f"Error: {section[:-1]} '{name}' already exists."
return None
def run_service_create(args: argparse.Namespace) -> int:
"""Create a service entry in castle.yaml."""
config = load_config()
name = args.name
if err := _check_new(config, name, "services"):
print(err)
return 1
run = _run_spec(args.runner, args.run or args.program or name, name)
expose = None
proxy = None
if args.port is not None:
expose = ExposeSpec(
http=HttpExposeSpec(
internal=HttpInternal(port=args.port, port_env=args.port_env),
health_path=args.health,
)
)
if not args.no_proxy:
path = args.path
if args.path:
path = args.path if args.path.startswith("/") else f"/{args.path}"
elif args.host:
path = None
else:
path = f"/{name}"
proxy = ProxySpec(caddy=CaddySpec(path_prefix=path, host=args.host))
config.services[name] = ServiceSpec(
id=name,
program=args.program,
description=args.description or None,
run=run,
expose=expose,
proxy=proxy,
manage=ManageSpec(systemd=SystemdSpec()),
)
save_config(config)
print(f"Created service '{name}'.")
print(f" runs: {args.runner} ({args.run or args.program or name})")
if expose:
print(f" port: {args.port}" + (f" (env: {args.port_env})" if args.port_env else ""))
if proxy and proxy.caddy:
if proxy.caddy.path_prefix:
print(f" proxy: {proxy.caddy.path_prefix}")
if proxy.caddy.host:
print(f" host: {proxy.caddy.host}")
print(f"\nNext: castle service deploy {name} && castle service start {name}")
return 0
def run_job_create(args: argparse.Namespace) -> int:
"""Create a job entry in castle.yaml."""
config = load_config()
name = args.name
if err := _check_new(config, name, "jobs"):
print(err)
return 1
run = _run_spec(args.runner, args.run or args.program or name, name)
config.jobs[name] = JobSpec(
id=name,
program=args.program,
description=args.description or None,
run=run,
schedule=args.schedule,
manage=ManageSpec(systemd=SystemdSpec()),
)
save_config(config)
print(f"Created job '{name}'.")
print(f" runs: {args.runner} ({args.run or args.program or name})")
print(f" schedule: {args.schedule}")
print(f"\nNext: castle job deploy {name} && castle job enable {name}")
return 0