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-<git tree hash of its build directory>: 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
80a0e88fc4
commit
6c8289f3f3
+58
-8
@@ -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-<git tree hash of its build directory>, 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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-<tree>`.
|
||||
# CI computes the same value (`git rev-parse HEAD:<dir>` 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-<tree>" 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
|
||||
}
|
||||
Reference in New Issue
Block a user