From 6c8289f3f3b511fdcdd0b814fde2944ab3aacca8 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 4 Oct 2026 18:09:29 -0400 Subject: [PATCH 1/2] Pin node images to the checked-out source tree (node-26#19) Nodes checked out v1 code but pulled :latest/:stable images built from main. Each image is now tagged src-: CI adds that tag in every build-*.yml, and scripts/image-tags.sh computes the same value on the node and writes it into .env as DRB_TAG_*, which docker-compose.yml uses (falls back to :latest/:stable for local dev). Tree hashes stay exact even though each image only rebuilds when its own directory changes. - install.sh pins after checkout and fails loudly if a pinned image is missing (compose pull only warns for services with a build: section). - drb-update.sh pulls the target commit's pinned images BEFORE checkout, using the target's own compose file, so a missing image changes nothing (uvicorn --reload would otherwise run new code on old images). It then checks out, writes the pins, recreates, and removes superseded src- tags. - New "Release images" workflow (v* tags + manual dispatch) builds any src- image a release ref is missing; it skips ones already published. The live v1 tag is not moved. Refs #19 Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/build-edge-node.yml | 5 ++ .gitea/workflows/build-icecast.yml | 5 ++ .gitea/workflows/build-op25.yml | 5 ++ .gitea/workflows/build-secondary-sdr.yml | 5 ++ .gitea/workflows/release-images.yml | 87 ++++++++++++++++++++++++ .gitignore | 1 + docker-compose.yml | 12 ++-- install.sh | 32 ++++++--- scripts/drb-update.sh | 66 +++++++++++++++--- scripts/image-tags.sh | 65 ++++++++++++++++++ 10 files changed, 263 insertions(+), 20 deletions(-) create mode 100644 .gitea/workflows/release-images.yml create mode 100644 scripts/image-tags.sh diff --git a/.gitea/workflows/build-edge-node.yml b/.gitea/workflows/build-edge-node.yml index c59b8da..fc89622 100644 --- a/.gitea/workflows/build-edge-node.yml +++ b/.gitea/workflows/build-edge-node.yml @@ -38,6 +38,10 @@ jobs: run: | echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT + # Source pin (node-26#19): the git tree hash of the build context. + # A node pulls src- — scripts/image-tags.sh + # computes the identical value — so its code and image always match. + echo "SRC_TAG=src-$(git rev-parse HEAD:drb-edge-node)" >> $GITHUB_OUTPUT - uses: docker/build-push-action@v6 with: @@ -47,6 +51,7 @@ jobs: push: true tags: | git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }} + git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.SRC_TAG }} git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:latest cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache cache-to: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache,mode=max diff --git a/.gitea/workflows/build-icecast.yml b/.gitea/workflows/build-icecast.yml index b68744d..3d39ac3 100644 --- a/.gitea/workflows/build-icecast.yml +++ b/.gitea/workflows/build-icecast.yml @@ -38,6 +38,10 @@ jobs: run: | echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT + # Source pin (node-26#19): the git tree hash of the build context. + # A node pulls src- — scripts/image-tags.sh + # computes the identical value — so its code and image always match. + echo "SRC_TAG=src-$(git rev-parse HEAD:icecast)" >> $GITHUB_OUTPUT - uses: docker/build-push-action@v6 with: @@ -47,6 +51,7 @@ jobs: push: true tags: | git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }} + git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.SRC_TAG }} git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:latest cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache cache-to: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache,mode=max diff --git a/.gitea/workflows/build-op25.yml b/.gitea/workflows/build-op25.yml index 1e8e768..626b659 100644 --- a/.gitea/workflows/build-op25.yml +++ b/.gitea/workflows/build-op25.yml @@ -38,6 +38,10 @@ jobs: run: | echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT + # Source pin (node-26#19): the git tree hash of the build context. + # A node pulls src- — scripts/image-tags.sh + # computes the identical value — so its code and image always match. + echo "SRC_TAG=src-$(git rev-parse HEAD:op25-container)" >> $GITHUB_OUTPUT - uses: docker/build-push-action@v6 with: @@ -47,6 +51,7 @@ jobs: push: true tags: | git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }} + git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.SRC_TAG }} git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:stable # Registry cache — avoids full recompile of GnuRadio/OP25 when only app code changes cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache diff --git a/.gitea/workflows/build-secondary-sdr.yml b/.gitea/workflows/build-secondary-sdr.yml index 7c2eaa6..1391074 100644 --- a/.gitea/workflows/build-secondary-sdr.yml +++ b/.gitea/workflows/build-secondary-sdr.yml @@ -38,6 +38,10 @@ jobs: run: | echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT + # Source pin (node-26#19): the git tree hash of the build context. + # A node pulls src- — scripts/image-tags.sh + # computes the identical value — so its code and image always match. + echo "SRC_TAG=src-$(git rev-parse HEAD:secondary-sdr-container)" >> $GITHUB_OUTPUT - uses: docker/build-push-action@v6 with: @@ -47,6 +51,7 @@ jobs: push: true tags: | git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }} + git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.SRC_TAG }} git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:latest cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache cache-to: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache,mode=max diff --git a/.gitea/workflows/release-images.yml b/.gitea/workflows/release-images.yml new file mode 100644 index 0000000..755496c --- /dev/null +++ b/.gitea/workflows/release-images.yml @@ -0,0 +1,87 @@ +name: Release images + +# Make sure every node image a release ref needs exists in the registry +# (node-26#19). A node on `v1` pulls :src-. The per-image build workflows only run when their own +# directory changes on main, and anything built before #19 has no src- tag at +# all, so a release tag can point at trees nobody ever built. This fills the +# gaps: for each image, if src- is already in the registry it does +# nothing; otherwise it builds and pushes it (registry cache keeps op25 off a +# full recompile when only its app code changed). +# +# Runs on every v* tag push, including a re-cut `v1`. Also runnable by hand +# (workflow_dispatch) on any ref — run it once on the ref `v1` points at +# before re-cutting `v1` onto a commit that contains #19, so nodes never see +# a moment where their pinned images don't exist yet. +# +# Pushes ONLY src- tags. :latest / :stable keep tracking main, as before. + +on: + workflow_dispatch: + push: + tags: + - "v*" + +jobs: + ensure: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - name: edge-node + context: drb-edge-node + - name: icecast + context: icecast + - name: op25-client + context: op25-container + - name: secondary-sdr + context: secondary-sdr-container + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-qemu-action@v3 + + - uses: docker/setup-buildx-action@v3 + with: + config-inline: | + [registry."git.vpn.cusano.net"] + http = false + insecure = false + + - uses: docker/login-action@v3 + with: + registry: git.vpn.cusano.net + username: ${{ gitea.actor }} + password: ${{ secrets.BUILD_TOKEN }} + + - name: Resolve source tag + id: meta + run: | + REPO_NAME="$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" + # Must match scripts/image-tags.sh and the build-*.yml workflows. + IMAGE="git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${REPO_NAME}/${{ matrix.name }}" + TAG="src-$(git rev-parse HEAD:${{ matrix.context }})" + echo "IMAGE=$IMAGE" >> $GITHUB_OUTPUT + echo "TAG=$TAG" >> $GITHUB_OUTPUT + if docker buildx imagetools inspect "$IMAGE:$TAG" >/dev/null 2>&1; then + echo "EXISTS=true" >> $GITHUB_OUTPUT + echo "$IMAGE:$TAG already published — nothing to do" + else + echo "EXISTS=false" >> $GITHUB_OUTPUT + echo "$IMAGE:$TAG missing — building it" + fi + + - if: steps.meta.outputs.EXISTS != 'true' + uses: docker/build-push-action@v6 + with: + context: ./${{ matrix.context }} + file: ./${{ matrix.context }}/Dockerfile + # arm64 only, same as the build-*.yml workflows (node-26#28). + platforms: linux/arm64 + push: true + tags: ${{ steps.meta.outputs.IMAGE }}:${{ steps.meta.outputs.TAG }} + cache-from: type=registry,ref=${{ steps.meta.outputs.IMAGE }}:buildcache diff --git a/.gitignore b/.gitignore index 6ba38c8..0e07b95 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ venv/ # Runtime-generated configs and recordings configs/ recordings/ +.drb-update-compose.yml # Logs *.log diff --git a/docker-compose.yml b/docker-compose.yml index 3890e0f..d53de78 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,10 @@ +# Image tags: a provisioned node pins every image to the source tree it has +# checked out (DRB_TAG_* = src-, written into .env by install.sh +# and scripts/drb-update.sh via scripts/image-tags.sh — node-26#19). Unset, as +# in local dev, they fall back to :latest / :stable, which CI builds from main. services: icecast: - image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/icecast:latest + image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/icecast:${DRB_TAG_ICECAST:-latest} build: ./icecast restart: unless-stopped network_mode: host @@ -16,7 +20,7 @@ services: # rather than being exposed. edge-node still reaches both over localhost # because it shares this host network namespace. op25: - image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/op25-client:stable + image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/op25-client:${DRB_TAG_OP25:-stable} build: ./op25-container restart: unless-stopped privileged: true @@ -38,7 +42,7 @@ services: # node-26#9. Same network/device access as op25 for the same reason: it # needs the raw USB device, not a virtualized one. secondary-sdr: - image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/secondary-sdr:latest + image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/secondary-sdr:${DRB_TAG_SECONDARY_SDR:-latest} build: ./secondary-sdr-container restart: unless-stopped privileged: true @@ -48,7 +52,7 @@ services: - /dev:/dev edge-node: - image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/edge-node:latest + image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/edge-node:${DRB_TAG_EDGE_NODE:-latest} build: ./drb-edge-node restart: unless-stopped network_mode: host diff --git a/install.sh b/install.sh index c82aafa..d7b7244 100644 --- a/install.sh +++ b/install.sh @@ -10,7 +10,7 @@ # --track-main for the owner's own always-latest nodes) # # Hosting, the pinned ref, and setup.sh's retirement are settled (owner -# decisions, 2026-09-06). One standing hazard remains — D3 below. +# decisions, 2026-09-06). D3 below is mitigated by image pinning (node-26#19). # # What it does, in order: # 1. Preflight (root, arch, apt, SDR present) @@ -48,15 +48,18 @@ # the mint panel / node-26#4). DEFAULT_REF below is `v1`. `--track-main` # stays as an opt-in for the owner's own nodes. # -# STANDING HAZARD +# MITIGATED HAZARD # # D3 SOURCE-OVERLAY. docker-compose.yml bind-mounts ./drb-edge-node/app and -# ./op25-container/app OVER the image's /app. So even in prebuilt mode -# the running Python is the CLONED REF's code against the pulled image's -# dependencies. `v1` == the commit CI built the current :latest/:stable -# from, so today they match — but the moment `v1` and the image tags -# diverge this silently mixes them. Fix: drop those two mounts from the -# prod compose path, or always retag images from the same ref as `v1`. +# ./op25-container/app OVER the image's /app, so the running Python is +# the CLONED REF's code against the pulled image's dependencies. Those +# used to be :latest/:stable, built from main — silently a different +# commit from `v1`. Since node-26#19 every image is pinned to +# src- +# (scripts/image-tags.sh, written into .env as DRB_TAG_*), which CI tags +# identically. The mounts now overlay the same code the image was built +# from. A pinned image that was never built fails the pull loudly; the +# "Release images" workflow builds any a release tag is missing. # --------------------------------------------------------------------------- set -euo pipefail @@ -334,6 +337,14 @@ EOF ok ".env written for '$NODE_ID'" fi +# Pin every image to the source just checked out (node-26#19, D3). Written +# into .env — fresh or preserved — so later manual `docker compose` runs on +# this node use the same pins. +# shellcheck source=scripts/image-tags.sh +. "$INSTALL_DIR/scripts/image-tags.sh" +drb_pin_image_tags "$INSTALL_DIR/.env" "$INSTALL_DIR" "$RESOLVED_SHA" || die "could not write image pins to .env" +ok "images pinned to the source at ${RESOLVED_SHA:0:12} (DRB_TAG_* in .env)" + # ── 5. Enrollment ─────────────────────────────────────────────────────────── # Client half of Server/drb-c2-core/app/routers/enrollment.py. It does NOT # exist in the edge-node app today (mqtt_manager.py:73-85 says so explicitly), @@ -570,6 +581,11 @@ if [ "$DO_START" = 1 ]; then requirement or a transient network error: re-run with DRB_REGISTRY_USER / DRB_REGISTRY_PASS set, or with --build to compile on the Pi (~1h for op25)." fi + # compose pull only WARNS for a missing image that also has a build: + # section, so check the pinned images really arrived. + drb_images_present || die "the images pinned to $REF (${RESOLVED_SHA:0:12}) are not in + $REGISTRY yet. CI may still be building them — wait and re-run; the owner can + run the 'Release images' workflow on that ref; or re-run with --build." docker compose up --no-build -d fi # ALWAYS recreate edge-node (node-26#14): `up -d` leaves an already-running diff --git a/scripts/drb-update.sh b/scripts/drb-update.sh index 02bb58b..2c3587f 100644 --- a/scripts/drb-update.sh +++ b/scripts/drb-update.sh @@ -17,7 +17,9 @@ # * Node credentials go to curl via a 0600 header file, never argv. # * C2 must be https. # What this does NOT defend against: a compromised git host or registry — -# refs and images are not signed (see the issue filed with node-26#15). +# refs and images are not signed (node-26#17). +# * Images are pinned to the checked-out source (node-26#19): each one is +# pulled as src-, never :latest. # # The whole body is inside main(), called on the last line: bash parses the # function completely before running it, so `git checkout` replacing this @@ -146,6 +148,45 @@ do_update() { fi local old new old="$(git rev-parse HEAD)"; new="$(git rev-parse "$target^{commit}")" + + local env_registry + env_registry="$(grep -E '^IMAGE_REGISTRY=' .env | head -1 | cut -d= -f2- | tr -d '"' || true)" + [ "${env_registry:-git.vpn.cusano.net}" = "$REGISTRY" ] \ + || { err ".env IMAGE_REGISTRY '$env_registry' != configured '$REGISTRY' — refusing"; return 1; } + + # Image pins for the TARGET commit (node-26#19): each image is tagged with + # the git tree hash of its own source directory, so the images always match + # the code being checked out. The helper is read from the target commit so + # its service list matches the target's compose file. + local pin=0 + if git cat-file -e "$new:scripts/image-tags.sh" 2>/dev/null; then + # shellcheck disable=SC1090 + source <(git show "$new:scripts/image-tags.sh") && pin=1 + fi + [ "$pin" = 1 ] || log "target ${new:0:12} predates image pinning — using its compose file's default tags" + + if [ "$BUILD" != 1 ]; then + # Pull BEFORE checkout. The app code is bind-mounted from the checkout + # (install.sh D3) and edge-node runs uvicorn --reload, so checking out first + # would run the new code on the old images for as long as the pull takes — + # or for good, if the pinned images don't exist. Pulling against the + # target's own compose file means a failed pull changes nothing. It is + # written next to the real one so its relative paths (.env, env_file, + # build contexts) resolve exactly the same way. + local tfile="$INSTALL_DIR/.drb-update-compose.yml" pulled=0 + git show "$new:docker-compose.yml" > "$tfile" || { rm -f "$tfile"; return 1; } + local -a tc=(-f "$tfile" --project-directory "$INSTALL_DIR") + ( + if [ "$pin" = 1 ]; then + while IFS= read -r l; do export "$l"; done < <(drb_image_tags "$INSTALL_DIR" "$new") + fi + docker compose "${tc[@]}" pull || exit 1 + if [ "$pin" = 1 ]; then drb_images_present "${tc[@]}" || exit 1; fi + ) && pulled=1 + rm -f "$tfile" + [ "$pulled" = 1 ] || { err "images for ${new:0:12} are not available — nothing changed (CI may still be building them; the 'Release images' workflow publishes any that are missing)"; return 1; } + fi + if [ "$old" != "$new" ]; then git -c advice.detachedHead=false checkout --force --detach "$new" || return 1 log "checked out $REF: ${old:0:12} -> ${new:0:12}" @@ -153,19 +194,28 @@ do_update() { bash scripts/install-updater.sh --refresh || log "unit refresh failed (continuing with the old units)" fi - local env_registry - env_registry="$(grep -E '^IMAGE_REGISTRY=' .env | head -1 | cut -d= -f2- | tr -d '"' || true)" - [ "${env_registry:-git.vpn.cusano.net}" = "$REGISTRY" ] \ - || { err ".env IMAGE_REGISTRY '$env_registry' != configured '$REGISTRY' — refusing"; return 1; } + if [ "$pin" = 1 ]; then + drb_pin_image_tags "$INSTALL_DIR/.env" "$INSTALL_DIR" "$new" || { err "could not write image pins to .env"; return 1; } + fi if [ "$BUILD" = 1 ]; then - docker compose build || return 1 - else - docker compose pull || return 1 + docker compose build || return 1 # tags the local builds with the same pins fi # --force-recreate: app code is bind-mounted from the checkout (install.sh # D3), so a code-only change also needs every container restarted. docker compose up -d --no-build --force-recreate --remove-orphans || return 1 + + # Drop pinned images this node no longer runs. `image prune` only removes + # dangling images, and every previous src- tag is still tagged. `image rm` + # without -f refuses anything a container still uses. + if [ "$pin" = 1 ]; then + local keep ref + keep="$(drb_image_tags "$INSTALL_DIR" "$new" | cut -d= -f2)" + docker images --format '{{.Repository}}:{{.Tag}}' | grep -E ':src-[0-9a-f]{40}$' \ + | while IFS= read -r ref; do + grep -qxF "${ref##*:}" <<< "$keep" || docker image rm "$ref" >/dev/null 2>&1 || true + done || true + fi docker image prune -f >/dev/null 2>&1 || true } diff --git a/scripts/image-tags.sh b/scripts/image-tags.sh new file mode 100644 index 0000000..8c4be82 --- /dev/null +++ b/scripts/image-tags.sh @@ -0,0 +1,65 @@ +# shellcheck shell=bash +# +# Pin each node image to the exact source it was built from (node-26#19). +# Sourced by install.sh and scripts/drb-update.sh; not run directly. +# +# The tag is the git TREE hash of the image's build context, `src-`. +# CI computes the same value (`git rev-parse HEAD:` in the build +# workflows), so a node asks for the image built from the very directory it +# has checked out. That is exact even though each image is only rebuilt when +# its own directory changes: an unchanged directory keeps the same tree hash, +# and so the same image, across commits. :latest / :stable still exist for +# local dev; a provisioned node never uses them. +# +# docker-compose.yml reads these as ${DRB_TAG_*:-latest}. The values live in +# .env so every manual `docker compose ...` on the node sees the same pins. + +# VAR:build-context-dir, one per compose service with an image. +DRB_IMAGE_SOURCES="DRB_TAG_ICECAST:icecast DRB_TAG_OP25:op25-container \ +DRB_TAG_SECONDARY_SDR:secondary-sdr-container DRB_TAG_EDGE_NODE:drb-edge-node" + +# drb_image_tags REPO_DIR COMMIT -> "VAR=src-" lines. A directory that +# does not exist at COMMIT is skipped (its compose file won't reference it). +drb_image_tags() { + local _p _var _dir _tree + for _p in $DRB_IMAGE_SOURCES; do + _var="${_p%%:*}"; _dir="${_p#*:}" + _tree="$(git -C "$1" rev-parse --verify -q "$2:$_dir" 2>/dev/null)" || continue + printf '%s=src-%s\n' "$_var" "$_tree" + done +} + +# drb_env_set ENV_FILE KEY VALUE — replace KEY's line in place, or append it. +# Rewrites through the same inode so .env keeps its 0600 mode and owner. +drb_env_set() { + local _tmp; _tmp="$(mktemp)" + awk -v k="$2" -v v="$3" ' + index($0, k "=") == 1 { if (!done) print k "=" v; done = 1; next } + { print } + END { if (!done) print k "=" v }' "$1" > "$_tmp" && cat "$_tmp" > "$1" + local _rc=$?; rm -f "$_tmp"; return $_rc +} + +# drb_pin_image_tags ENV_FILE REPO_DIR COMMIT — write the pins into .env and +# export them, so compose sees them whether or not it re-reads .env. +drb_pin_image_tags() { + local _line + while IFS= read -r _line; do + drb_env_set "$1" "${_line%%=*}" "${_line#*=}" || return 1 + export "$_line" + done < <(drb_image_tags "$2" "$3") +} + +# drb_images_present COMPOSE_ARGS... — 0 if every image the compose project +# names is in the local image store. `compose pull` only WARNS when a service +# that also has a build: section fails to pull, so its exit code alone does +# not prove the pinned images exist. +drb_images_present() { + local _imgs _img _missing=0 + _imgs="$(docker compose "$@" config --images)" || return 1 + while IFS= read -r _img; do + [ -n "$_img" ] || continue + docker image inspect "$_img" >/dev/null 2>&1 || { printf 'missing image: %s\n' "$_img" >&2; _missing=1; } + done <<< "$_imgs" + return $_missing +} -- 2.54.0 From e7b4aef142c6f0ffb2e860d1e911f93de703a305 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 4 Oct 2026 18:17:22 -0400 Subject: [PATCH 2/2] Fix updater tests for image pinning; harden helper load (node-26#19) - test_host_updater: stub git/docker now model cat-file/show/rev-parse --verify and compose config/image inspect. The two update-path tests were failing on Linux CI (skipped locally on macOS, no flock). Adds tests for pull-before-checkout + .env pins, a missing pinned image leaving the node unchanged, and a pre-pinning target still updating. - drb-update.sh: load image-tags.sh via a variable + eval and confirm the functions exist; `source <(git show ...)` "succeeded" with nothing defined when git show failed, wedging every later update. - install.sh: skip pinning (warn) when the checked-out ref predates scripts/image-tags.sh instead of dying on the missing file. Co-Authored-By: Claude Opus 5.5 --- drb-edge-node/tests/test_host_updater.py | 74 +++++++++++++++++++++--- install.sh | 14 +++-- scripts/drb-update.sh | 8 ++- 3 files changed, 82 insertions(+), 14 deletions(-) diff --git a/drb-edge-node/tests/test_host_updater.py b/drb-edge-node/tests/test_host_updater.py index c2006b2..73b92bc 100644 --- a/drb-edge-node/tests/test_host_updater.py +++ b/drb-edge-node/tests/test_host_updater.py @@ -24,21 +24,37 @@ pytestmark = pytest.mark.skipif( ) REPO = "https://git.example.net/logan/node-26.git" -OLD, NEW = "a" * 40, "b" * 40 +OLD, NEW, TREE = "a" * 40, "b" * 40, "c" * 40 +REPO_ROOT = SCRIPT.parents[1] STUB_GIT = r'''#!/usr/bin/env bash echo "git $*" >> "$STUB/calls" -while [ "$1" = -c ]; do shift 2; done +while [ "$1" = -c ] || [ "$1" = -C ]; do shift 2; done case "$1" in - rev-parse) if [ "$2" = HEAD ]; then cat "$STUB/head"; else echo "$STUB_NEW"; fi ;; + rev-parse) + if [ "$2" = HEAD ]; then cat "$STUB/head" + elif [ "$2" = --verify ]; then echo "$STUB_TREE" # tree hash of : + else echo "$STUB_NEW"; fi ;; remote) echo "$STUB_ORIGIN" ;; fetch) exit 0 ;; show-ref) [ "$4" = "refs/tags/v1" ] ;; + cat-file) [ -z "${STUB_NO_PIN:-}" ] ;; # target has scripts/image-tags.sh + show) case "$2" in + *:scripts/image-tags.sh) cat "$STUB_REPO/scripts/image-tags.sh" ;; + *:docker-compose.yml) cat "$STUB_REPO/docker-compose.yml" ;; + esac ;; checkout) echo "${@: -1}" > "$STUB/head" ;; *) exit 0 ;; esac ''' -STUB_DOCKER = '#!/usr/bin/env bash\necho "docker $*" >> "$STUB/calls"\n' +STUB_DOCKER = r'''#!/usr/bin/env bash +echo "docker $*" >> "$STUB/calls" +case "$*" in + *"config --images"*) echo "reg.example/edge-node:$STUB_PULLED_TAG" ;; + "image inspect"*) [ -z "${STUB_MISSING_IMAGE:-}" ] || exit 1 ;; +esac +exit 0 +''' STUB_CURL = r'''#!/usr/bin/env bash echo "curl $*" >> "$STUB/calls" for a in "$@"; do case "$a" in @*) cat "${a#@}" >> "$STUB/headers" ;; esac; done @@ -79,6 +95,7 @@ def env(tmp_path): e.update({ "PATH": f"{bindir}:{os.environ['PATH']}", "STUB": str(stub), "STUB_NEW": NEW, "STUB_ORIGIN": REPO, + "STUB_TREE": TREE, "STUB_REPO": str(REPO_ROOT), "STUB_PULLED_TAG": f"src-{TREE}", "DRB_UPDATER_CONF": str(conf), "DRB_UPDATER_STATE": str(tmp_path / "state"), "DRB_UPDATER_LOCK": str(tmp_path / "lock"), @@ -94,6 +111,10 @@ def _calls(env): return (env["stub"] / "calls").read_text() +def _pulled(calls: str) -> bool: + return any(line.startswith("docker compose ") and line.endswith(" pull") for line in calls.splitlines()) + + def test_no_request_means_checkin_only(env): r = _run(env) assert r.returncode == 0, r.stderr @@ -108,7 +129,7 @@ def test_c2_request_updates_once_and_reports(env): r = _run(env) assert r.returncode == 0, r.stderr + r.stdout calls = _calls(env) - assert "docker compose pull" in calls + assert _pulled(calls) assert "docker compose up -d --no-build --force-recreate --remove-orphans" in calls assert (env["stub"] / "head").read_text().strip() == NEW assert '"applied_request_at":"2026-10-04T12:00:00+00:00"' in calls @@ -126,7 +147,7 @@ def test_mqtt_sentinel_triggers_update_and_is_consumed(env): assert r.returncode == 0, r.stderr assert not sentinel.exists() calls = _calls(env) - assert "docker compose pull" in calls + assert _pulled(calls) assert "evil" not in calls and "rm -rf" not in calls @@ -153,7 +174,7 @@ def test_refuses_when_env_registry_differs(env): (env["inst"] / "configs" / "update-requested").write_text("") r = _run(env) assert r.returncode != 0 - assert "docker compose pull" not in _calls(env) + assert not _pulled(_calls(env)) def test_non_https_repo_in_conf_is_rejected(env): @@ -177,3 +198,42 @@ def test_plain_http_c2_skips_checkin(env): r = _run(env) assert r.returncode == 0 assert "curl" not in _calls(env) + + +# ── node-26#19: images pinned to the target's source tree ───────────────── + +def test_pins_images_and_pulls_before_checkout(env): + (env["inst"] / "configs" / "update-requested").write_text("") + r = _run(env) + assert r.returncode == 0, r.stderr + r.stdout + lines = _calls(env).splitlines() + pull = next(i for i, ln in enumerate(lines) if ln.startswith("docker compose ") and ln.endswith(" pull")) + checkout = next(i for i, ln in enumerate(lines) if ln.startswith("git") and " checkout " in ln) + assert pull < checkout, "images must be pulled before the new code is checked out" + dotenv = (env["inst"] / ".env").read_text() + for var in ("DRB_TAG_EDGE_NODE", "DRB_TAG_OP25", "DRB_TAG_ICECAST", "DRB_TAG_SECONDARY_SDR"): + assert f"{var}=src-{TREE}\n" in dotenv + assert dotenv.startswith("NODE_ID=node-7\n") # existing lines kept + assert not (env["inst"] / ".drb-update-compose.yml").exists() + + +def test_missing_pinned_image_changes_nothing(env): + env["env"]["STUB_MISSING_IMAGE"] = "1" + (env["inst"] / "configs" / "update-requested").write_text("") + r = _run(env) + assert r.returncode != 0 + assert (env["stub"] / "head").read_text().strip() == OLD + calls = _calls(env) + assert " checkout " not in calls + assert "up -d" not in calls + assert "DRB_TAG_" not in (env["inst"] / ".env").read_text() + + +def test_target_without_pinning_still_updates(env): + env["env"]["STUB_NO_PIN"] = "1" + (env["inst"] / "configs" / "update-requested").write_text("") + r = _run(env) + assert r.returncode == 0, r.stderr + r.stdout + assert (env["stub"] / "head").read_text().strip() == NEW + assert _pulled(_calls(env)) + assert "DRB_TAG_" not in (env["inst"] / ".env").read_text() diff --git a/install.sh b/install.sh index d7b7244..3d1f29a 100644 --- a/install.sh +++ b/install.sh @@ -340,10 +340,16 @@ fi # Pin every image to the source just checked out (node-26#19, D3). Written # into .env — fresh or preserved — so later manual `docker compose` runs on # this node use the same pins. -# shellcheck source=scripts/image-tags.sh -. "$INSTALL_DIR/scripts/image-tags.sh" -drb_pin_image_tags "$INSTALL_DIR/.env" "$INSTALL_DIR" "$RESOLVED_SHA" || die "could not write image pins to .env" -ok "images pinned to the source at ${RESOLVED_SHA:0:12} (DRB_TAG_* in .env)" +# A --ref older than #19 has no helper; its compose file hardcodes its tags. +if [ -f "$INSTALL_DIR/scripts/image-tags.sh" ]; then + # shellcheck source=scripts/image-tags.sh + . "$INSTALL_DIR/scripts/image-tags.sh" + drb_pin_image_tags "$INSTALL_DIR/.env" "$INSTALL_DIR" "$RESOLVED_SHA" || die "could not write image pins to .env" + ok "images pinned to the source at ${RESOLVED_SHA:0:12} (DRB_TAG_* in .env)" +else + warn "$REF predates image pinning (node-26#19) — using its compose file's default image tags" + drb_images_present() { return 0; } +fi # ── 5. Enrollment ─────────────────────────────────────────────────────────── # Client half of Server/drb-c2-core/app/routers/enrollment.py. It does NOT diff --git a/scripts/drb-update.sh b/scripts/drb-update.sh index 2c3587f..6237339 100644 --- a/scripts/drb-update.sh +++ b/scripts/drb-update.sh @@ -158,10 +158,12 @@ do_update() { # the git tree hash of its own source directory, so the images always match # the code being checked out. The helper is read from the target commit so # its service list matches the target's compose file. - local pin=0 + # Read into a variable, not `source <(...)`: a failed `git show` must leave + # pin=0 rather than "succeed" with no functions defined. + local pin=0 helper if git cat-file -e "$new:scripts/image-tags.sh" 2>/dev/null; then - # shellcheck disable=SC1090 - source <(git show "$new:scripts/image-tags.sh") && pin=1 + helper="$(git show "$new:scripts/image-tags.sh")" && [ -n "$helper" ] && eval "$helper" \ + && declare -F drb_image_tags drb_images_present drb_pin_image_tags >/dev/null && pin=1 fi [ "$pin" = 1 ] || log "target ${new:0:12} predates image pinning — using its compose file's default tags" -- 2.54.0