Files
wild-pc/cli/src/castle_cli/commands/deploy_create.py
Paul Payne 317232ca6a Manager-first deployment model: split runner, merge service/job, frontend→static
Replace the conflated `runner` axis with two orthogonal ones: `manager`
(systemd|caddy|path|none) — who supervises/realizes a deployment — and, for
systemd only, a nested `launcher` (python|command|container|compose|node) — how
the process starts. ServiceSpec and JobSpec collapse into one manager-
discriminated DeploymentSpec union (Systemd/Caddy/Path/Remote); the services/
and jobs/ config dirs collapse into one deployments/ dir. The human "kind"
(service|job|tool|static|reference) is fully derived (kind_for), never stored —
the frontend kind is renamed static. behavior is gone.

- core: DeploymentSpec union + LaunchSpec + kind_for; legacy-aware loader
  normalizes old runner shapes; CastleConfig.deployments with derived
  services/jobs/tools views; registry.Deployment carries manager/launcher/kind.
- cli: service/job/tool as filtered views + a deployment group; --behavior→--kind,
  create --runner→--launcher; lifecycle dispatches over config.deployments.
- castle-api: /deployments primary with /services,/jobs as views; summaries
  derive kind; PUT/DELETE /config/deployments/{name} (services/jobs aliased).
- app: KindBadge, frontend→static everywhere, pick-a-kind creation wizard,
  per-kind config editors.
- docs: single deployments/ layout, manager/launcher, static kind throughout.

Live migration verified byte-identical: regenerated Caddyfile and every unit
ExecStart line unchanged, so nothing restarted. Suites: core 124, cli 25,
castle-api 55; dashboard build + type-check clean.
2026-07-01 10:23:03 -07:00

123 lines
3.7 KiB
Python

"""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 (
DefaultsSpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
ManageSpec,
RunCommand,
RunPython,
SystemdDeployment,
SystemdSpec,
)
def _defaults(env_args: list[str] | None) -> DefaultsSpec | None:
"""Parse repeated --env KEY=VALUE into a DefaultsSpec, or None."""
if not env_args:
return None
env: dict[str, str] = {}
for item in env_args:
key, _, value = item.partition("=")
env[key.strip()] = value
return DefaultsSpec(env=env)
def _run_spec(launcher: str, target: str, name: str) -> RunPython | RunCommand:
if launcher == "command":
return RunCommand(launcher="command", argv=target.split() or [name])
return RunPython(launcher="python", program=target or name)
def _check_new(config: object, name: str, label: str) -> str | None:
"""Return an error message if the deployment name is taken, else None."""
if name in config.deployments: # type: ignore[attr-defined]
return f"Error: {label} '{name}' already exists."
return None
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, "service"):
print(err)
return 1
run = _run_spec(args.launcher, args.run or args.program or name, name)
expose = None
proxy = False
if args.port is not None:
expose = ExposeSpec(
http=HttpExposeSpec(
internal=HttpInternal(port=args.port),
health_path=args.health,
)
)
# Expose at <name>.<gateway.domain> (the subdomain is the service name).
proxy = not args.no_proxy
config.deployments[name] = SystemdDeployment(
id=name,
manager="systemd",
program=args.program,
description=args.description or None,
run=run,
expose=expose,
proxy=proxy,
manage=ManageSpec(systemd=SystemdSpec()),
defaults=_defaults(args.env),
)
save_config(config)
print(f"Created service '{name}'.")
print(f" runs: {args.launcher} ({args.run or args.program or name})")
if expose:
print(f" port: {args.port}")
if proxy:
print(f" subdomain: {name}.<gateway.domain>")
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, "job"):
print(err)
return 1
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(
id=name,
manager="systemd",
program=args.program,
description=args.description or None,
run=run,
schedule=args.schedule,
manage=ManageSpec(systemd=SystemdSpec()),
defaults=_defaults(args.env),
)
save_config(config)
print(f"Created job '{name}'.")
print(f" runs: {args.launcher} ({args.run or args.program or name})")
print(f" schedule: {args.schedule}")
print(f"\nNext: castle job deploy {name} && castle job enable {name}")
return 0