smoke.sh (10471B)
1 #!/bin/sh 2 # deploy/smoke.sh - build the images and run a pipeline through them 3 # 4 # Checks the thing people actually deploy, rather than the code the tests 5 # import: both images build, the conductor starts on an empty volume, 6 # a worker registers, and a real pipeline runs to completion with its log 7 # and artifact readable afterwards. 8 # 9 # Usage: deploy/smoke.sh [--keep] 10 # 11 # --keep leave the stack running afterwards for poking at 12 # 13 # Needs docker and a few hundred megabytes of disk. Takes about a minute. 14 15 set -eu 16 17 KEEP=0 18 [ "${1:-}" = "--keep" ] && KEEP=1 19 20 ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) 21 WORK=$(mktemp -d) 22 PORT="${SMOKE_PORT:-18500}" 23 PROJECT=conductor-smoke 24 SECRET=smoke-trigger-secret 25 26 # Published ports live on the docker host, which is the gateway when this 27 # runs in a container, as it does in CI. 28 if [ -z "${SMOKE_HOST:-}" ] && [ -f /.dockerenv ]; then 29 SMOKE_HOST=$(ip route 2>/dev/null | awk '/^default/{print $3; exit}') 30 fi 31 SMOKE_HOST="${SMOKE_HOST:-127.0.0.1}" 32 33 34 35 log() { printf '\n== %s\n' "$*"; } 36 fail() { printf '\nFAILED: %s\n' "$*" >&2; exit 1; } 37 38 cleanup() { 39 status=$? 40 if [ "${KEEP}" -eq 1 ] && [ "${status}" -eq 0 ]; then 41 printf '\nleaving the stack up; tear it down with:\n' 42 printf ' docker compose -p %s -f %s down -v\n' "${PROJECT}" "${WORK}/compose.yml" 43 return 44 fi 45 printf '\n== cleaning up\n' 46 SMOKE_TOKEN=unused docker compose -p "${PROJECT}" -f "${WORK}/compose.yml" down -v >/dev/null 2>&1 || true 47 rm -rf "${WORK}" 48 } 49 trap cleanup EXIT 50 51 log "building images" 52 docker build -q -f "${ROOT}/deploy/Dockerfile" -t conductor:smoke "${ROOT}" 53 docker build -q -f "${ROOT}/deploy/Dockerfile.worker" -t conductor-worker:smoke "${ROOT}" 54 55 log "preparing a repository" 56 mkdir -p "${WORK}/repo" 57 cat > "${WORK}/repo/.conductor.yml" <<'PIPELINE' 58 version: 1 59 visibility: public 60 defaults: 61 image: alpine:3 62 tasks: 63 build: 64 script: 65 - echo "run $CONDUCTOR_JOB_NUMBER of $CONDUCTOR_PROJECT" 66 - mkdir -p out && echo "packaged" > out/result.txt 67 artifacts: 68 paths: [out/**] 69 publish: 70 needs: [build] 71 script: ['echo published'] 72 release: 73 needs: [build] 74 only: 75 refs: [refs/heads/main] 76 script: ['echo released'] 77 PIPELINE 78 echo 'smoke test repository' > "${WORK}/repo/README.md" 79 git -C "${WORK}/repo" init -q -b main 80 git -C "${WORK}/repo" config user.email smoke@example.invalid 81 git -C "${WORK}/repo" config user.name Smoke 82 git -C "${WORK}/repo" add -A 83 git -C "${WORK}/repo" commit -q -m 'smoke test' 84 SHA=$(git -C "${WORK}/repo" rev-parse HEAD) 85 86 cat > "${WORK}/compose.yml" <<COMPOSE 87 name: ${PROJECT} 88 services: 89 conductor: 90 image: conductor:smoke 91 ports: ["${PORT}:9080"] 92 environment: 93 CONDUCTOR_PORT: "9080" 94 CONDUCTOR_PUBLIC_URL: http://conductor:9080 95 CONDUCTOR_SESSION_SECRET: smoke-session-secret 96 CONDUCTOR_ADMIN_PASSWORD: smoke-admin-password 97 volumes: 98 - smoke-data:/data 99 worker: 100 image: conductor-worker:smoke 101 depends_on: [conductor] 102 environment: 103 CONDUCTOR_URL: http://conductor:9080 104 CONDUCTOR_WORKER_NAME: smoke-worker 105 CONDUCTOR_WORKER_TOKEN: "\${SMOKE_TOKEN}" 106 CONDUCTOR_WORKER_CONCURRENCY: "2" 107 volumes: 108 - /var/run/docker.sock:/var/run/docker.sock 109 volumes: 110 smoke-data: 111 COMPOSE 112 113 compose() { SMOKE_TOKEN="${SMOKE_TOKEN:-unused}" docker compose -p "${PROJECT}" -f "${WORK}/compose.yml" "$@"; } 114 115 log "starting the conductor" 116 compose up -d conductor 117 118 BASE_URL="http://${SMOKE_HOST}:${PORT}" 119 printf 'using %s\n' "${BASE_URL}" 120 121 i=0 122 while [ "${i}" -lt 60 ]; do 123 curl -fsS -m 2 "${BASE_URL}/health" >/dev/null 2>&1 && break 124 i=$((i + 1)) 125 sleep 1 126 done 127 if [ "${i}" -ge 60 ]; then 128 compose logs conductor | tail -40 129 fail "the conductor did not become healthy" 130 fi 131 curl -fsS "${BASE_URL}/health" 132 printf '\n' 133 134 # Copied rather than bind mounted: the daemon resolves a mount source on the 135 # host, where a path made in this container does not exist. 136 log "copying the repository into the conductor" 137 CONDUCTOR_CID=$(compose ps -q conductor) 138 [ -n "${CONDUCTOR_CID}" ] || fail "the conductor container is not running" 139 docker cp "${WORK}/repo" "${CONDUCTOR_CID}:/repo" 140 # docker cp writes as root; git refuses a repository owned by somebody else. 141 docker exec -u 0 "${CONDUCTOR_CID}" chown -R conductor:conductor /repo 142 compose exec -T conductor git -C /repo rev-parse HEAD >/dev/null \ 143 || fail "the copied repository is not readable inside the conductor" 144 145 log "registering the project and a worker" 146 compose exec -T conductor node src/admin-cli.js project:add demo /repo --secret "${SECRET}" >/dev/null 147 SMOKE_TOKEN=$(compose exec -T conductor node src/admin-cli.js token:add smoke-worker | awk '/token:/{print $2}') 148 [ -n "${SMOKE_TOKEN}" ] || fail "no worker token was issued" 149 export SMOKE_TOKEN 150 151 log "starting the worker" 152 compose up -d worker 153 154 log "triggering a job" 155 BODY=$(printf '{"sha":"%s","ref":"refs/heads/main"}' "${SHA}") 156 SIG=$(printf '%s' "${BODY}" | openssl dgst -sha256 -hmac "${SECRET}" | sed 's/^.*[= ]//') 157 RESPONSE=$(curl -fsS -X POST "${BASE_URL}/api/v1/projects/demo/trigger" \ 158 -H 'Content-Type: application/json' \ 159 -H "X-Hub-Signature-256: sha256=${SIG}" \ 160 -d "${BODY}") 161 printf '%s\n' "${RESPONSE}" 162 163 JOB=$(printf '%s' "${RESPONSE}" | sed 's/.*"job_id":"\([^"]*\)".*/\1/') 164 [ -n "${JOB}" ] || fail "no job was created" 165 166 # The job state is the first badge on the job page. A job still being 167 # compiled is not readable yet, so a refusal is just another turn. 168 job_state() { 169 curl -fsS "${BASE_URL}/jobs/$1" 2>/dev/null \ 170 | sed -n 's/.*class="badge \([a-z]*\)".*/\1/p' | head -1 171 } 172 173 # A pipeline that fails to compile is recorded on the job row and nowhere 174 # else, and leaves the job private, which reads here as a 404. 175 job_row() { 176 compose exec -T conductor node --input-type=module -e " 177 import { DatabaseSync } from 'node:sqlite'; 178 const db = new DatabaseSync(process.env.CONDUCTOR_DATABASE_PATH); 179 const row = db.prepare('SELECT state, visibility, error FROM jobs WHERE id = ?').get('$1'); 180 console.log(JSON.stringify(row)); 181 " 2>/dev/null || true 182 } 183 184 log "waiting for the job to finish" 185 STATE_NOW= 186 i=0 187 while [ "${i}" -lt 90 ]; do 188 STATE_NOW=$(job_state "${JOB}") 189 case "${STATE_NOW}" in 190 success|failed|cancelled) break ;; 191 esac 192 # An unreadable job page does not become readable by waiting it out. 193 if [ -z "${STATE_NOW}" ] && [ "${i}" -ge 10 ]; then 194 printf 'the job page stayed unreadable\njob row: %s\n' "$(job_row "${JOB}")" >&2 195 compose logs conductor | tail -20 >&2 196 fail "the job never became readable" 197 fi 198 i=$((i + 1)) 199 sleep 1 200 done 201 202 printf 'job %s finished as %s after %ss\n' "${JOB}" "${STATE_NOW}" "${i}" 203 [ "${STATE_NOW}" = success ] || { 204 printf 'job row: %s\n' "$(job_row "${JOB}")" 205 compose logs worker | tail -30 206 fail "the job ended as ${STATE_NOW}" 207 } 208 209 log "checking the log and the artifact" 210 # Task ids carry no structure, so the build task is found by following the 211 # link from the job page rather than by assembling an id. 212 TASK=$(curl -fsS "${BASE_URL}/jobs/${JOB}" \ 213 | sed -n 's#.*href="/tasks/\([^"]*\)">build<.*#\1#p' | head -1) 214 [ -n "${TASK}" ] || fail "no build task was linked from the job page" 215 216 PAGE=$(curl -fsS "${BASE_URL}/tasks/${TASK}") 217 printf '%s\n' "${PAGE}" | grep -q 'run 1 of demo' || fail "the task environment did not reach the script" 218 219 ARTIFACT_PATH=$(printf '%s' "${PAGE}" \ 220 | sed -n 's#.*href="\(/api/v1/[^"]*/artifacts/[^"]*\)".*#\1#p' | head -1) 221 [ -n "${ARTIFACT_PATH}" ] || fail "no artifact was recorded" 222 223 CONTENT=$(curl -fsSL "${BASE_URL}${ARTIFACT_PATH}") 224 printf 'artifact contents: %s\n' "${CONTENT}" 225 [ "${CONTENT}" = packaged ] || fail "the artifact did not round trip" 226 227 log "reading the task back through the api" 228 # Both paths describe a public task to anyone, with no credential at all. 229 # The absence of the environment is the point of the endpoint, so it is 230 # checked here against the running image rather than trusted from the unit 231 # tests: the pipeline above puts CONDUCTOR_PROJECT in the script, so a 232 # response carrying it would mean the environment had escaped. 233 for URL in "/api/v1/tasks/${TASK}" "/api/v1/projects/demo/tasks/${TASK}"; do 234 BODY=$(curl -fsS "${BASE_URL}${URL}") || fail "${URL} was not readable" 235 236 printf '%s' "${BODY}" | grep -q '"state":"success"' \ 237 || fail "${URL} did not report the task state" 238 printf '%s' "${BODY}" | grep -q '"path":"out/result.txt"' \ 239 || fail "${URL} did not list the artifact" 240 printf '%s' "${BODY}" | grep -q '"url":"http' \ 241 || fail "${URL} did not offer a download url" 242 243 if printf '%s' "${BODY}" | grep -q 'CONDUCTOR_PROJECT'; then 244 fail "${URL} leaked the task environment" 245 fi 246 if printf '%s' "${BODY}" | grep -q '"script"'; then 247 fail "${URL} leaked the task script" 248 fi 249 done 250 251 log "checking the interface" 252 curl -fsS "${BASE_URL}/" | grep -q 'conductor' || fail "the interface did not render" 253 254 # The release task is restricted to main. A push to anything else must not 255 # produce it at all, rather than produce it and skip it, since a skipped 256 # task fails the job. 257 log "checking that a branch push leaves the restricted task out" 258 BRANCH_BODY=$(printf '{"sha":"%s","ref":"refs/heads/feature"}' "${SHA}") 259 BRANCH_SIG=$(printf '%s' "${BRANCH_BODY}" | openssl dgst -sha256 -hmac "${SECRET}" | sed 's/^.*[= ]//') 260 BRANCH=$(curl -fsS -X POST "${BASE_URL}/api/v1/projects/demo/trigger" \ 261 -H 'Content-Type: application/json' \ 262 -H "X-Hub-Signature-256: sha256=${BRANCH_SIG}" \ 263 -d "${BRANCH_BODY}") 264 printf '%s\n' "${BRANCH}" 265 266 BRANCH_JOB=$(printf '%s' "${BRANCH}" | sed 's/.*"job_id":"\([^"]*\)".*/\1/') 267 [ -n "${BRANCH_JOB}" ] || fail "no branch job was created" 268 269 i=0 270 while [ "${i}" -lt 90 ]; do 271 BRANCH_STATE=$(job_state "${BRANCH_JOB}") 272 case "${BRANCH_STATE}" in 273 success|failed|cancelled) break ;; 274 esac 275 i=$((i + 1)) 276 sleep 1 277 done 278 279 # The trigger only records the job, so the tasks are counted off the 280 # finished job rather than the response. 281 BRANCH_PAGE=$(curl -fsS "${BASE_URL}/jobs/${BRANCH_JOB}") 282 BRANCH_TASKS=$(printf '%s' "${BRANCH_PAGE}" | grep -c 'href="/tasks/') 283 [ "${BRANCH_TASKS}" -eq 2 ] \ 284 || fail "expected 2 tasks on a branch, got ${BRANCH_TASKS}" 285 286 printf '%s' "${BRANCH_PAGE}" | grep -q '>release<' \ 287 && fail "the release task should not exist on a branch job" 288 289 printf 'branch job finished as %s with %s tasks and no release task\n' \ 290 "${BRANCH_STATE}" "${BRANCH_TASKS}" 291 [ "${BRANCH_STATE}" = success ] || fail "the branch job ended as ${BRANCH_STATE}" 292 293 printf '\nPASSED\n'