diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb94d44..923ac4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,4 +18,5 @@ jobs: - run: npm ci - run: npx prisma generate - run: bash -n scripts/install + - run: cd scripts && sha256sum -c install.sha256 - run: npm run build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 13ffa16..52b79ee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,5 +18,6 @@ jobs: - run: npm ci - run: npx prisma generate - run: bash -n scripts/install + - run: cd scripts && sha256sum -c install.sha256 - run: npm run test - run: npm run build diff --git a/AGENTS.md b/AGENTS.md index d5f6af0..1f96344 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,3 +65,14 @@ This version has breaking changes — APIs, conventions, and file structure may - Post-install verification covers container health, persistent DB volume, host execution, PTY relay, MCP discovery, OAuth metadata, consumed claim state, and public HTTPS when configured. - Cloudflare/tunnel credentials are secrets and must be encrypted at rest. Never return them to the browser after save. - Additional VPS targets are explicit later connections. Do not conflate “where GroundControl runs” with “all servers GroundControl may manage.” + + +### Upgrade safety + +- `scripts/install --preview` must remain non-mutating with respect to the running GroundControl instance. +- `--upgrade` must identify persistent `/app/prisma` storage before any migration-capable image is started. +- Upgrade targets should run by resolved registry digest when available; do not regress the canonical installer to mutable-tag-only Compose state. +- Back up SQLite plus Compose/env state while the old container is quiesced. +- If the new container fails startup/health, restore the DB and previous image/config automatically and verify rollback health. +- A successful rollback after a failed upgrade still exits non-zero and reports `upgrade_rolled_back`. +- `--uninstall` preserves data by default. Destructive purge must never be implicit. diff --git a/docs/agent-assisted-distribution.md b/docs/agent-assisted-distribution.md index b3479ad..9371ce7 100644 --- a/docs/agent-assisted-distribution.md +++ b/docs/agent-assisted-distribution.md @@ -97,3 +97,22 @@ The result is persisted and returned as structured data instead of shell-log sou - publishing requires authenticated admin access; - external agents receive MCP/OAuth grants, not operator sessions; - provider/tunnel credentials remain encrypted at rest. + + +## Upgrade and recovery contract + +The canonical installer also owns upgrades. Agents should call `--preview` before `--upgrade`. + +A guarded upgrade: + +1. identifies the current image and persistent `/app/prisma` storage; +2. pulls and resolves the requested image to a digest; +3. quiesces the app and backs up SQLite plus Compose/env state; +4. starts the new digest-pinned image; +5. treats the container health check as the migration/startup acceptance gate; +6. restores the previous DB, image and configuration if the gate fails; +7. returns structured evidence. + +An automatic rollback that restores health exits non-zero because the requested upgrade still failed. Agents should report the rollback as successful recovery, not as a successful upgrade. + +`--uninstall` is data-preserving by default. It removes the runtime but leaves the database volume and install directory available for recovery. diff --git a/docs/agent-assisted-installation.md b/docs/agent-assisted-installation.md index 21b0f5a..a9fb5b9 100644 --- a/docs/agent-assisted-installation.md +++ b/docs/agent-assisted-installation.md @@ -25,6 +25,17 @@ curl -fsSL https://raw.githubusercontent.com/teckedd-code2save/groundcontrol/mai | sudo bash -s -- --json ``` +For a download/verify/run flow: + +```bash +curl -fsSLO https://raw.githubusercontent.com/teckedd-code2save/groundcontrol/main/scripts/install +curl -fsSLO https://raw.githubusercontent.com/teckedd-code2save/groundcontrol/main/scripts/install.sha256 +sha256sum -c install.sha256 +sudo bash install --json +``` + +CI verifies `scripts/install.sha256` on every change to keep the published checksum in sync. + Progress is written to stderr in JSON mode. stdout contains one final JSON object. ## Fresh-install result @@ -82,11 +93,57 @@ Running the canonical installer against a healthy existing instance does not upd - already claimed → `already_claimed`, no change; - healthy but unclaimed → rotate/return a fresh claim, no image change; -- explicit `--upgrade` → refresh the requested image/version. +- explicit `--upgrade` → run the guarded upgrade transaction. + +## Upgrade preview + +Before changing a running instance: + +```bash +curl -fsSL https://raw.githubusercontent.com/teckedd-code2save/groundcontrol/main/scripts/install \ + | sudo bash -s -- --preview --version --json +``` + +Preview checks the current container, persistent DB mount, target image reachability and available disk without changing the running instance. + +## Guarded upgrade + +```bash +curl -fsSL https://raw.githubusercontent.com/teckedd-code2save/groundcontrol/main/scripts/install \ + | sudo bash -s -- --upgrade --version --json +``` + +GroundControl: + +1. pulls the requested image while the current instance is still running; +2. resolves the pulled tag to a registry digest when available; +3. stops the current container briefly for a consistent SQLite volume backup; +4. saves the previous Compose/env state; +5. starts the digest-pinned image; +6. waits for the health check after migrations; +7. restores the DB + previous image/config automatically when the new instance fails health; +8. records `result.json` and failed-upgrade logs in the backup directory. + +Machine-readable outcomes include: + +- `upgrade_complete` +- `upgrade_rolled_back` +- `rollback_failed` + +The last three backup directories are retained by default. + +## Data-preserving uninstall + +```bash +curl -fsSL https://raw.githubusercontent.com/teckedd-code2save/groundcontrol/main/scripts/install \ + | sudo bash -s -- --uninstall --json +``` + +This removes the runtime container/bridge processes but preserves the GroundControl DB volume and install directory for recovery. ## Versioning -`--version TAG` pins the container tag/short SHA. Production distribution should prefer published release tags or immutable SHA tags over `latest`. +`--version TAG` selects a release/short-SHA tag. The installer resolves that tag to the pulled registry digest when possible and writes the immutable digest into the generated Compose file. ## Security properties @@ -97,6 +154,6 @@ Running the canonical installer against a healthy existing instance does not upd - claim token is random, short-lived, hashed at rest, single-use; - no bootstrap credential survives successful claim. -## Next distribution slice +## Post-claim distribution -The remaining #93 work is post-claim automation: supported HTTPS/Caddy or outbound bridge setup, structured host-execution/PTY/MCP/OAuth verification, release-channel upgrades and rollback evidence. +After claim, onboarding can keep the instance private or publish it through direct Caddy HTTPS, a named Cloudflare Tunnel, or a temporary outbound HTTPS bridge. GroundControl then verifies host execution, PTY, MCP discovery, OAuth metadata, persistent storage, consumed claim state and public HTTPS when configured. diff --git a/scripts/install b/scripts/install index 5d2f48a..7d4fcde 100644 --- a/scripts/install +++ b/scripts/install @@ -17,6 +17,9 @@ INSTALL_DIR="/opt/groundcontrol" JSON_MODE=0 CLAIM_TTL_MINUTES="30" UPGRADE=0 +PREVIEW=0 +UNINSTALL=0 +BACKUP_KEEP=3 IMAGE_REPO="ghcr.io/teckedd-code2save/groundcontrol" log() { @@ -42,6 +45,9 @@ GroundControl installer Usage: install [--version TAG] [--port PORT] [--dir PATH] [--claim-ttl MINUTES] [--json] + install --preview [--version TAG] [--json] + install --upgrade [--version TAG] [--json] + install --uninstall [--json] Options: --version TAG Container tag/short SHA. Default: latest. @@ -49,11 +55,13 @@ Options: --dir PATH Install directory. Default: /opt/groundcontrol. --claim-ttl MINUTES One-time human claim lifetime, 5-120. Default: 30. --json Emit one machine-readable result on stdout; progress goes to stderr. - --upgrade Explicitly refresh a healthy existing install to the requested image tag. + --preview Check the requested image and local upgrade prerequisites without changing the instance. + --upgrade Upgrade with a quiesced SQLite backup, health verification and automatic rollback. + --uninstall Remove the GroundControl runtime while preserving data and install files. -h, --help Show this help. -The service binds 127.0.0.1 only. Use an HTTPS reverse proxy or an SSH tunnel -to complete the one-time human claim. +The service binds 127.0.0.1 only. After human claim, publish the instance +through GroundControl's HTTPS/domain or outbound bridge flow. USAGE } @@ -75,6 +83,10 @@ while [ "$#" -gt 0 ]; do JSON_MODE=1; shift ;; --upgrade) UPGRADE=1; shift ;; + --preview) + PREVIEW=1; shift ;; + --uninstall) + UNINSTALL=1; shift ;; -h|--help) usage; exit 0 ;; *) @@ -92,6 +104,12 @@ case "$CLAIM_TTL_MINUTES" in esac [ "$CLAIM_TTL_MINUTES" -ge 5 ] && [ "$CLAIM_TTL_MINUTES" -le 120 ] || fail "Claim TTL must be between 5 and 120 minutes" +case "$VERSION" in + ''|*[!A-Za-z0-9_.-]*) fail "Image version contains unsupported characters" ;; +esac +[ "$PREVIEW" -eq 0 ] || [ "$UNINSTALL" -eq 0 ] || fail "--preview and --uninstall cannot be combined" +[ "$UPGRADE" -eq 0 ] || [ "$UNINSTALL" -eq 0 ] || fail "--upgrade and --uninstall cannot be combined" + [ "$(uname -s 2>/dev/null || true)" = "Linux" ] || fail "This installer currently supports Linux hosts" if ! command -v docker >/dev/null 2>&1; then @@ -111,9 +129,133 @@ else fail "Docker Compose is required" fi +container_health() { + docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' groundcontrol-web 2>/dev/null || true +} + +wait_healthy() { + local tries="$1" + local i + for i in $(seq 1 "$tries"); do + local health + health="$(container_health)" + if [ "$health" = "healthy" ] || [ "$health" = "running" ]; then + return 0 + fi + sleep 2 + done + return 1 +} + +image_repo_digest() { + local image="$1" + docker image inspect "$image" --format '{{range .RepoDigests}}{{println .}}{{end}}' 2>/dev/null | grep "^$IMAGE_REPO@" | head -n 1 || true +} + +database_mount() { + docker inspect groundcontrol-web --format '{{range .Mounts}}{{if eq .Destination "/app/prisma"}}{{.Type}}|{{.Name}}|{{.Source}}{{end}}{{end}}' 2>/dev/null || true +} + +backup_existing() { + local image_id="$1" + local mount_line="$2" + local backup_dir="$3" + local mount_type="" + local mount_name="" + local mount_source="" + + mkdir -p "$backup_dir/db" + chmod 700 "$backup_dir" + [ -f "$INSTALL_DIR/.env" ] && cp -p "$INSTALL_DIR/.env" "$backup_dir/env" + [ -f "$INSTALL_DIR/docker-compose.yml" ] && cp -p "$INSTALL_DIR/docker-compose.yml" "$backup_dir/docker-compose.yml" + + IFS='|' read -r mount_type mount_name mount_source </dev/null 2>&1 || true + + IFS='|' read -r mount_type mount_name mount_source </dev/null 2>&1 || true ;; esac + fi + + cd "$INSTALL_DIR" + $COMPOSE up -d --remove-orphans >/dev/null + wait_healthy 36 +} + +write_upgrade_evidence() { + local backup_dir="$1" + local status="$2" + local previous="$3" + local requested="$4" + local resolved="$5" + local rollback="$6" + cat > "$backup_dir/result.json" </dev/null | sort -r) +} + +EXISTING=0 EXISTING_HEALTHY=0 +CURRENT_REF="" +CURRENT_ID="" +CURRENT_DIGEST="" +DB_MOUNT="" if docker inspect groundcontrol-web >/dev/null 2>&1; then - health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' groundcontrol-web 2>/dev/null || true)" + EXISTING=1 + CURRENT_REF="$(docker inspect --format '{{.Config.Image}}' groundcontrol-web 2>/dev/null || true)" + CURRENT_ID="$(docker inspect --format '{{.Image}}' groundcontrol-web 2>/dev/null || true)" + CURRENT_DIGEST="$(image_repo_digest "$CURRENT_ID")" + DB_MOUNT="$(database_mount)" + health="$(container_health)" if [ "$health" = "healthy" ] || [ "$health" = "running" ]; then EXISTING_HEALTHY=1 fi @@ -124,6 +266,62 @@ if [ "$EXISTING_HEALTHY" -eq 1 ]; then if [ -n "$EXISTING_PORT" ]; then HOST_PORT="$EXISTING_PORT"; fi fi +TARGET_REF="$IMAGE_REPO:$VERSION" +CURRENT_EFFECTIVE="$CURRENT_DIGEST" +if [ -z "$CURRENT_EFFECTIVE" ]; then CURRENT_EFFECTIVE="$CURRENT_REF"; fi +if [ -z "$CURRENT_EFFECTIVE" ]; then CURRENT_EFFECTIVE="none"; fi + +if [ "$UNINSTALL" -eq 1 ]; then + docker rm -f groundcontrol-quick-tunnel >/dev/null 2>&1 || true + for name in $(docker ps -a --format '{{.Names}}' | grep '^gc-control-plane-' || true); do + docker rm -f "$name" >/dev/null 2>&1 || true + done + if [ -f "$INSTALL_DIR/docker-compose.yml" ]; then + (cd "$INSTALL_DIR" && $COMPOSE down --remove-orphans) >/dev/null 2>&1 || true + else + docker rm -f groundcontrol-web >/dev/null 2>&1 || true + fi + if [ "$JSON_MODE" -eq 1 ]; then + printf '{"product":"groundcontrol","stage":"uninstalled","dataPreserved":true,"installDir":"%s","changed":%s}\n' "$(json_escape "$INSTALL_DIR")" "$([ "$EXISTING" -eq 1 ] && printf true || printf false)" + else + log "GroundControl runtime removed. Database volume and $INSTALL_DIR were preserved." + fi + exit 0 +fi + +if [ "$PREVIEW" -eq 1 ]; then + target_status="ready" + if ! docker manifest inspect "$TARGET_REF" >/dev/null 2>&1; then + target_status="failed" + fi + disk_kb="$(df -Pk "$INSTALL_DIR" 2>/dev/null | awk 'NR==2 {print $4}' || true)" + if [ -z "$disk_kb" ]; then disk_kb="0"; fi + disk_mb=$((disk_kb / 1024)) + storage_status="skipped" + if [ "$EXISTING" -eq 1 ]; then + if [ -n "$DB_MOUNT" ]; then storage_status="ready"; else storage_status="failed"; fi + fi + container_status="skipped" + if [ "$EXISTING" -eq 1 ]; then + if [ "$EXISTING_HEALTHY" -eq 1 ]; then container_status="ready"; else container_status="failed"; fi + fi + + if [ "$JSON_MODE" -eq 1 ]; then + printf '{"product":"groundcontrol","stage":"upgrade_preview","currentImage":"%s","targetImage":"%s","hostPort":%s,"existing":%s,"checks":{"container":"%s","storage":"%s","targetImage":"%s","diskMb":%s},"changed":false}\n' "$(json_escape "$CURRENT_EFFECTIVE")" "$(json_escape "$TARGET_REF")" "$HOST_PORT" "$([ "$EXISTING" -eq 1 ] && printf true || printf false)" "$container_status" "$storage_status" "$target_status" "$disk_mb" + else + log "GroundControl upgrade preview" + log " current: $CURRENT_EFFECTIVE" + log " target: $TARGET_REF" + log " container: $container_status" + log " DB storage: $storage_status" + log " target registry check: $target_status" + log " free disk: $disk_mb MB" + fi + [ "$target_status" = "ready" ] || exit 1 + [ "$storage_status" != "failed" ] || exit 1 + exit 0 +fi + if [ "$EXISTING_HEALTHY" -eq 1 ] && [ "$UPGRADE" -eq 0 ]; then log "[install] healthy GroundControl already exists; leaving image and Compose unchanged" CLAIM_JSON="$(docker exec groundcontrol-web node /app/scripts/create-install-claim.cjs --ttl-minutes "$CLAIM_TTL_MINUTES" 2>/dev/null)" || { @@ -191,7 +389,32 @@ else fi fi -IMAGE="$IMAGE_REPO:$VERSION" +if [ "$UPGRADE" -eq 1 ] && [ "$EXISTING" -eq 1 ] && [ -z "$DB_MOUNT" ]; then + fail "Upgrade refused: persistent /app/prisma storage could not be identified for backup" +fi + +log "[install] pulling $TARGET_REF" +docker pull "$TARGET_REF" >/dev/null +RESOLVED_IMAGE="$(image_repo_digest "$TARGET_REF")" +if [ -z "$RESOLVED_IMAGE" ]; then RESOLVED_IMAGE="$TARGET_REF"; fi +IMAGE="$RESOLVED_IMAGE" + +BACKUP_DIR="" +if [ "$UPGRADE" -eq 1 ] && [ "$EXISTING" -eq 1 ]; then + [ -n "$CURRENT_ID" ] || fail "Upgrade refused: current image identity could not be determined" + timestamp="$(date -u +%Y%m%dT%H%M%SZ)" + short_id="$(printf '%s' "$CURRENT_ID" | sed 's/^sha256://' | cut -c1-12)" + BACKUP_DIR="$INSTALL_DIR/backups/$timestamp-$short_id" + log "[upgrade] stopping GroundControl briefly for a consistent SQLite backup" + docker stop groundcontrol-web >/dev/null + if ! backup_existing "$CURRENT_ID" "$DB_MOUNT" "$BACKUP_DIR"; then + docker start groundcontrol-web >/dev/null 2>&1 || true + fail "Upgrade refused: persistent database backup failed" + fi + write_upgrade_evidence "$BACKUP_DIR" "backup_ready" "$CURRENT_EFFECTIVE" "$TARGET_REF" "$RESOLVED_IMAGE" "not_needed" + log "[upgrade] backup ready at $BACKUP_DIR" +fi + cat > "$INSTALL_DIR/docker-compose.yml" </dev/null | grep -q healthy; then - ready=1 - break - fi - sleep 2 -done +log "[install] starting GroundControl with immutable image $RESOLVED_IMAGE" +start_ok=1 +if ! $COMPOSE up -d --remove-orphans >/dev/null; then + start_ok=0 +fi -if [ "$ready" -ne 1 ]; then +if [ "$start_ok" -ne 1 ] || ! wait_healthy 36; then $COMPOSE ps >&2 || true - $COMPOSE logs --tail=100 web >&2 || true + $COMPOSE logs --tail=120 web >&2 || true + + if [ -n "$BACKUP_DIR" ]; then + $COMPOSE logs --tail=200 web > "$BACKUP_DIR/failed-upgrade.log" 2>&1 || true + log "[upgrade] new image failed health; restoring previous database and image" + if restore_backup "$CURRENT_ID" "$CURRENT_REF" "$DB_MOUNT" "$BACKUP_DIR"; then + write_upgrade_evidence "$BACKUP_DIR" "upgrade_rolled_back" "$CURRENT_EFFECTIVE" "$TARGET_REF" "$RESOLVED_IMAGE" "success" + if [ "$JSON_MODE" -eq 1 ]; then + printf '{"product":"groundcontrol","stage":"upgrade_rolled_back","previousImage":"%s","requestedImage":"%s","resolvedImage":"%s","backup":"%s","checks":{"upgradeHealth":"failed","rollbackHealth":"ready"},"changed":false}\n' "$(json_escape "$CURRENT_EFFECTIVE")" "$(json_escape "$TARGET_REF")" "$(json_escape "$RESOLVED_IMAGE")" "$(json_escape "$BACKUP_DIR")" + else + log "Upgrade failed. GroundControl rolled back successfully and is healthy on the previous image." + log "Evidence: $BACKUP_DIR" + fi + exit 1 + fi + + write_upgrade_evidence "$BACKUP_DIR" "rollback_failed" "$CURRENT_EFFECTIVE" "$TARGET_REF" "$RESOLVED_IMAGE" "failed" + if [ "$JSON_MODE" -eq 1 ]; then + printf '{"product":"groundcontrol","stage":"rollback_failed","previousImage":"%s","requestedImage":"%s","resolvedImage":"%s","backup":"%s","checks":{"upgradeHealth":"failed","rollbackHealth":"failed"},"changed":true}\n' "$(json_escape "$CURRENT_EFFECTIVE")" "$(json_escape "$TARGET_REF")" "$(json_escape "$RESOLVED_IMAGE")" "$(json_escape "$BACKUP_DIR")" + fi + fail "GroundControl upgrade and automatic rollback both failed; evidence is in $BACKUP_DIR" + fi + fail "GroundControl did not become healthy" fi +if [ -n "$BACKUP_DIR" ]; then + write_upgrade_evidence "$BACKUP_DIR" "upgrade_complete" "$CURRENT_EFFECTIVE" "$TARGET_REF" "$RESOLVED_IMAGE" "not_needed" + prune_backups +fi + CLAIM_JSON="$(docker exec groundcontrol-web node /app/scripts/create-install-claim.cjs --ttl-minutes "$CLAIM_TTL_MINUTES")" || fail "GroundControl is healthy but could not create the installation claim" STAGE="$(printf '%s' "$CLAIM_JSON" | sed -n 's/.*"stage":"\([^"]*\)".*/\1/p')" @@ -255,12 +497,23 @@ EXPIRES_AT="$(printf '%s' "$CLAIM_JSON" | sed -n 's/.*"expiresAt":"\([^"]*\)".*/ if [ "$STAGE" = "already_claimed" ]; then if [ "$JSON_MODE" -eq 1 ]; then - printf '{"product":"groundcontrol","stage":"already_claimed","instanceId":"%s","hostPort":%s,"localUrl":"http://127.0.0.1:%s","checks":{"docker":"ready","container":"ready","health":"ready"}}\n' "$(json_escape "$INSTANCE_ID")" "$HOST_PORT" "$HOST_PORT" + if [ -n "$BACKUP_DIR" ]; then + printf '{"product":"groundcontrol","stage":"upgrade_complete","instanceId":"%s","hostPort":%s,"localUrl":"http://127.0.0.1:%s","previousImage":"%s","image":"%s","backup":"%s","checks":{"container":"ready","health":"ready","storageBackup":"ready"},"changed":true}\n' "$(json_escape "$INSTANCE_ID")" "$HOST_PORT" "$HOST_PORT" "$(json_escape "$CURRENT_EFFECTIVE")" "$(json_escape "$RESOLVED_IMAGE")" "$(json_escape "$BACKUP_DIR")" + else + printf '{"product":"groundcontrol","stage":"already_claimed","instanceId":"%s","hostPort":%s,"localUrl":"http://127.0.0.1:%s","image":"%s","checks":{"docker":"ready","container":"ready","health":"ready"},"changed":true}\n' "$(json_escape "$INSTANCE_ID")" "$HOST_PORT" "$HOST_PORT" "$(json_escape "$RESOLVED_IMAGE")" + fi else log "" - log "GroundControl is already claimed and healthy." - log "Instance: $INSTANCE_ID" - log "Local endpoint: http://127.0.0.1:$HOST_PORT" + if [ -n "$BACKUP_DIR" ]; then + log "GroundControl upgrade completed and verified." + log "Previous image: $CURRENT_EFFECTIVE" + log "Running image: $RESOLVED_IMAGE" + log "Rollback backup: $BACKUP_DIR" + else + log "GroundControl is already claimed and healthy." + log "Instance: $INSTANCE_ID" + log "Local endpoint: http://127.0.0.1:$HOST_PORT" + fi fi exit 0 fi @@ -272,8 +525,8 @@ CLAIM_PATH="/claim#token=$CLAIM_TOKEN" LOCAL_CLAIM_URL="http://127.0.0.1:$HOST_PORT$CLAIM_PATH" if [ "$JSON_MODE" -eq 1 ]; then - printf '{"product":"groundcontrol","stage":"claim_required","instanceId":"%s","localUrl":"http://127.0.0.1:%s","claimUrl":"http://127.0.0.1:%s%s","claimPath":"%s","claimToken":"%s","expiresAt":"%s","hostPort":%s,"checks":{"docker":"ready","container":"ready","health":"ready"},"next":"human_claim"}\n' \ - "$(json_escape "$INSTANCE_ID")" "$HOST_PORT" "$HOST_PORT" "$(json_escape "$CLAIM_PATH")" "$(json_escape "$CLAIM_PATH")" "$(json_escape "$CLAIM_TOKEN")" "$(json_escape "$EXPIRES_AT")" "$HOST_PORT" + printf '{"product":"groundcontrol","stage":"claim_required","instanceId":"%s","localUrl":"http://127.0.0.1:%s","claimUrl":"http://127.0.0.1:%s%s","claimPath":"%s","claimToken":"%s","expiresAt":"%s","hostPort":%s,"image":"%s","checks":{"docker":"ready","container":"ready","health":"ready"},"next":"human_claim","changed":true}\n' \ + "$(json_escape "$INSTANCE_ID")" "$HOST_PORT" "$HOST_PORT" "$(json_escape "$CLAIM_PATH")" "$(json_escape "$CLAIM_PATH")" "$(json_escape "$CLAIM_TOKEN")" "$(json_escape "$EXPIRES_AT")" "$HOST_PORT" "$(json_escape "$RESOLVED_IMAGE")" else log "" log "GroundControl is installed and waiting for its owner." diff --git a/scripts/install.sha256 b/scripts/install.sha256 new file mode 100644 index 0000000..a087f69 --- /dev/null +++ b/scripts/install.sha256 @@ -0,0 +1 @@ +f7fc493e177ad5f03450bc16647022717510db29e5ed5ca0ea03976fe1af5587 install diff --git a/src/lib/distribution-installer.test.ts b/src/lib/distribution-installer.test.ts new file mode 100644 index 0000000..54e6cc0 --- /dev/null +++ b/src/lib/distribution-installer.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment node +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const installer = readFileSync(join(process.cwd(), "scripts", "install"), "utf8"); + +describe("GroundControl distribution installer contract", () => { + it("keeps preview, upgrade and uninstall on the canonical install surface", () => { + expect(installer).toContain("--preview"); + expect(installer).toContain("--upgrade"); + expect(installer).toContain("--uninstall"); + expect(installer).toContain('"stage":"upgrade_preview"'); + expect(installer).toContain('"stage":"uninstalled"'); + }); + + it("pins the requested image to a resolved registry digest when available", () => { + expect(installer).toContain("image_repo_digest"); + expect(installer).toContain("RESOLVED_IMAGE"); + expect(installer).toContain('IMAGE="$RESOLVED_IMAGE"'); + expect(installer).toContain("starting GroundControl with immutable image"); + }); + + it("requires persistent database identity before upgrading", () => { + expect(installer).toContain('Destination "/app/prisma"'); + expect(installer).toContain("persistent /app/prisma storage could not be identified for backup"); + expect(installer).toContain("backup_existing"); + }); + + it("backs up SQLite before upgrade and restores it on failed health", () => { + expect(installer).toContain("consistent SQLite backup"); + expect(installer).toContain("restore_backup"); + expect(installer).toContain('"stage":"upgrade_rolled_back"'); + expect(installer).toContain('"stage":"rollback_failed"'); + expect(installer).toContain("failed-upgrade.log"); + }); + + it("reports a distinct successful upgrade result with rollback evidence", () => { + expect(installer).toContain('"stage":"upgrade_complete"'); + expect(installer).toContain('"storageBackup":"ready"'); + expect(installer).toContain("result.json"); + }); + + it("keeps uninstall data-preserving by default", () => { + expect(installer).toContain('"dataPreserved":true'); + expect(installer).toContain("Database volume"); + expect(installer).not.toContain("docker compose down -v"); + }); + + it("keeps the management port loopback-only", () => { + expect(installer).toContain('127.0.0.1:'); + expect(installer).not.toContain("/root/.ssh"); + }); +});