Add castle doctor + getting-started docs
The README/docs explained how the system works but not how to get it up and running, and there was no way to tell a healthy node from a half-configured one. Two additions close that gap. castle doctor — a read-only preflight/postflight check. It inspects setup *and* runtime (CLI on PATH, uv, lingering; repo:, control plane registered, dashboard built; gateway/api running + listening, specs generated; and — under tls=acme — DNS-plugin Caddy, provider token, :443 bind; tunnel config for public services) and, for anything not green, prints the exact next command. Exit 0 when nothing failed (warnings allowed), 1 otherwise, so it doubles as a scriptable smoke test after install/deploy. Agents can lean on it the same way they use `castle tool list`. Docs — the quick start now leads with prerequisites and one command (install.sh installs the CLI + registers the control plane, so the old manual `uv tool install` step is gone), adds a `castle doctor` verify step, and gains an "exposure ladder" table framing the three rungs (localhost → LAN HTTPS → public) that link the deep DNS/TLS/tunnel docs. install.sh's closing summary and the AGENTS.md CLI reference mention doctor too.
This commit is contained in:
@@ -82,6 +82,7 @@ castle tool install|uninstall <name>
|
|||||||
# Platform-wide
|
# Platform-wide
|
||||||
castle list [--kind ...] [--stack ...] [--json] # all deployments
|
castle list [--kind ...] [--stack ...] [--json] # all deployments
|
||||||
castle status # unified health/status
|
castle status # unified health/status
|
||||||
|
castle doctor # diagnose setup + runtime, with fix hints
|
||||||
castle deploy [name] # apply config → units + Caddyfile
|
castle deploy [name] # apply config → units + Caddyfile
|
||||||
castle start | stop | restart # all services (+ gateway)
|
castle start | stop | restart # all services (+ gateway)
|
||||||
castle gateway start|stop|reload|status # the Caddy gateway
|
castle gateway start|stop|reload|status # the Caddy gateway
|
||||||
|
|||||||
37
README.md
37
README.md
@@ -101,18 +101,42 @@ block set, a sensible default set (`claude`, `opencode`, `amplifier`, …) is of
|
|||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
**Prerequisites:** a Debian/Ubuntu-family Linux with `apt` and `sudo`, `systemd`
|
||||||
# Install the CLI (editable, onto your PATH)
|
(user services), and `git`. `install.sh` sets up everything else — Docker, Caddy,
|
||||||
uv tool install --editable cli/
|
`uv`, and the `castle` CLI itself.
|
||||||
|
|
||||||
# Bootstrap infrastructure + the ~/.castle tree and a default castle.yaml
|
```bash
|
||||||
|
git clone <this-repo> ~/castle && cd ~/castle
|
||||||
|
|
||||||
|
# One command: installs uv + the castle CLI, sets up infra (Docker, Caddy, MQTT,
|
||||||
|
# Postgres), creates ~/.castle, registers Castle's own control plane, builds the UI.
|
||||||
./install.sh
|
./install.sh
|
||||||
|
|
||||||
castle list # what's registered
|
|
||||||
castle deploy && castle start # apply config to the runtime, then bring it up
|
castle deploy && castle start # apply config to the runtime, then bring it up
|
||||||
|
castle doctor # verify — every check should be green
|
||||||
open http://localhost:9000 # the dashboard
|
open http://localhost:9000 # the dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`castle doctor` is your friend at every step: it inspects setup *and* runtime and,
|
||||||
|
for anything not green, prints the exact next command. Run it any time something
|
||||||
|
looks off — after an install, a deploy, or a config change.
|
||||||
|
|
||||||
|
### Exposure: from localhost to your own HTTPS domain
|
||||||
|
|
||||||
|
Localhost is the first rung; you climb only as far as you need. Each rung is a small
|
||||||
|
config change plus `castle deploy`, and `castle doctor` tells you what a rung still
|
||||||
|
needs.
|
||||||
|
|
||||||
|
| Rung | You get | What it takes |
|
||||||
|
|------|---------|---------------|
|
||||||
|
| **localhost** *(default)* | dashboard + services on `:9000` / `host:port` | nothing — this is the quick start above |
|
||||||
|
| **LAN HTTPS** | real `https://<name>.<your-domain>` on every device on your network, publicly-trusted cert, services stay internal | own a domain; set `gateway.tls: acme` + `domain:`; one wildcard DNS record on your router; a Cloudflare token. → [docs/dns-and-tls.md](docs/dns-and-tls.md) |
|
||||||
|
| **Public** | a chosen service reachable from the internet | `public: true` on the service + a Cloudflare tunnel. → [docs/tunnel-setup.md](docs/tunnel-setup.md) |
|
||||||
|
|
||||||
|
The jump to LAN HTTPS is the involved one (DNS + a token + binding `:443`). Set
|
||||||
|
`gateway.tls: acme` and run `castle doctor` — it enumerates exactly the pieces that
|
||||||
|
are still missing, each with its fix.
|
||||||
|
|
||||||
## Creating programs
|
## Creating programs
|
||||||
|
|
||||||
`castle program create` scaffolds the source **and** its deployment from a stack:
|
`castle program create` scaffolds the source **and** its deployment from a stack:
|
||||||
@@ -153,7 +177,8 @@ castle tool list|info|install|uninstall # CLIs on your PA
|
|||||||
|
|
||||||
# Platform-wide
|
# Platform-wide
|
||||||
castle list [--kind K] [--stack S] [--json] # catalog + every deployment view
|
castle list [--kind K] [--stack S] [--json] # catalog + every deployment view
|
||||||
castle status # unified status
|
castle status # unified runtime status
|
||||||
|
castle doctor # diagnose setup + health, with fix hints
|
||||||
castle deploy [name] # apply config → units + Caddyfile
|
castle deploy [name] # apply config → units + Caddyfile
|
||||||
castle start | stop | restart # all deployments (+ gateway)
|
castle start | stop | restart # all deployments (+ gateway)
|
||||||
castle gateway start|stop|reload|status
|
castle gateway start|stop|reload|status
|
||||||
|
|||||||
423
cli/src/castle_cli/commands/doctor.py
Normal file
423
cli/src/castle_cli/commands/doctor.py
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
"""castle doctor — diagnose whether this node is set up and running.
|
||||||
|
|
||||||
|
Read-only. It answers the question the runtime status view can't: *is this node
|
||||||
|
correctly configured, and if not, what's the exact next command?* Runs a series
|
||||||
|
of checks grouped into Environment, Configuration, Runtime, and TLS & exposure;
|
||||||
|
each check reports ok / warn / fail with a one-line hint when action is needed.
|
||||||
|
|
||||||
|
Exit code: 0 when nothing FAILed (warnings are allowed), 1 otherwise — so it
|
||||||
|
doubles as a scriptable smoke test after `./install.sh` or `castle deploy`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
OK, WARN, FAIL = "ok", "warn", "fail"
|
||||||
|
|
||||||
|
_ICON = {
|
||||||
|
OK: "\033[32m✓\033[0m",
|
||||||
|
WARN: "\033[33m!\033[0m",
|
||||||
|
FAIL: "\033[31m✗\033[0m",
|
||||||
|
}
|
||||||
|
|
||||||
|
_GATEWAY = "castle-gateway"
|
||||||
|
_API = "castle-api"
|
||||||
|
_DASHBOARD = "castle"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Check:
|
||||||
|
status: str
|
||||||
|
label: str
|
||||||
|
detail: str = ""
|
||||||
|
hint: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _print(check: Check) -> None:
|
||||||
|
line = f" {_ICON[check.status]} {check.label}"
|
||||||
|
if check.detail:
|
||||||
|
line += f" \033[90m{check.detail}\033[0m"
|
||||||
|
print(line)
|
||||||
|
if check.hint and check.status != OK:
|
||||||
|
print(f" \033[90m→ {check.hint}\033[0m")
|
||||||
|
|
||||||
|
|
||||||
|
def _port_open(port: int, host: str = "127.0.0.1") -> bool:
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=0.5):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# --- Environment ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _check_environment() -> list[Check]:
|
||||||
|
checks: list[Check] = []
|
||||||
|
|
||||||
|
if shutil.which("castle"):
|
||||||
|
checks.append(Check(OK, "castle CLI on PATH"))
|
||||||
|
else:
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
WARN,
|
||||||
|
"castle CLI not on PATH",
|
||||||
|
hint="ensure ~/.local/bin is on PATH (uv tool install target)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if shutil.which("uv"):
|
||||||
|
checks.append(Check(OK, "uv installed"))
|
||||||
|
else:
|
||||||
|
checks.append(
|
||||||
|
Check(FAIL, "uv not found", hint="curl -LsSf https://astral.sh/uv/install.sh | sh")
|
||||||
|
)
|
||||||
|
|
||||||
|
checks.append(_check_lingering())
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def _check_lingering() -> Check:
|
||||||
|
import getpass
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
user = getpass.getuser()
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["loginctl", "show-user", user],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return Check(WARN, "systemd lingering unknown", detail="loginctl not found")
|
||||||
|
if "Linger=yes" in out.stdout:
|
||||||
|
return Check(OK, "systemd user lingering enabled")
|
||||||
|
return Check(
|
||||||
|
WARN,
|
||||||
|
"systemd user lingering off",
|
||||||
|
detail="services stop when you log out",
|
||||||
|
hint=f"sudo loginctl enable-linger {user}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Configuration ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _check_configuration(config) -> list[Check]:
|
||||||
|
checks: list[Check] = []
|
||||||
|
|
||||||
|
gw = config.gateway
|
||||||
|
tls = (gw.tls or "off").lower()
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
OK,
|
||||||
|
"castle.yaml loaded",
|
||||||
|
detail=f"gateway :{gw.port}, tls={tls}"
|
||||||
|
+ (f", domain={gw.domain}" if gw.domain else ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if config.repo:
|
||||||
|
checks.append(Check(OK, "repo: set", detail=str(config.repo)))
|
||||||
|
else:
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
FAIL,
|
||||||
|
"repo: not set in castle.yaml",
|
||||||
|
detail="source: repo:<name> cannot resolve castle's own programs",
|
||||||
|
hint="add 'repo: <path-to-castle-checkout>' to ~/.castle/castle.yaml",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = [n for n in (_GATEWAY, _API, _DASHBOARD) if n not in config.deployments]
|
||||||
|
if not missing:
|
||||||
|
checks.append(
|
||||||
|
Check(OK, "control plane registered", detail="gateway, api, dashboard")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
FAIL,
|
||||||
|
"control plane missing",
|
||||||
|
detail=", ".join(missing),
|
||||||
|
hint="re-run ./install.sh (it seeds the control plane from bootstrap/)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
checks.append(_check_dashboard_built(config))
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def _check_dashboard_built(config) -> Check:
|
||||||
|
from castle_core.lifecycle import _static_built
|
||||||
|
|
||||||
|
if _DASHBOARD not in config.deployments:
|
||||||
|
return Check(WARN, "dashboard not registered", detail="skipping build check")
|
||||||
|
if _static_built(_DASHBOARD, config):
|
||||||
|
return Check(OK, "dashboard built", detail="app/dist/")
|
||||||
|
return Check(
|
||||||
|
WARN,
|
||||||
|
"dashboard not built",
|
||||||
|
detail="gateway has no UI to serve at /",
|
||||||
|
hint=f"castle program build {_DASHBOARD}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Runtime ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _deployment_port(config, name: str) -> int | None:
|
||||||
|
dep = config.deployments.get(name)
|
||||||
|
expose = getattr(dep, "expose", None)
|
||||||
|
http = getattr(expose, "http", None)
|
||||||
|
internal = getattr(http, "internal", None)
|
||||||
|
return getattr(internal, "port", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_runtime(config) -> list[Check]:
|
||||||
|
from castle_core.config import SPECS_DIR
|
||||||
|
from castle_core.lifecycle import is_active
|
||||||
|
|
||||||
|
checks: list[Check] = []
|
||||||
|
|
||||||
|
# Gateway: active + actually listening on its port.
|
||||||
|
gw_port = config.gateway.port
|
||||||
|
if is_active(_GATEWAY, config):
|
||||||
|
if _port_open(gw_port):
|
||||||
|
checks.append(Check(OK, "gateway running", detail=f"listening on :{gw_port}"))
|
||||||
|
else:
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
WARN,
|
||||||
|
"gateway active but not listening",
|
||||||
|
detail=f":{gw_port} refused",
|
||||||
|
hint="castle gateway reload; check 'castle service logs castle-gateway'",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
FAIL,
|
||||||
|
"gateway not running",
|
||||||
|
hint="castle deploy && castle start",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# API: active + listening on its port.
|
||||||
|
api_port = _deployment_port(config, _API)
|
||||||
|
if is_active(_API, config):
|
||||||
|
if api_port and not _port_open(api_port):
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
WARN,
|
||||||
|
"castle-api active but not listening",
|
||||||
|
detail=f":{api_port} refused",
|
||||||
|
hint="castle service logs castle-api",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
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 deploy && castle start")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generated artifacts.
|
||||||
|
registry = SPECS_DIR / "registry.yaml"
|
||||||
|
caddyfile = SPECS_DIR / "Caddyfile"
|
||||||
|
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()
|
||||||
|
]
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
FAIL,
|
||||||
|
"generated specs missing",
|
||||||
|
detail=", ".join(missing),
|
||||||
|
hint="castle deploy",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
# --- TLS & exposure ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _check_tls_exposure(config) -> list[Check]:
|
||||||
|
from castle_core.config import SECRETS_DIR
|
||||||
|
|
||||||
|
checks: list[Check] = []
|
||||||
|
gw = config.gateway
|
||||||
|
tls = (gw.tls or "off").lower()
|
||||||
|
|
||||||
|
if tls == "acme":
|
||||||
|
provider = gw.acme_dns_provider or "cloudflare"
|
||||||
|
|
||||||
|
if not gw.domain:
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
FAIL,
|
||||||
|
"tls=acme but no domain set",
|
||||||
|
hint="add 'domain: <your-zone>' under gateway: in castle.yaml",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# DNS-plugin Caddy (stock apt Caddy has no DNS-01 modules).
|
||||||
|
plugin = Path("/usr/local/bin/caddy")
|
||||||
|
module = f"dns.providers.{provider}"
|
||||||
|
has_module = False
|
||||||
|
if plugin.exists():
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
out = subprocess.run(
|
||||||
|
[str(plugin), "list-modules"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
has_module = module in out.stdout
|
||||||
|
if has_module:
|
||||||
|
checks.append(Check(OK, "DNS-plugin Caddy present", detail=module))
|
||||||
|
else:
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
FAIL,
|
||||||
|
"DNS-plugin Caddy missing",
|
||||||
|
detail=f"need {module} at /usr/local/bin/caddy",
|
||||||
|
hint=f"./install.sh --with-dns-plugin={provider}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Provider token secret.
|
||||||
|
token_name = {"cloudflare": "CLOUDFLARE_API_TOKEN"}.get(
|
||||||
|
provider, f"{provider.upper()}_API_TOKEN"
|
||||||
|
)
|
||||||
|
if (SECRETS_DIR / token_name).exists():
|
||||||
|
checks.append(Check(OK, "provider token present", detail=token_name))
|
||||||
|
else:
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
FAIL,
|
||||||
|
"provider token missing",
|
||||||
|
detail=f"~/.castle/secrets/{token_name}",
|
||||||
|
hint=f"printf '%s' <token> > ~/.castle/secrets/{token_name} && chmod 600 $_",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Can the gateway bind :443/:80?
|
||||||
|
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)]
|
||||||
|
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)")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
missing = []
|
||||||
|
if not gw.public_domain:
|
||||||
|
missing.append("public_domain")
|
||||||
|
if not gw.tunnel_id:
|
||||||
|
missing.append("tunnel_id")
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
FAIL,
|
||||||
|
"public services but tunnel unconfigured",
|
||||||
|
detail="missing " + ", ".join(missing),
|
||||||
|
hint="see docs/tunnel-setup.md",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not is_active("castle-tunnel", config):
|
||||||
|
checks.append(
|
||||||
|
Check(
|
||||||
|
WARN,
|
||||||
|
"castle-tunnel not running",
|
||||||
|
detail="public routes are down",
|
||||||
|
hint="castle service start castle-tunnel",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not checks:
|
||||||
|
checks.append(Check(OK, "off mode — no TLS/exposure to check", detail="localhost only"))
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def _check_privileged_ports() -> Check:
|
||||||
|
try:
|
||||||
|
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:
|
||||||
|
return Check(OK, "can bind :80/:443", detail=f"unprivileged floor {val}")
|
||||||
|
return Check(
|
||||||
|
WARN,
|
||||||
|
"cannot bind :80/:443",
|
||||||
|
detail=f"unprivileged floor is {val}",
|
||||||
|
hint="echo 'net.ipv4.ip_unprivileged_port_start=80' | "
|
||||||
|
"sudo tee /etc/sysctl.d/50-castle-gateway.conf && sudo sysctl --system",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Driver -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def run_doctor(args: argparse.Namespace) -> int:
|
||||||
|
from castle_core.config import load_config
|
||||||
|
|
||||||
|
print("\n\033[1mCastle Doctor\033[0m")
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = load_config()
|
||||||
|
except Exception as exc: # noqa: BLE001 — surface any load failure as the first FAIL
|
||||||
|
print("\n\033[1mConfiguration\033[0m")
|
||||||
|
_print(
|
||||||
|
Check(
|
||||||
|
FAIL,
|
||||||
|
"castle.yaml failed to load",
|
||||||
|
detail=str(exc),
|
||||||
|
hint="check ~/.castle/castle.yaml — re-run ./install.sh to reseed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
sections: list[tuple[str, list[Check]]] = [
|
||||||
|
("Environment", _check_environment()),
|
||||||
|
("Configuration", _check_configuration(config)),
|
||||||
|
("Runtime", _check_runtime(config)),
|
||||||
|
("TLS & exposure", _check_tls_exposure(config)),
|
||||||
|
]
|
||||||
|
|
||||||
|
fails = warns = 0
|
||||||
|
for title, checks in sections:
|
||||||
|
print(f"\n\033[1m{title}\033[0m")
|
||||||
|
for check in checks:
|
||||||
|
_print(check)
|
||||||
|
fails += check.status == FAIL
|
||||||
|
warns += check.status == WARN
|
||||||
|
|
||||||
|
print()
|
||||||
|
if fails:
|
||||||
|
print(f"\033[31m{fails} problem(s)\033[0m" + (f", {warns} warning(s)" if warns else ""))
|
||||||
|
return 1
|
||||||
|
if warns:
|
||||||
|
print(f"\033[33m{warns} warning(s)\033[0m — Castle is up; address when convenient")
|
||||||
|
return 0
|
||||||
|
print("\033[32mAll checks passed.\033[0m")
|
||||||
|
return 0
|
||||||
@@ -193,6 +193,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
subparsers.add_parser("stop", help="Stop all services and the gateway")
|
subparsers.add_parser("stop", help="Stop all services and the gateway")
|
||||||
subparsers.add_parser("restart", help="Restart all services and jobs")
|
subparsers.add_parser("restart", help="Restart all services and jobs")
|
||||||
subparsers.add_parser("status", help="Show status across the platform")
|
subparsers.add_parser("status", help="Show status across the platform")
|
||||||
|
subparsers.add_parser(
|
||||||
|
"doctor", help="Diagnose setup + runtime health, with next-step hints"
|
||||||
|
)
|
||||||
p = subparsers.add_parser("deploy", help="Apply config to runtime (units + Caddyfile)")
|
p = subparsers.add_parser("deploy", help="Apply config to runtime (units + Caddyfile)")
|
||||||
p.add_argument("name", nargs="?", help="Service/job to deploy (default: all)")
|
p.add_argument("name", nargs="?", help="Service/job to deploy (default: all)")
|
||||||
|
|
||||||
@@ -346,6 +349,10 @@ def main() -> int:
|
|||||||
from castle_cli.commands.service import run_status
|
from castle_cli.commands.service import run_status
|
||||||
|
|
||||||
return run_status(args)
|
return run_status(args)
|
||||||
|
if cmd == "doctor":
|
||||||
|
from castle_cli.commands.doctor import run_doctor
|
||||||
|
|
||||||
|
return run_doctor(args)
|
||||||
if cmd == "deploy":
|
if cmd == "deploy":
|
||||||
from castle_cli.commands.deploy import run_deploy
|
from castle_cli.commands.deploy import run_deploy
|
||||||
|
|
||||||
|
|||||||
39
cli/tests/test_doctor.py
Normal file
39
cli/tests/test_doctor.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Tests for castle doctor."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from argparse import Namespace
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from castle_cli.commands.doctor import run_doctor
|
||||||
|
|
||||||
|
|
||||||
|
class TestDoctor:
|
||||||
|
"""The diagnosis path — a bare, unconfigured node should fail loudly."""
|
||||||
|
|
||||||
|
def test_bare_node_reports_problems(self, castle_root: Path, capsys: object) -> None:
|
||||||
|
"""No repo:, no control plane, nothing running → exit 1 with fix hints."""
|
||||||
|
from castle_cli.config import load_config
|
||||||
|
|
||||||
|
# The shared fixture has no repo: and no castle-gateway/api/dashboard, so the
|
||||||
|
# Configuration and Runtime sections must FAIL. Patch where doctor imports it.
|
||||||
|
with patch("castle_core.config.load_config", return_value=load_config(castle_root)):
|
||||||
|
result = run_doctor(Namespace())
|
||||||
|
|
||||||
|
assert result == 1
|
||||||
|
out = capsys.readouterr().out # type: ignore[attr-defined]
|
||||||
|
assert "repo: not set" in out
|
||||||
|
assert "control plane missing" in out
|
||||||
|
# Every failing check offers a concrete next command.
|
||||||
|
assert "castle deploy" in out
|
||||||
|
|
||||||
|
def test_load_failure_is_first_fail(self, capsys: object) -> None:
|
||||||
|
"""A castle.yaml that won't load is surfaced as a FAIL, not a traceback."""
|
||||||
|
with patch("castle_core.config.load_config", side_effect=ValueError("bad yaml")):
|
||||||
|
result = run_doctor(Namespace())
|
||||||
|
|
||||||
|
assert result == 1
|
||||||
|
out = capsys.readouterr().out # type: ignore[attr-defined]
|
||||||
|
assert "failed to load" in out
|
||||||
|
assert "bad yaml" in out
|
||||||
@@ -555,6 +555,7 @@ print_summary() {
|
|||||||
printf "Next steps:\n"
|
printf "Next steps:\n"
|
||||||
printf " castle deploy # Generate registry, systemd units, Caddyfile\n"
|
printf " castle deploy # Generate registry, systemd units, Caddyfile\n"
|
||||||
printf " castle start # Start the gateway, API, and all deployments\n"
|
printf " castle start # Start the gateway, API, and all deployments\n"
|
||||||
|
printf " castle doctor # Verify setup + health (green = good to go)\n"
|
||||||
printf " open http://localhost:9000 # the dashboard\n"
|
printf " open http://localhost:9000 # the dashboard\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user