From 2adc07386372fa2c5303ff5a90db7b4e118d28c6 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Thu, 2 Jul 2026 10:07:58 -0700 Subject: [PATCH] Add castle doctor + getting-started docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 1 + README.md | 37 ++- cli/src/castle_cli/commands/doctor.py | 423 ++++++++++++++++++++++++++ cli/src/castle_cli/main.py | 7 + cli/tests/test_doctor.py | 39 +++ install.sh | 1 + 6 files changed, 502 insertions(+), 6 deletions(-) create mode 100644 cli/src/castle_cli/commands/doctor.py create mode 100644 cli/tests/test_doctor.py diff --git a/AGENTS.md b/AGENTS.md index 6e478d0..64ae9b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,7 @@ castle tool install|uninstall # Platform-wide castle list [--kind ...] [--stack ...] [--json] # all deployments castle status # unified health/status +castle doctor # diagnose setup + runtime, with fix hints castle deploy [name] # apply config → units + Caddyfile castle start | stop | restart # all services (+ gateway) castle gateway start|stop|reload|status # the Caddy gateway diff --git a/README.md b/README.md index 50be11b..1716b8c 100644 --- a/README.md +++ b/README.md @@ -101,18 +101,42 @@ block set, a sensible default set (`claude`, `opencode`, `amplifier`, …) is of ## Quick start -```bash -# Install the CLI (editable, onto your PATH) -uv tool install --editable cli/ +**Prerequisites:** a Debian/Ubuntu-family Linux with `apt` and `sudo`, `systemd` +(user services), and `git`. `install.sh` sets up everything else — Docker, Caddy, +`uv`, and the `castle` CLI itself. -# Bootstrap infrastructure + the ~/.castle tree and a default castle.yaml +```bash +git clone ~/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 -castle list # what's registered 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 ``` +`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://.` 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 `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 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 start | stop | restart # all deployments (+ gateway) castle gateway start|stop|reload|status diff --git a/cli/src/castle_cli/commands/doctor.py b/cli/src/castle_cli/commands/doctor.py new file mode 100644 index 0000000..66275cf --- /dev/null +++ b/cli/src/castle_cli/commands/doctor.py @@ -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: cannot resolve castle's own programs", + hint="add 'repo: ' 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: ' 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' > ~/.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 diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py index 200c34b..0f53a8a 100644 --- a/cli/src/castle_cli/main.py +++ b/cli/src/castle_cli/main.py @@ -193,6 +193,9 @@ def build_parser() -> argparse.ArgumentParser: subparsers.add_parser("stop", help="Stop all services and the gateway") subparsers.add_parser("restart", help="Restart all services and jobs") 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.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 return run_status(args) + if cmd == "doctor": + from castle_cli.commands.doctor import run_doctor + + return run_doctor(args) if cmd == "deploy": from castle_cli.commands.deploy import run_deploy diff --git a/cli/tests/test_doctor.py b/cli/tests/test_doctor.py new file mode 100644 index 0000000..ecc1142 --- /dev/null +++ b/cli/tests/test_doctor.py @@ -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 diff --git a/install.sh b/install.sh index 9b69239..f0d9833 100755 --- a/install.sh +++ b/install.sh @@ -555,6 +555,7 @@ print_summary() { printf "Next steps:\n" printf " castle deploy # Generate registry, systemd units, Caddyfile\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" }