install.sh: bootstrap Castle's own control plane

A fresh clone + ./install.sh set up the infra (Docker, Caddy, MQTT,
Postgres) but left the registry empty — so `castle deploy && castle
start` brought up nothing. install.sh now gets a person all the way to
a running Castle:

- ensure_uv + install_cli: install uv and the `castle` CLI (editable
  from ./cli) so the command exists on a fresh machine.
- seed_control_plane: register castle-gateway, castle-api, and the
  castle dashboard from a new bootstrap/ seed dir, and add `repo:` to
  castle.yaml so `source: repo:<name>` resolves. Never clobbers
  existing entries.
- build_dashboard: build app/dist/ so the gateway has a UI to serve
  (best-effort; warns if pnpm is absent).

The gateway seed uses a `__SPECS_DIR__` placeholder (no machine-specific
paths in the repo), substituted with this machine's specs dir at seed
time. .gitignore negates bootstrap/**/castle.yaml so the seeds are
tracked despite the user-registry ignore rule.

Verified: seeded config loads, derives kinds service/service/static,
and renders an off-mode Caddyfile serving the dashboard at :9000/ with
/api/* proxied to castle-api.
This commit is contained in:
2026-07-01 16:33:42 -07:00
parent 442b1692d9
commit d34ff9220d
7 changed files with 145 additions and 2 deletions

1
.gitignore vendored
View File

@@ -5,4 +5,5 @@ __pycache__
.claude .claude
node_modules node_modules
castle.yaml castle.yaml
!bootstrap/**/castle.yaml
.working .working

View File

@@ -0,0 +1,18 @@
program: castle-api
description: Castle API
manager: systemd
run:
launcher: python
program: castle-api
expose:
http:
internal:
port: 9020
health_path: /health
proxy: true
manage:
systemd: {}
defaults:
env:
CASTLE_API_PORT: ${port}
CASTLE_API_DATA_DIR: ${data_dir}

View File

@@ -0,0 +1,22 @@
# The Caddy gateway. `__SPECS_DIR__` is substituted with $CASTLE_HOME/artifacts/specs
# at install time (install.sh seed_control_plane). For acme TLS, add a
# CLOUDFLARE_API_TOKEN to defaults.env later — see docs/dns-and-tls.md.
description: Caddy reverse proxy gateway
manager: systemd
run:
launcher: command
argv:
- caddy
- run
- --config
- __SPECS_DIR__/Caddyfile
- --adapter
- caddyfile
expose:
http:
internal:
port: 9000
health_path: /
manage:
systemd:
exec_reload: caddy reload --config __SPECS_DIR__/Caddyfile --adapter caddyfile

View File

@@ -0,0 +1,3 @@
program: castle
description: Castle web app
manager: caddy

View File

@@ -0,0 +1,3 @@
description: Castle API
source: repo:castle-api
stack: python-fastapi

View File

@@ -0,0 +1,6 @@
description: Castle web app
source: repo:app
stack: react-vite
build:
outputs:
- dist/

View File

@@ -215,6 +215,34 @@ ensure_caddy_dns_plugin() {
log_ok log_ok
} }
# ---------------------------------------------------------------------------
# Castle CLI
# ---------------------------------------------------------------------------
ensure_uv() {
log_step "Ensuring uv (Python package manager)"
if cmd_exists uv; then
log_skip "already installed"
return
fi
curl -LsSf https://astral.sh/uv/install.sh | sh >/dev/null 2>&1 || log_fail "uv install failed"
# The installer drops uv in ~/.local/bin — make it visible for the rest of this run.
export PATH="${HOME}/.local/bin:${PATH}"
cmd_exists uv || log_fail "uv installed but not on PATH (expected ~/.local/bin)"
log_ok
}
# Install the `castle` CLI from this repo as an editable uv tool, so a fresh
# clone becomes a working `castle` command. Idempotent — reinstall is cheap and
# keeps the entry point pointed at the current checkout.
install_cli() {
log_step "Installing the castle CLI"
( cd "$CASTLE_ROOT" && uv tool install --editable ./cli >/dev/null 2>&1 ) \
|| log_fail "uv tool install of ./cli failed"
cmd_exists castle || log_info "NOTE: ensure ~/.local/bin is on your PATH to use 'castle'"
log_ok
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Directory structure # Directory structure
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -247,6 +275,41 @@ create_directories() {
fi fi
} }
# ---------------------------------------------------------------------------
# Control plane (Castle's own gateway + API + dashboard)
# ---------------------------------------------------------------------------
# Register Castle's own control-plane programs/deployments from bootstrap/ so a
# fresh registry is not empty. Without this, `castle deploy && castle start`
# would bring up nothing. Never clobbers existing entries (idempotent). The
# gateway deployment carries a `__SPECS_DIR__` placeholder (the source repo has
# no machine-specific paths) that we substitute with this machine's specs dir.
seed_control_plane() {
log_step "Registering Castle's control plane"
local specs="${CASTLE_HOME}/artifacts/specs"
# The `repo:` field lets `source: repo:<name>` resolve castle's own programs.
if ! grep -q "^repo:" "${CASTLE_HOME}/castle.yaml" 2>/dev/null; then
printf 'repo: %s\n' "$CASTLE_ROOT" >> "${CASTLE_HOME}/castle.yaml"
fi
local seeded=0 f dst
for f in "${CASTLE_ROOT}"/bootstrap/programs/*.yaml; do
dst="${CASTLE_HOME}/programs/$(basename "$f")"
[ -f "$dst" ] || { cp "$f" "$dst"; seeded=1; }
done
for f in "${CASTLE_ROOT}"/bootstrap/deployments/*.yaml; do
dst="${CASTLE_HOME}/deployments/$(basename "$f")"
[ -f "$dst" ] || { sed "s#__SPECS_DIR__#${specs}#g" "$f" > "$dst"; seeded=1; }
done
if [ "$seeded" = "1" ]; then
log_ok "castle-gateway, castle-api, castle (dashboard)"
else
log_skip "already registered"
fi
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Systemd lingering # Systemd lingering
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -435,6 +498,28 @@ maybe_setup_neo4j() {
fi fi
} }
# ---------------------------------------------------------------------------
# Dashboard build
# ---------------------------------------------------------------------------
# Build the dashboard SPA so the gateway has something to serve at :9000. The
# `castle` program serves `app/dist/` in place; without a build there's no UI.
# Best-effort: a missing pnpm is a warning (the CLI/API still work), not a failure.
build_dashboard() {
log_step "Building the dashboard"
if [ ! -d "${CASTLE_ROOT}/app" ]; then
log_skip "no app/ directory"
return
fi
if ! cmd_exists pnpm; then
log_skip "pnpm not found — build later with 'castle program build castle'"
return
fi
( cd "${CASTLE_ROOT}/app" && pnpm install --silent >/dev/null 2>&1 && pnpm build >/dev/null 2>&1 ) \
|| { log_skip "build failed — retry later with 'castle program build castle'"; return; }
log_ok "app/dist/"
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Summary # Summary
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -468,8 +553,9 @@ print_summary() {
printf "\n" printf "\n"
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 all deployments and the gateway\n" printf " castle start # Start the gateway, API, and all deployments\n"
printf " open http://localhost:9000 # the dashboard\n"
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -500,13 +586,17 @@ main() {
ensure_docker ensure_docker
ensure_caddy ensure_caddy
[ -n "$WITH_DNS_PLUGIN" ] && ensure_caddy_dns_plugin "$WITH_DNS_PLUGIN" [ -n "$WITH_DNS_PLUGIN" ] && ensure_caddy_dns_plugin "$WITH_DNS_PLUGIN"
ensure_uv
install_cli
create_directories create_directories
seed_control_plane
enable_lingering enable_lingering
seed_caddyfile seed_caddyfile
migrate_old_containers migrate_old_containers
setup_mqtt setup_mqtt
setup_postgres setup_postgres
maybe_setup_neo4j maybe_setup_neo4j
build_dashboard
print_summary print_summary
} }