#!/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" marker_id='' marker_name="$id-persistence" 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() { value="$(docker exec -i "$container" node --input-type=module - "$1" "$marker_id" "$marker_name" <<'MARKER' import assert from 'node:assert/strict' const [mode, existingId, name] = process.argv.slice(2) assert.ok(mode === 'write' || mode === 'read') const call = async (path, options = {}) => { const response = await fetch(`http://127.0.0.1:8080/api/v1${path}`, { ...options, signal: AbortSignal.timeout(10000) }) assert.ok(response.ok, `Persistence marker HTTP ${response.status}`) return response.json() } let id = existingId if (mode === 'write' && !id) { const created = await call('/lists', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, type: 'static', entries: ['192.0.2.123/32'] }) }) assert.ok(typeof created.id === 'string' && created.id.length > 0, 'Marker creation did not return an ID') id = created.id } assert.ok(id, 'Persistence marker ID is missing') const stored = await call(`/lists/${encodeURIComponent(id)}`) assert.equal(stored.id, id) assert.equal(stored.name, name) assert.equal(stored.type, 'static') assert.deepEqual(stored.entries, ['192.0.2.123/32'], 'Marker entries did not survive') assert.equal(stored.entry_count, 1) process.stdout.write(id) MARKER )" if [ "$1" = write ]; then marker_id="$value"; fi } 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)'