#!/usr/bin/env sh # Runs only disposable named volumes. IMAGE must already be loaded locally. set -eu image="${1:?Usage: image-smoke.sh IMAGE [PREVIOUS_IMAGE]}" previous="${2:-}" if [ -n "$previous" ]; then printf '%s' "$previous" | grep -Eq '@sha256:[0-9a-f]{64}$' || { echo 'PREVIOUS_IMAGE must be pinned by digest' >&2; exit 1; } elif [ "${REQUIRE_UPGRADE:-false}" = true ] && [ "${FIRST_RELEASE:-false}" != true ]; then echo 'Release requires a digest-pinned previous image; set FIRST_RELEASE=true only for an explicitly approved first release' >&2 exit 1 fi id="evofw-smoke-$(date +%s)-$$" volume="$id-data" restore_volume="$id-restore" container="$id" tmp="$(mktemp -d)" cleanup() { result=$? docker logs "$container" >>"$tmp/container.log" 2>&1 || true if [ "$result" -ne 0 ]; then cat "$tmp/container.log" >&2; fi docker rm -f "$container" >/dev/null 2>&1 || true docker volume rm "$volume" "$restore_volume" >/dev/null 2>&1 || true if [ -n "${SMOKE_ARTIFACT_DIR:-}" ]; then mkdir -p "$SMOKE_ARTIFACT_DIR"; cp "$tmp/container.log" "$SMOKE_ARTIFACT_DIR/"; fi rm -rf "$tmp" } trap cleanup EXIT HUP INT TERM docker volume create "$volume" >/dev/null docker volume create "$restore_volume" >/dev/null start() { docker run -d --name "$container" --mount "type=volume,src=$2,dst=/data" \ -e EVOFW_ALLOW_UNSAFE=true -e AUTH_REQUIRED=false "$1" >/dev/null attempts=0 until docker exec "$container" node -e "fetch('http://127.0.0.1:8080/ready',{signal:AbortSignal.timeout(1000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"; do attempts=$((attempts + 1)) [ "$attempts" -lt 120 ] || { docker logs "$container"; exit 1; } [ "$(docker inspect -f '{{.State.Running}}' "$container")" = true ] || { docker logs "$container"; exit 1; } sleep 1 done } marker() { docker exec "$container" node -e "const D=require('node:module').createRequire('/app/node_modules/@evofw/db/package.json')('better-sqlite3'); const db=new D('/data/app.db'); if(process.argv[1]==='write') db.prepare('INSERT OR REPLACE INTO settings(key,value) VALUES (?,?)').run('ci-smoke','survives'); else if(db.prepare('SELECT value FROM settings WHERE key=?').get('ci-smoke')?.value!=='survives')process.exitCode=1; db.close()" "$1" } stop() { docker stop -t 30 "$container" >/dev/null [ "$(docker inspect -f '{{.State.ExitCode}}' "$container")" = 0 ] [ "$(docker inspect -f '{{.State.OOMKilled}}' "$container")" = false ] docker logs "$container" >>"$tmp/container.log" 2>&1 docker rm "$container" >/dev/null } if [ -n "$previous" ]; then # Explicit previous version/digest is preferable to a mutable latest tag. docker pull "$previous" start "$previous" "$volume" marker write stop start "$image" "$volume" marker read echo 'upgrade: passed' else if [ "${FIRST_RELEASE:-false}" = true ]; then echo 'upgrade: first-release exception explicitly selected (no previous deployment)' else echo 'upgrade: NOT RUN (development smoke only; not release validation)' fi start "$image" "$volume" fi docker exec -i "$container" node --input-type=module <<'NODE' import assert from 'node:assert/strict' import { existsSync, readdirSync } from 'node:fs' for (const path of ['/health', '/ready', '/api/v1/agents']) { const response = await fetch(`http://127.0.0.1:8080${path}`) assert.equal(response.status, 200, path) await response.json() } for (const path of ['/', '/agents']) { const response = await fetch(`http://127.0.0.1:8080${path}`) assert.equal(response.status, 200, path) assert.match(await response.text(), / 100, path) } assert.ok(existsSync('/app/node_modules/@evofw/db/migrations')) assert.ok(existsSync('/app/dist/agent-scripts/install.sh')) for (const name of ['src', 'test', '.turbo', 'pnpm-lock.yaml']) assert.ok(!existsSync(`/app/${name}`), name) assert.ok(!readdirSync('/app/dist').some(name => name.endsWith('.sh') || name.endsWith('.rsc')), 'no duplicate root scripts') NODE marker write sh deploy/docker/backup.sh "$container" "$tmp/backup.db" stop start "$image" "$volume" marker read stop sh deploy/docker/restore.sh "$image" "$restore_volume" "$tmp/backup.db" start "$image" "$restore_volume" marker read stop echo 'image smoke: passed (migration, native SQLite, API, SPA, scripts, SIGTERM, persistence, online backup and restore)'