conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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'