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.
123 lines
3.7 KiB
Python
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
|