Merge pull request #542 from aaronsb/feat/migration-checkpoint #24
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Appliance Integration (GHCR images) | |
| # Integration CI for the thin-appliance deployment path. | |
| # | |
| # We deliberately do NOT build the qcow2/OVA in CI. That build runs virt-customize | |
| # under TCG emulation (no /dev/kvm on runners) — 30+ minutes to mostly re-prove | |
| # that apt + get.docker.com can install Docker, the least-interesting layer. Same | |
| # philosophy as the container images: build the artifact locally where it's fast, | |
| # and let CI *integrate* (ADR-103 Stage 2). | |
| # | |
| # What this actually validates — the things that genuinely break: | |
| # * the PUBLISHED GHCR images (kg-api/kg-web/kg-postgres/kg-operator) pull and | |
| # run together, on a clean host, with nothing built locally; | |
| # * the appliance's real first-boot path — operator.sh init --headless | |
| # --image-source=ghcr — provisions per-instance secrets and brings the stack | |
| # up healthy; | |
| # * the #502 fail-closed secret assertions don't ship a placeholder; | |
| # * the ADR-105 in-VM Traefik router (--router=traefik --tls=selfsigned) serves | |
| # a unified HTTPS ingress: :80 redirects to :443, https://localhost/ -> web, | |
| # https://localhost/api -> api (selfsigned default cert). | |
| # | |
| # Runs on the runner's native CPU, so it finishes in minutes and barely touches | |
| # the Actions minute budget. The qcow2/OVA is built locally and attached to | |
| # releases by hand — see appliance/README.md. | |
| on: | |
| workflow_dispatch: | |
| push: | |
| branches: [main] | |
| # Only when the deployment/provisioning surface this exercises actually | |
| # changes — not on every docs commit. (The container images update | |
| # out-of-band via local publish; run this manually after publishing new | |
| # images, or rely on the path triggers below for provisioning-code changes.) | |
| paths: | |
| - 'operator/**' | |
| - 'docker/**' | |
| - 'schema/**' | |
| - 'install.sh' | |
| - 'appliance/files/**' | |
| - '.github/workflows/appliance-integration.yml' | |
| permissions: | |
| contents: read | |
| packages: read # pull GHCR images (public, but be explicit) | |
| jobs: | |
| integration: | |
| name: Pull GHCR images → first-boot provision → health | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Free up disk | |
| # kg-api carries baked nomic weights + torch; reclaim runner space the | |
| # default image hogs so the pull + volumes have room. | |
| run: | | |
| sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \ | |
| /opt/hostedtoolcache/CodeQL /usr/local/share/boost || true | |
| df -h / | |
| - name: Provision via the appliance first-boot path (GHCR images) | |
| # Byte-for-byte the invocation kg-firstboot.sh runs on a real appliance's | |
| # first power-on, minus the VM-only bits (cloud-init hostname, motd). If | |
| # init exits non-zero the #502 assert_secrets_safe / image pull / compose | |
| # up all failed loudly here. | |
| run: | | |
| ./operator.sh init --headless \ | |
| --container-prefix=kg \ | |
| --image-source=ghcr \ | |
| --gpu=cpu \ | |
| --web-hostname=localhost \ | |
| --router=traefik \ | |
| --tls=selfsigned \ | |
| --skip-cli \ | |
| --skip-ai-config \ | |
| --password-mode=random | |
| - name: Bring up the router (full stack incl. Traefik) | |
| # ROUTER_MODE=traefik is persisted to .operator.conf above; a full | |
| # `start` brings the stack up with the traefik overlay (web/api gain | |
| # routing labels, traefik joins the network). Idempotent if already up. | |
| run: ./operator.sh start | |
| - name: Wait for platform health | |
| # api /health gates on postgres+garage healthy and the nomic model load; | |
| # web depends_on api service_healthy. Both green = the published images | |
| # integrate. Generous deadline: the kg-api image pull is large. | |
| run: | | |
| deadline=$((SECONDS + 900)) | |
| ok_api=0; ok_web=0 | |
| while [ "$SECONDS" -lt "$deadline" ]; do | |
| curl -fsS http://localhost:8000/health >/dev/null 2>&1 && ok_api=1 | |
| curl -fsS http://localhost:3000/ >/dev/null 2>&1 && ok_web=1 | |
| if [ "$ok_api" -eq 1 ] && [ "$ok_web" -eq 1 ]; then | |
| echo "::notice::platform healthy — API + Web both responding" | |
| exit 0 | |
| fi | |
| sleep 10 | |
| done | |
| echo "timed out (api=$ok_api web=$ok_web)" | |
| exit 1 | |
| - name: Verify Traefik unified HTTPS ingress (ADR-105, TLS=selfsigned) | |
| # The whole point of ROUTER_MODE=traefik + TLS_MODE=selfsigned: one HTTPS | |
| # front door routes to both the SPA and the API, and plain HTTP redirects | |
| # to it. Prove all three: :80 -> 301 https, https:// serves web, | |
| # https:///api/health reaches the API (/api stripped). -k: the selfsigned | |
| # default cert is untrusted by design. | |
| run: | | |
| deadline=$((SECONDS + 120)) | |
| while [ "$SECONDS" -lt "$deadline" ]; do | |
| code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost/ 2>/dev/null || echo 000) | |
| if [ "$code" = "301" ] || [ "$code" = "308" ]; then | |
| # Assert the :80 redirect actually targets https:// (not just any 3xx), | |
| # then that https serves web and reaches the API. -k: selfsigned cert. | |
| if curl -sI http://localhost/ | grep -qi '^location: https://' \ | |
| && curl -fsSk https://localhost/ >/dev/null 2>&1 \ | |
| && curl -fsSk https://localhost/api/health >/dev/null 2>&1; then | |
| echo "::notice::Traefik HTTPS ingress healthy — :80 → $code https, / → web, /api → api" | |
| echo "--- :80 redirect target ---" | |
| curl -sI http://localhost/ | grep -i '^location:' || true | |
| echo "--- /api/health via https ingress ---" | |
| curl -fsSk https://localhost/api/health; echo | |
| exit 0 | |
| fi | |
| fi | |
| sleep 5 | |
| done | |
| echo "::error::Traefik HTTPS ingress did not come up (last :80 code=$code)" | |
| docker ps -a | |
| docker logs "$(docker ps -aqf name=traefik | head -1)" 2>&1 | tail -40 || true | |
| exit 1 | |
| - name: Assert no placeholder secrets shipped (#502) | |
| # Belt-and-suspenders: init already fails closed on placeholder secrets, | |
| # but make the regression test explicit and visible. | |
| run: | | |
| test -f .env || { echo "no .env minted"; exit 1; } | |
| grep -q '^POSTGRES_PASSWORD=' .env | |
| if grep -qiE '^POSTGRES_PASSWORD=(password|CHANGE_THIS)' .env; then | |
| echo "::error::placeholder POSTGRES_PASSWORD shipped — #502 regression" | |
| exit 1 | |
| fi | |
| echo "secrets minted (no placeholder)" | |
| - name: Diagnostics on failure | |
| if: failure() | |
| run: | | |
| echo "===== containers =====" | |
| docker ps -a | |
| echo "===== logs =====" | |
| for c in $(docker ps -aq); do | |
| echo "----- $(docker inspect --format '{{.Name}}' "$c") -----" | |
| docker logs --tail 120 "$c" 2>&1 || true | |
| done | |
| - name: Tear down | |
| if: always() | |
| run: ./operator.sh stop || true |