# cairn dev tasks — https://just.systems set quiet [private] default: just --list # build the production image build: docker build -f docker/Dockerfile -t cairn:local . # lint the Helm chart and render the shapes CI renders chart: helm lint charts/cairn helm template cairn charts/cairn >/dev/null helm template cairn charts/cairn --set ingress.enabled=true --set ingress.host=cairn.example.org --set ingress.tls.enabled=true >/dev/null helm template cairn charts/cairn --set 'imagePullSecrets[0].name=harbor' >/dev/null grep -qF 'kubeVersion: ">=1.19.0-0"' charts/cairn/Chart.yaml || { echo "kubeVersion is the Kubernetes floor, not cairn's version: it does not move with a release"; exit 1; } echo "chart ok" # run vet + tests test: go vet ./... && go test ./... # measure coverage (add `-html=/tmp/cov.out` to the last line for a report) # -coverpkg counts a package's code however it is reached: without it, each # package only credits its own tests and the figure reads far too low. coverage: go test -covermode=atomic -coverpkg=./src/... -coverprofile=/tmp/cov.out ./src/... >/dev/null go tool cover -func=/tmp/cov.out | tail -1 # run the linter and the format check lint: golangci-lint run golangci-lint fmt --diff # check which checks a pull request's files turn on changes-test: bash .github/changes_test.sh # install the git pre-commit hook hooks: git config core.hooksPath githooks echo "pre-commit hook active (bypass: git commit --no-verify)" # start the demo stack (cairn + gatus + sample services) demo: docker compose -f demo/compose.yaml up -d echo "cairn → http://localhost:8080 gatus → http://localhost:8081" # rebuild the image and recreate the demo demo-rebuild: docker compose -f demo/compose.yaml up -d --build --remove-orphans # save a release to dist/ for an air-gapped move: just save 1.27.1 [linux/amd64] save version platform="": #!/usr/bin/env bash set -euo pipefail # Checked before anything is fetched. A missing cosign fails the verify # below exactly the way a bad signature does, and it used to fail there, # after the pulls: the recipe printed "nothing here is trustworthy" about a # release that was fine, and left unverified files in dist/, the directory # that crosses the gap. command -v cosign >/dev/null || { echo "cosign is not installed, and without it this recipe cannot check a signature: https://docs.sigstore.dev/cosign/system_config/installation/" >&2; exit 1; } # docker save exports the platform you pulled and nothing else, so this is # an argument rather than an assumption: preparing an amd64 cluster from # an arm64 laptop is the classic way to learn that at CrashLoopBackOff # instead of here. Left out, it is this machine's, spelled the way the # rest of the project spells it: uname says aarch64, images say arm64. plat="{{ platform }}" if [ -z "$plat" ]; then case "$(uname -m)" in arm64|aarch64) plat=linux/arm64 ;; x86_64|amd64) plat=linux/amd64 ;; *) plat="linux/$(uname -m)" ;; esac fi mkdir -p dist docker pull --platform "$plat" morgankryze/cairn:{{ version }} # --platform on the save too, and it is load-bearing rather than belt and # braces. Under the containerd image store a tag keeps its whole index, so # saving by name exports every platform present and `docker load` then # materialises the host's: asking for amd64 on an arm64 laptop produced a # tar that loaded as arm64, silently, which is the CrashLoopBackOff this # recipe exists to prevent. Measured before it was written this way. docker save --platform "$plat" morgankryze/cairn:{{ version }} \ -o "dist/cairn-{{ version }}-${plat//\//-}.tar" # The packaged chart rather than charts/ from git: it is the artifact the # release signed, and the only one whose version is not whatever the # working tree happens to say. helm pull oci://ghcr.io/morgankryze/charts/cairn --version {{ version }} --destination dist # Verified here on purpose, and this is the whole reason the recipe exists # rather than two docker commands: cosign queries the public transparency # log, so it is not something you get to do on the other side of the gap. # Both write their summary to stderr, hence 2>&1 and not just >/dev/null. for ref in morgankryze/cairn ghcr.io/morgankryze/charts/cairn; do cosign verify "$ref:{{ version }}" \ --certificate-identity-regexp '^https://github.com/MorganKryze/cairn/' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com >/dev/null 2>&1 \ || { echo "cosign could not verify $ref:{{ version }}; nothing here is trustworthy" >&2; exit 1; } done ls -lh dist/ | tail -n +2 | awk '{print " " $9 " " $5}' echo "signatures verified for $plat. Moving it across: docs/deployment/airgap.md" # compare every response against a released tag, before cutting a release: # just sweep v1.22.0 sweep tag: #!/usr/bin/env bash set -uo pipefail # Both binaries carry the same stamped version and both read the tag's own # config tree, so neither the version nor a fixture edited since can be the # difference: what is measured is the binary. Against main rather than the # tag would compare the tree with itself. # A leftover server from an earlier sweep answers the readiness loop # instantly and the run then measures that build instead. It cost one # phantom difference before this check existed; test-browser guards its own # ports for the same reason. for port in $(seq 8190 8195) $(seq 8290 8295); do if curl -fsS -o /dev/null "http://127.0.0.1:$port/healthz" 2>/dev/null; then echo "something already answers on 127.0.0.1:$port; kill it or this sweep measures it" >&2 exit 1 fi done wt=$(mktemp -d) trap 'git worktree remove --force "$wt" 2>/dev/null || true; kill $(jobs -p) 2>/dev/null || true' EXIT git worktree prune git worktree add -q --detach "$wt" {{ tag }} || exit 1 go build -C "$wt" -trimpath -buildvcs=false -ldflags="-s -w -X main.version=sweep" -o /tmp/cairn-sweep-old ./src/cmd/cairn || exit 1 go build -trimpath -buildvcs=false -ldflags="-s -w -X main.version=sweep" -o /tmp/cairn-sweep-new ./src/cmd/cairn || exit 1 # -buildvcs=false on both, or the guard below never speaks: go stamps the # commit and a dirty flag into the binary, so a working tree that is the tag # with one file edited still produces different bytes. if [ "$(shasum -a 256 &2; exit 1 fi # The two servers answer on two ports and cairn writes its own origin into # sitemap.xml and robots.txt, so the port is normalised. LC_ALL=C or the # filter dies on the binary assets, and a filter that fails on both sides # reports equality. Asset names carry a digest of their contents: a changed # script is expected to move its own URL, and that is the one difference # read rather than counted. blur() { LC_ALL=C sed -E "s/127\.0\.0\.1:[0-9]+/HOST/g; s/\.[0-9a-f]{8}\.(css|js)/.DIGEST.\1/g"; } configs=(example scripts/fixtures/many-categories scripts/fixtures/status scripts/fixtures/themed scripts/fixtures/leave scripts/fixtures/states) total=0; same=0; left=0; oneside=0 for i in "${!configs[@]}"; do cfg="${configs[$i]}"; po=$((8190+i)); pn=$((8290+i)) /tmp/cairn-sweep-old -config "$wt/$cfg" -addr "127.0.0.1:$po" >/dev/null 2>&1 & o=$! /tmp/cairn-sweep-new -config "$wt/$cfg" -addr "127.0.0.1:$pn" >/dev/null 2>&1 & n=$! for p in $po $pn; do for _ in $(seq 1 30); do curl -fsS -o /dev/null "http://127.0.0.1:$p/healthz" 2>/dev/null && break; sleep .3; done; done mapfile -t pages < <(curl -fsS "http://127.0.0.1:$pn/sitemap.xml" | grep -o '[^<]*' | sed "s#http://127.0.0.1:$pn##;s###") mapfile -t assets < <(for u in "${pages[@]}"; do curl -fsS "http://127.0.0.1:$pn$u"; done | grep -ao '/static/[A-Za-z0-9._/-]*' | sort -u) for u in "${pages[@]}" "${assets[@]}" /healthz /readyz /robots.txt /sitemap.xml /manifest.webmanifest /favicon.ico /.well-known/security.txt /custom.css /; do total=$((total+1)) ao=$(curl -fsS "http://127.0.0.1:$po$u" -o /tmp/sweep-old 2>/dev/null; echo $?) an=$(curl -fsS "http://127.0.0.1:$pn$u" -o /tmp/sweep-new 2>/dev/null; echo $?) if [ "$ao" != "$an" ]; then oneside=$((oneside+1)); echo " only one side answers: $cfg $u ($ao vs $an)"; continue; fi if [ "$ao" != 0 ]; then same=$((same+1)); continue; fi if cmp -s /tmp/sweep-old /tmp/sweep-new; then same=$((same+1)); continue; fi a=$(blur &2; exit 1; fi if [ "$a" = "$b" ]; then same=$((same+1)); else left=$((left+1)); echo "=== $cfg $u"; diff <(echo "$a") <(echo "$b") | head -14 fi done # The control, once: two pages that differ, through the same filter. A # silent one here would mean the zero below is a broken instrument. if [ "$i" = 0 ]; then c=$(diff <(curl -fsS "http://127.0.0.1:$pn/en/" | blur) <(curl -fsS "http://127.0.0.1:$pn/en/pad/" | blur) | wc -l) [ "$c" -gt 10 ] || { echo "the control found $c differing lines between two different pages" >&2; exit 1; } echo " control: $c diff lines between two different pages" fi kill $o $n 2>/dev/null || true; wait $o $n 2>/dev/null || true done echo echo "compared $total responses against {{ tag }}: $same identical once asset digests are blurred, $left different, $oneside on one side only" [ "$left" = 0 ] || exit 1 # check what a release actually published, from the registries: just verify 1.27.1 verify version: #!/usr/bin/env bash set -uo pipefail # Read the registries rather than the workflow's log. A job can report # success and still leave a tag behind: 1.20.1 published without moving # stable and latest, and only a query said so. fail=0 echo "=== one object under every tag ===" seen="" for ref in ghcr.io/morgankryze/cairn morgankryze/cairn; do for t in {{ version }} "$(echo {{ version }} | cut -d. -f1-2)" "$(echo {{ version }} | cut -d. -f1)" stable latest; do d=$(docker buildx imagetools inspect "$ref:$t" --format '{{{{.Manifest.Digest}}}}' 2>/dev/null || echo MISSING) printf ' %-32s %-8s %s\n' "$ref" "$t" "$d" [ "$d" = MISSING ] && fail=1 case " $seen " in *" $d "*) ;; *) seen="$seen $d" ;; esac done done [ "$(echo $seen | wc -w)" -eq 1 ] || { echo " not one digest:$seen"; fail=1; } # cosign from its own image: a missing binary and a bad signature exit the # same way, and one release was called unsigned because it was not installed. echo "=== signatures, with a wrong identity as the control ===" cosign="docker run --rm ghcr.io/sigstore/cosign/cosign:latest" for ref in "morgankryze/cairn:{{ version }}" "ghcr.io/morgankryze/cairn:{{ version }}" "ghcr.io/morgankryze/charts/cairn:{{ version }}"; do if $cosign verify "$ref" --certificate-identity-regexp '^https://github.com/MorganKryze/cairn/' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com >/dev/null 2>&1; then echo " signed $ref"; else echo " UNSIGNED $ref"; fail=1; fi if $cosign verify "$ref" --certificate-identity-regexp '^https://github.com/NotMorganKryze/' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com >/dev/null 2>&1; then echo " the control passed for $ref: this check accepts anything"; fail=1 fi done echo "=== chart ===" helm show chart oci://ghcr.io/morgankryze/charts/cairn --version {{ version }} 2>/dev/null \ | grep -E '^(version|appVersion|kubeVersion):' || { echo " no chart {{ version }} published"; fail=1; } echo "=== binaries, and the compiler that built them ===" tmp=$(mktemp -d) gh release download "v{{ version }}" -D "$tmp" -p 'cairn_*' -p 'checksums.txt' 2>/dev/null || { echo " no assets on the release"; fail=1; } [ -f "$tmp/checksums.txt" ] && { ( cd "$tmp" && shasum -a 256 -c checksums.txt ) || fail=1; } # The tarballs are built inside the image's own Go, and this is where that # promise is read back off the artifact instead of trusted. goimg=$(awk '$1 == "FROM" && $NF == "build" {print $3}' docker/Dockerfile) want=$(docker run --rm "$goimg" go env GOVERSION) for f in "$tmp"/cairn_*linux_amd64.tar.gz; do tar -xzf "$f" -C "$tmp" got=$(docker run --rm -v "$tmp:/t:ro" "$goimg" go version /t/cairn | awk '{print $2}') printf ' tarball built by %s, the pinned image says %s\n' "$got" "$want" [ "$got" = "$want" ] || { echo " they disagree"; fail=1; } done rm -rf "$tmp" echo "=== pull size, which README.md states ===" index=$(docker buildx imagetools inspect "morgankryze/cairn:{{ version }}" --raw 2>/dev/null) if [ -z "$index" ]; then echo " no image to weigh" else for arch in amd64 arm64; do d=$(echo "$index" | python3 -c "import json,sys; i=json.load(sys.stdin); print(next((m['digest'] for m in i['manifests'] if m.get('platform',{}).get('architecture')=='$arch' and m['platform']['os']=='linux'), ''))") [ -n "$d" ] || { echo " $arch not in the index"; continue; } docker buildx imagetools inspect "morgankryze/cairn@$d" --raw | python3 -c "import json,sys; m=json.load(sys.stdin); print(' $arch', round((sum(l['size'] for l in m['layers'])+m['config']['size'])/1e6,1), 'MB')" done fi echo if [ "$fail" = 0 ]; then echo "all verified"; else echo "something is wrong"; exit 1; fi # stop everything down: docker compose -f demo/compose.yaml down # follow the demo logs logs: docker compose -f demo/compose.yaml logs -f # refresh the README hero from the running demo; do this before every release # (playwright lands in a gitignored node_modules, nothing is committed). # The loop waits for Gatus to have polled, the way the CI demo job does. A fixed # delay used to be enough until it wasn't: it caught the page with every pill on # "unknown", which writes both files, fails nothing, and only shows up if # someone opens the png. shots: demo-rebuild @for i in $(seq 1 24); do p=$(curl -fs http://127.0.0.1:8080/en/ || true); echo "$p" | grep -q status-up && echo "$p" | grep -q status-down && break; [ "$i" = 24 ] && { echo "gatus never settled: every pill would read unknown" >&2; exit 1; }; sleep 5; done @npm --silent install --no-save --no-package-lock playwright @npx --yes playwright install --only-shell chromium @node scripts/screenshots.mjs # The short looping clip, for the places a still picture does not answer "what # is this page for". Same Gatus wait as `shots`, for the same reason: a clip # recorded before the first poll shows a wall of "unknown" pills. clip: demo-rebuild @for i in $(seq 1 24); do p=$(curl -fs http://127.0.0.1:8080/en/ || true); echo "$p" | grep -q status-up && echo "$p" | grep -q status-down && break; [ "$i" = 24 ] && { echo "gatus never settled: every pill would read unknown" >&2; exit 1; }; sleep 5; done @npm --silent install --no-save --no-package-lock playwright @npx --yes playwright install --only-shell chromium @command -v ffmpeg >/dev/null || { echo "ffmpeg not found: brew install ffmpeg" >&2; exit 1; } @node scripts/demo-video.mjs # drive search and the accessibility behaviours in a real browser: what the Go # tests cannot see, since they assert the markup that ships and not what # happens once someone types, clicks or scrolls test-browser: #!/usr/bin/env bash # One shell for the whole recipe, and a trap. just runs each line of an # ordinary recipe in a shell of its own, so the pid was written on one line # and killed on another: aborting in between left cairn bound to 8090 for # good. The leak was the smaller half. On the next run the leftover # answered the readiness loop instantly, and the browser then drove the # build from before the change under test, green. Two instances now, so # both halves of that apply to every port: a stale server on any one of # them is enough to make the whole run measure yesterday's build. set -euo pipefail for port in 8090 8091 8092 8093 8094 8095 8100 8101 8102; do if curl -fsS -o /dev/null "http://127.0.0.1:$port/healthz" 2>/dev/null; then echo "test-browser: something already answers on 127.0.0.1:$port, probably" >&2 echo "a leaked cairn. Kill it, or this run measures that one instead." >&2 exit 1 fi done npm --silent install --no-save --no-package-lock playwright npx --yes playwright install --only-shell chromium go build -o /tmp/cairn-browser ./src/cmd/cairn # 8090 is the example config; 8091 is the fixture with nine categories, # enough for the trail to overflow its row, and a header burger, neither of # which example/ has; 8092 is # the one with a status page, which example/ has no business carrying; # 8093 is the one whose logo and icons differ between the two themes; # 8094 is the one that guards its external links with a dialog; # 8095 is the one carrying a card per state; # 8100 is the demo's own config, which layout.mjs measures the phone page # against; 8101 is the site name too long for one header row; 8102 is # every built-in language plus a right-to-left one. The last three take # their logo from demo/assets. /tmp/cairn-browser -config example -addr 127.0.0.1:8090 & pids=$! /tmp/cairn-browser -config scripts/fixtures/many-categories -addr 127.0.0.1:8091 & pids="$pids $!" /tmp/cairn-browser -config scripts/fixtures/status -addr 127.0.0.1:8092 & pids="$pids $!" /tmp/cairn-browser -config scripts/fixtures/themed -addr 127.0.0.1:8093 & pids="$pids $!" /tmp/cairn-browser -config scripts/fixtures/leave -addr 127.0.0.1:8094 & pids="$pids $!" /tmp/cairn-browser -config scripts/fixtures/states -addr 127.0.0.1:8095 & pids="$pids $!" /tmp/cairn-browser -config demo/config -assets demo/assets -addr 127.0.0.1:8100 & pids="$pids $!" /tmp/cairn-browser -config scripts/fixtures/longname -assets demo/assets -addr 127.0.0.1:8101 & pids="$pids $!" /tmp/cairn-browser -config scripts/fixtures/locales -assets demo/assets -addr 127.0.0.1:8102 & pids="$pids $!" trap 'kill $pids 2>/dev/null || true' EXIT INT TERM for port in 8090 8091 8092 8093 8094 8095 8100 8101 8102; do ready= for _ in $(seq 1 20); do if curl -fsS -o /dev/null "http://127.0.0.1:$port/healthz" 2>/dev/null; then ready=1; break; fi sleep 1 done [ -n "$ready" ] || { echo "test-browser: cairn never came up on $port" >&2; exit 1; } done node scripts/search.mjs http://127.0.0.1:8090/en/ http://127.0.0.1:8091/en/ node scripts/a11y.mjs http://127.0.0.1:8090/en/ http://127.0.0.1:8091/en/ http://127.0.0.1:8092/en/ http://127.0.0.1:8093/en/ http://127.0.0.1:8094/en/ http://127.0.0.1:8095/en/ node scripts/status.mjs http://127.0.0.1:8092/en/ node scripts/layout.mjs http://127.0.0.1:8100/ http://127.0.0.1:8101/ http://127.0.0.1:8102/ http://127.0.0.1:8090/ http://127.0.0.1:8095/ # The export is files and no server: the script serves them itself, on a # port the system picks, so there is no leftover for the check above to # find. mktemp gives the empty directory -export insists on. static=$(mktemp -d) trap 'kill $pids 2>/dev/null || true; rm -rf "$static"' EXIT INT TERM /tmp/cairn-browser -config scripts/fixtures/static -base-path /tools -export "$static" node scripts/export.mjs "$static" # regenerate every icon from the one drawing in the script; checks the # maskable safe zone the manifest promises Android icons: @npm --silent install --no-save --no-package-lock playwright @npx --yes playwright install --only-shell chromium @node scripts/icons.mjs