Files
ergosteurandClaude Opus 5 ab0b4d7d0f Make update-image-pins.sh shellcheck-clean
shellcheck flagged three things, one of which was worth acting on beyond
silencing it.

echo was emitting a script fragment containing a literal \n meant for the
generated code, not for this shell. Bash's echo leaves it alone by default,
but the handling of backslashes is implementation-defined and xpg_echo would
have expanded it and corrupted the fragment. printf '%s\n' instead.

The discover_* functions were dispatched as "discover_$distro", which reads as
dead code to shellcheck and to anyone grepping for a caller. An explicit case
dispatch replaces it, which is what SC2317 was really complaining about.

The two remaining SC2016 hits are intentional single quotes, one a sed address
matching a literal $DISTRO_CHOICE and one emitting code, and are annotated
rather than fixed.

Also reject an unknown --distro. It previously filtered every distro out and
reported "all pins are already current" with exit 0, so a typo looked like a
clean run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 16:10:51 +00:00

343 lines
13 KiB
Bash
Executable File

#!/bin/bash
# Refresh the cloud image version pins in build-cloudinit-template.sh.
#
# Usage:
# ./update-image-pins.sh [--write] [--distro <name>] [--script <path>]
#
# Queries each distribution's upstream for its current stable release, builds
# the image URL exactly as the template script would, and only accepts a new
# pin once that URL has been confirmed to serve a real qcow2. Reports what
# would change and exits without touching anything unless --write is given.
#
# Options:
# --write Apply the changes (default is a dry run)
# --distro <name> Check only one distro, repeatable
# --script <path> Template script to update
# (default: build-cloudinit-template.sh beside this one)
#
# Exit status is 0 when everything is already current or was updated cleanly,
# 1 on a usage error, 2 when a distro could not be checked, and 3 when pins are
# out of date during a dry run, so CI can use it as a staleness check.
#
# Arch is rolling and Debian's URL already tracks "latest" within a release, so
# neither needs a lookup here beyond confirming the image still resolves.
set -euo pipefail
# Print the header comment block: everything from line 2 up to the first blank line.
usage() {
sed -n '2,/^$/p' "$0" | sed -e 's/^#//' -e 's/^ //'
}
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
TARGET="$SCRIPT_DIR/build-cloudinit-template.sh"
WRITE=false
ONLY=()
# Arch is absent deliberately: it is rolling and has no pin to check.
DISTROS=(alpine ubuntu debian fedora almalinux)
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help) usage; exit 0 ;;
--write) WRITE=true; shift ;;
--distro) [[ -n "${2:-}" ]] || { echo "Error: --distro needs a value." >&2; exit 1; }
ONLY+=("$2"); shift 2 ;;
--script) [[ -n "${2:-}" ]] || { echo "Error: --script needs a value." >&2; exit 1; }
TARGET="$2"; shift 2 ;;
*) echo "Error: unknown option '$1'." >&2; usage >&2; exit 1 ;;
esac
done
for bin in curl sed grep sort; do
command -v "$bin" >/dev/null || { echo "Required command '$bin' not found." >&2; exit 1; }
done
# Reject a misspelled --distro rather than filtering everything out and
# reporting "all pins are current", which reads as success.
for d in ${ONLY[@]+"${ONLY[@]}"}; do
[[ " ${DISTROS[*]} " == *" $d "* ]] && continue
echo "Error: unknown distro '$d'." >&2
echo "Choose one of: ${DISTROS[*]} (arch is rolling and has no pin)" >&2
exit 1
done
[[ -f "$TARGET" ]] || { echo "Error: template script not found at $TARGET" >&2; exit 1; }
[[ -w "$TARGET" ]] || [[ "$WRITE" == false ]] || { echo "Error: $TARGET is not writable." >&2; exit 1; }
wanted() {
[[ ${#ONLY[@]} -eq 0 ]] && return 0
local d
for d in "${ONLY[@]}"; do [[ "$d" == "$1" ]] && return 0; done
return 1
}
# Retry rather than fail on a single flaky mirror; a transient 503 should not
# look like "this release no longer exists".
fetch() {
curl -sSL --max-time 30 --retry 3 --retry-delay 2 --retry-connrefused "$1" 2>/dev/null
}
# A mirror that has lost an image often serves an HTML error page with a 200,
# so check the qcow2 magic ("QFI\xfb") rather than trusting the status code.
image_ok() {
local url=$1 code magic
code=$(curl -sIL --max-time 30 --retry 2 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo 000)
[[ "$code" == 200 ]] || return 1
magic=$(curl -sL --max-time 30 --retry 2 -r 0-3 "$url" 2>/dev/null | head -c 4 | od -An -tx1 | tr -d ' \n')
[[ "$magic" == "514649fb" ]]
}
# Read a pin's current value out of the template script.
current_pin() {
sed -nE "s/^$1=\"([^\"]*)\".*/\1/p" "$TARGET" | head -1
}
# Derive the image URLs from the template script itself rather than repeating
# its URL templates here. Keeping one copy means this script cannot silently
# drift from what the builder actually downloads.
image_urls_from() {
local script=$1 pins cases out
pins=$(sed -n '/^# --- Distribution version pins/,/^# Arch is rolling/p' "$script")
# shellcheck disable=SC2016 # a literal $DISTRO_CHOICE in the sed address
cases=$(sed -n '/^case \$DISTRO_CHOICE in$/,/^esac$/p' "$script")
# Both anchors must have matched something recognisable. Without this an
# unmatched anchor yields an empty fragment that still runs, producing URLs
# built from unset pins rather than an obvious failure.
grep -qE '^[A-Z_]+="' <<<"$pins" || return 1
grep -q '^esac$' <<<"$cases" || return 1
out=$(
{
# set -u so a pin the case block references but the extracted block
# does not define aborts here, instead of interpolating as empty.
echo 'set -u'
printf '%s\n' "$pins"
echo 'for DISTRO_CHOICE in alpine ubuntu debian fedora almalinux arch; do'
printf '%s\n' "$cases"
# printf, not echo: the \n belongs to the emitted fragment, and
# echo's handling of backslashes is implementation-defined.
# shellcheck disable=SC2016 # emitting code, expansion happens there
printf '%s\n' 'printf "%s %s\n" "$DISTRO_CHOICE" "$IMAGE_URL"; done'
} | bash 2>/dev/null
) || true
# Six distros in, six URLs out; anything less means the template no longer
# has the shape this script reads.
[[ $(grep -c . <<<"$out") -eq 6 ]] || return 1
printf '%s\n' "$out"
}
url_for() { grep "^$1 " <<<"$URLS" | cut -d' ' -f2-; }
# --- Upstream lookups -------------------------------------------------------
# Each prints "PIN_NAME=value" lines, or nothing when the lookup fails.
# meta-release is what do-release-upgrade reads, so it is the authoritative
# statement of which LTS is current and still supported.
discover_ubuntu() {
local block codename version major minor
block=$(fetch https://changelogs.ubuntu.com/meta-release) || return 1
read -r codename version < <(
awk '/^Dist:/{d=$2} /^Version:/{v=$2; lts=($3=="LTS")} /^Supported:/{if ($2==1 && lts) print d, v}' \
<<<"$block" | sort -k2 -V | tail -1
)
[[ -n "$codename" && -n "$version" ]] || return 1
# Version is "26.04" but "24.04.4" once point releases start, and the pin
# wants just the release, so keep the first two components.
IFS=. read -r major minor _ <<<"$version"
[[ -n "$major" && -n "$minor" ]] || return 1
printf 'UBUNTU_CODENAME=%s\nUBUNTU_VER=%s\n' "$codename" "$major$minor"
}
# The stable suite's own Release file, so this follows Debian's definition of
# stable and cannot be fooled by a testing directory appearing on the mirror.
discover_debian() {
local release codename version
release=$(fetch https://deb.debian.org/debian/dists/stable/Release) || return 1
codename=$(sed -nE 's/^Codename: (.*)/\1/p' <<<"$release" | head -1)
version=$(sed -nE 's/^Version: ([0-9]+).*/\1/p' <<<"$release" | head -1)
[[ -n "$codename" && -n "$version" ]] || return 1
printf 'DEBIAN_CODENAME=%s\nDEBIAN_VER=%s\n' "$codename" "$version"
}
# Fedora bakes an exact compose number into the filename, so the release has to
# be read off the mirror. Walk down from the newest: a branched release appears
# under releases/ shortly before its cloud images land, and the highest
# numbered directory is briefly not yet installable.
discover_fedora() {
local base=https://dl.fedoraproject.org/pub/fedora/linux/releases majors ver img rel
majors=$(fetch "$base/") || return 1
majors=$(grep -oE 'href="[0-9]+/"' <<<"$majors" | tr -dc '0-9\n' | sort -un | tail -3 | tac)
[[ -n "$majors" ]] || return 1
for ver in $majors; do
img=$(fetch "$base/$ver/Cloud/x86_64/images/") || continue
rel=$(grep -oE "Fedora-Cloud-Base-Generic-$ver-[0-9]+\.[0-9]+\.x86_64\.qcow2" <<<"$img" |
sed -E "s/.*-$ver-([0-9.]+)\.x86_64\.qcow2/\1/" | sort -uV | tail -1)
[[ -n "$rel" ]] || continue
printf 'FEDORA_VER=%s\nFEDORA_RELEASE=%s\n' "$ver" "$rel"
return 0
done
return 1
}
# Only the major version is pinned; the URL's "-latest" suffix tracks minors.
# Integer directories only, so 10 is considered but 10.2 is not.
discover_almalinux() {
local dirs ver
dirs=$(fetch https://repo.almalinux.org/almalinux/) || return 1
ver=$(grep -oE 'href="[0-9]+/"' <<<"$dirs" | tr -dc '0-9\n' | sort -un | tail -1)
[[ -n "$ver" ]] || return 1
printf 'ALMA_VER=%s\n' "$ver"
}
# latest-stable is a symlink to the current branch, so the filename there gives
# the patch level and the prefix at once. Reading the prefix rather than
# assuming it is what lets the nocloud_ -> generic_ rename be picked up.
discover_alpine() {
local listing file prefix patch
listing=$(fetch https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/cloud/) || return 1
file=$(grep -oE '[a-z]+_alpine-[0-9]+\.[0-9]+\.[0-9]+-x86_64-uefi-cloudinit-r0\.qcow2' <<<"$listing" |
sort -uV | tail -1)
[[ -n "$file" ]] || return 1
prefix=${file%%_alpine-*}
patch=$(sed -E 's/.*_alpine-([0-9]+\.[0-9]+\.[0-9]+)-.*/\1/' <<<"$file")
[[ -n "$prefix" && -n "$patch" ]] || return 1
# The branch directory is the first two components of the patch version.
printf 'ALPINE_MAJOR=v%s\nALPINE_PATCH=%s\nALPINE_PREFIX=%s\n' "${patch%.*}" "$patch" "$prefix"
}
# Dispatch explicitly rather than calling "discover_$distro". The indirect form
# reads as dead code to both shellcheck and to anyone grepping for a caller.
discover() {
case $1 in
alpine) discover_alpine ;;
ubuntu) discover_ubuntu ;;
debian) discover_debian ;;
fedora) discover_fedora ;;
almalinux) discover_almalinux ;;
*) return 1 ;;
esac
}
# --- Collect proposals ------------------------------------------------------
declare -A PROPOSED=()
CHANGED=()
FAILED=()
echo "Checking upstream for current stable releases..."
for distro in "${DISTROS[@]}"; do
wanted "$distro" || continue
if ! result=$(discover "$distro"); then
echo " $distro: lookup FAILED" >&2
FAILED+=("$distro")
continue
fi
distro_changed=false
while IFS='=' read -r key value; do
[[ -n "$key" ]] || continue
PROPOSED["$key"]=$value
[[ "$(current_pin "$key")" != "$value" ]] && distro_changed=true
done <<<"$result"
if [[ "$distro_changed" == true ]]; then
CHANGED+=("$distro")
echo " $distro: update available"
else
echo " $distro: current"
fi
done
# --- Verify against a candidate copy before proposing anything --------------
CANDIDATE=$(mktemp)
trap 'rm -f "$CANDIDATE"' EXIT
cp "$TARGET" "$CANDIDATE"
for key in "${!PROPOSED[@]}"; do
value=${PROPOSED[$key]}
# Replace only the quoted value so trailing comments on the pin survive.
sed -i -E "s|^($key=)\"[^\"]*\"|\1\"$value\"|" "$CANDIDATE"
done
# The Ubuntu comment names the release, so regenerate it rather than let it go
# stale against the codename beside it.
if [[ -n "${PROPOSED[UBUNTU_VER]:-}" ]]; then
v=${PROPOSED[UBUNTU_VER]}
sed -i -E "s|^(UBUNTU_CODENAME=\"[^\"]*\").*|\1 # ${v:0:2}.${v:2} LTS|" "$CANDIDATE"
fi
if ! URLS=$(image_urls_from "$CANDIDATE"); then
echo "Error: could not derive image URLs from $TARGET." >&2
echo "The pin block or the 'case \$DISTRO_CHOICE' block has probably been restructured;" >&2
echo "this script reads both by anchor and needs them intact." >&2
exit 2
fi
if [[ ${#CHANGED[@]} -eq 0 ]]; then
echo
echo "All pins are already current."
[[ ${#FAILED[@]} -gt 0 ]] && exit 2
exit 0
fi
echo
echo "Verifying candidate images..."
VERIFIED=()
for distro in "${CHANGED[@]}"; do
url=$(url_for "$distro")
if image_ok "$url"; then
echo " $distro: OK $url"
VERIFIED+=("$distro")
else
# Refuse the bump rather than write a pin that 404s at build time.
echo " $distro: UNVERIFIED, pin left alone $url" >&2
FAILED+=("$distro")
fi
done
if [[ ${#VERIFIED[@]} -eq 0 ]]; then
echo >&2
echo "No candidate image could be verified. Nothing to do." >&2
exit 2
fi
# Drop the pins of any distro that failed verification, so a bad lookup for one
# distro cannot block the others or sneak a broken URL in alongside them.
if [[ ${#VERIFIED[@]} -ne ${#CHANGED[@]} ]]; then
cp "$TARGET" "$CANDIDATE"
for key in "${!PROPOSED[@]}"; do
keep=false
for distro in "${VERIFIED[@]}"; do
prefix=$(tr '[:lower:]' '[:upper:]' <<<"${distro/almalinux/alma}")
[[ "$key" == "${prefix}_"* ]] && keep=true
done
[[ "$keep" == true ]] || continue
sed -i -E "s|^($key=)\"[^\"]*\"|\1\"${PROPOSED[$key]}\"|" "$CANDIDATE"
done
if [[ -n "${PROPOSED[UBUNTU_VER]:-}" ]] && [[ " ${VERIFIED[*]} " == *" ubuntu "* ]]; then
v=${PROPOSED[UBUNTU_VER]}
sed -i -E "s|^(UBUNTU_CODENAME=\"[^\"]*\").*|\1 # ${v:0:2}.${v:2} LTS|" "$CANDIDATE"
fi
fi
echo
echo "Proposed pin changes:"
diff -u "$TARGET" "$CANDIDATE" | sed -n '/^[-+][A-Z]/p' || true
if [[ "$WRITE" != true ]]; then
echo
echo "Dry run. Re-run with --write to apply."
exit 3
fi
if ! bash -n "$CANDIDATE"; then
echo "Error: the updated script does not parse. Refusing to write." >&2
exit 2
fi
cp "$CANDIDATE" "$TARGET"
echo
echo "Updated $TARGET."
echo "Review with 'git diff' and remember the Proxmox host needs the new copy."
[[ ${#FAILED[@]} -gt 0 ]] && exit 2
exit 0