Files
ServerKit/scripts/test/smoke-docker-image.sh
Juan DenisandClaude Opus 5 4f12f8164d ci: boot the built image, and check the agent download URLs resolve
Issue #101 shipped because release.yml went straight from `build` to `push`
with nothing in between. The image was missing scripts/, so every containerised
panel 404'd its own enrollment URL, while the entire test suite stayed green --
pytest runs against the source tree, where the file always exists. Only booting
the artifact catches that class of bug.

scripts/test/smoke-docker-image.sh builds (or takes) an image, asserts the files
the panel reads at request time are present, boots it, and requests every route
that serves a file off disk. release.yml now builds amd64, runs it, and only
then pushes multi-arch (layers are cached, so the push reuses the build).
release-smoke.yml gets the same job on dev and PRs, and its path filters now
include Dockerfile, .dockerignore and scripts/ -- none of which used to trigger
it.

scripts/test/verify-agent-release-chain.sh covers the other half, which unit
tests structurally cannot: they stub curl, so they only ever prove the installer
is self-consistent, never that the URL it builds resolves to a real file. This
one reads the coordinates back out of the script, asks GitHub, downloads the
archive, verifies the checksum and confirms the tarball entry name the installer
moves. It runs nightly, and is also the tripwire for the reverse case -- the
agent repo changing its release naming and breaking the panel from the other
side of the repo split.

Both are plain scripts so the same check runs locally:
  bash scripts/test/smoke-docker-image.sh
  bash scripts/test/verify-agent-release-chain.sh

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 05:27:38 -04:00

146 lines
5.5 KiB
Bash

#!/usr/bin/env bash
#
# Smoke-test a BUILT ServerKit image.
#
# This is the check that issue #101 needed and nobody had. Everything the panel
# reads off its own filesystem at request time -- installers, the SPA bundle,
# the VERSION file -- exists in the source tree, so the whole pytest suite passes
# while the shipped image is missing the file. The only way to catch that class
# of bug is to boot the artifact you are about to publish and ask it for the
# things it promises to serve.
#
# Usage:
# bash scripts/test/smoke-docker-image.sh # builds serverkit:smoke, then tests
# bash scripts/test/smoke-docker-image.sh <image[:tag]> # tests an image you already have
# SKIP_BUILD=1 bash scripts/test/smoke-docker-image.sh # reuse the existing serverkit:smoke
#
# Exits non-zero on the first failed assertion, with the response that failed.
set -uo pipefail
IMAGE="${1:-serverkit:smoke}"
PORT="${SMOKE_PORT:-45990}"
CONTAINER="serverkit-smoke-$$"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
PASS=0
FAIL=0
ok() { PASS=$((PASS + 1)); printf ' \033[32m✔\033[0m %s\n' "$1"; }
bad() { FAIL=$((FAIL + 1)); printf ' \033[31m✘\033[0m %s\n' "$1"; }
cleanup() {
if [ -n "${CONTAINER:-}" ] && docker inspect "$CONTAINER" >/dev/null 2>&1; then
printf '\n--- container logs (tail) ---\n'
docker logs --tail 40 "$CONTAINER" 2>&1 || true
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
# --------------------------------------------------------------------------
# Build (unless we were handed an image or told to skip)
# --------------------------------------------------------------------------
if [ -z "${SKIP_BUILD:-}" ] && [ "$IMAGE" = "serverkit:smoke" ]; then
printf '\nBuilding %s (this is the slow part)...\n' "$IMAGE"
if ! docker build -t "$IMAGE" "$REPO_ROOT"; then
printf '\n\033[31mimage build failed\033[0m\n'
exit 1
fi
fi
printf '\nServerKit image smoke test — %s\n\n' "$IMAGE"
# --------------------------------------------------------------------------
# Filesystem assertions: cheap, and they name the missing path directly rather
# than making you infer it from an HTTP status.
# --------------------------------------------------------------------------
for f in /app/scripts/install.sh /app/scripts/install.ps1 /app/VERSION \
/app/frontend/dist/index.html; do
if docker run --rm --entrypoint sh "$IMAGE" -c "test -f $f" 2>/dev/null; then
ok "image contains $f"
else
bad "image is MISSING $f — it will be requested at runtime"
fi
done
# --------------------------------------------------------------------------
# Boot it
# --------------------------------------------------------------------------
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
if ! docker run -d --name "$CONTAINER" -p "${PORT}:5000" \
-e SECRET_KEY=smoke-secret \
-e JWT_SECRET_KEY=smoke-jwt \
"$IMAGE" >/dev/null; then
bad "container failed to start"
exit 1
fi
BASE="http://127.0.0.1:${PORT}"
printf ' waiting for %s/api/v1/system/health ...\n' "$BASE"
ready=""
for _ in $(seq 1 90); do
if curl -fsS -o /dev/null "${BASE}/api/v1/system/health" 2>/dev/null; then
ready=1
break
fi
# A container that has already exited will never become ready.
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null)" != "true" ]; then
bad "container exited during boot"
exit 1
fi
sleep 2
done
if [ -n "$ready" ]; then
ok "panel boots and answers /api/v1/system/health"
else
bad "panel never became healthy"
exit 1
fi
# --------------------------------------------------------------------------
# HTTP assertions: every route that serves a file off the panel's own disk.
# --------------------------------------------------------------------------
assert_http() {
local path="$1" want="$2" desc="$3"
local code
code="$(curl -s -o /tmp/smoke-body.$$ -w '%{http_code}' "${BASE}${path}")"
if [ "$code" = "$want" ]; then
ok "$desc"
else
bad "$desc — got HTTP $code, want $want: $(head -c 200 /tmp/smoke-body.$$)"
fi
rm -f /tmp/smoke-body.$$
}
assert_body() {
local path="$1" needle="$2" desc="$3"
if curl -fsS "${BASE}${path}" 2>/dev/null | grep -q -- "$needle"; then
ok "$desc"
else
bad "$desc — '$needle' not found in the response body"
fi
}
# The exact request from issue #101.
assert_http /api/v1/servers/install.sh 200 "GET /api/v1/servers/install.sh serves the agent installer"
assert_http /api/v1/servers/install.ps1 200 "GET /api/v1/servers/install.ps1 serves the Windows installer"
assert_http / 200 "GET / serves the SPA"
assert_body /api/v1/servers/install.sh '#!/bin/bash' \
"the served install.sh is a real bash script"
# Placeholder substitution runs against the served copy; if it silently stopped
# working the box would try to enroll against a nonexistent domain.
if curl -fsS "${BASE}/api/v1/servers/install.sh" | grep -q 'your-serverkit.com'; then
bad "served install.sh still contains the your-serverkit.com placeholder"
else
ok "served install.sh has this panel's URL substituted in"
fi
assert_body /api/v1/servers/install.sh 'jhd3197/serverkit-agent' \
"the served install.sh downloads from the agent release repo"
assert_body /api/v1/servers/install.ps1 'Install-ServerKitAgent' \
"the served install.ps1 is a real PowerShell installer"
printf '\n%d passed, %d failed\n\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ]