Ctrl K
ComunidadGuías…

justfile

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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 </tmp/cairn-sweep-old)" = "$(shasum -a 256 </tmp/cairn-sweep-new)" ]; then
      echo "the two builds are the same bytes: {{ tag }} is what is checked out" >&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 '<loc>[^<]*</loc>' | sed "s#<loc>http://127.0.0.1:$pn##;s#</loc>##")
      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 </tmp/sweep-old); b=$(blur </tmp/sweep-new)
        if [ -z "$a" ] || [ -z "$b" ]; then echo "the filter produced nothing for $u" >&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