Embed the sha256 prefix in the uploaded filename so every rebuild lives at a unique URL. Cloudflare's edge cache (and any similar CDN in front of R2) can never serve stale bytes for the URL the catalog points at. The R2 console offers no per-URL purge for this bucket layout, so making the URL itself content-addressed is the only durable fix. Also republishes the debian-bookworm catalog entry with the new filename. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
161 lines
5 KiB
Bash
Executable file
161 lines
5 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# publish-golden-image.sh
|
|
#
|
|
# Build the banger golden-image bundle, upload it to R2, and patch
|
|
# internal/imagecat/catalog.json with the resulting URL + sha256 +
|
|
# size. Mirrors publish-kernel.sh for kernelcat.
|
|
#
|
|
# Usage:
|
|
# scripts/publish-golden-image.sh [--name <n>] [--kernel-ref <k>] \
|
|
# [--distro <d>] [--arch <a>] [--description "..."] \
|
|
# [--size <spec>] [--platform <p>] [--skip-upload]
|
|
#
|
|
# Environment overrides:
|
|
# RCLONE_REMOTE rclone remote to upload through (default: banger-images)
|
|
# RCLONE_BUCKET R2 bucket name (default: banger-images)
|
|
# BASE_URL public URL prefix for the bucket (default: https://images.thaloco.com)
|
|
|
|
set -euo pipefail
|
|
|
|
log() { printf '[publish-golden-image] %s\n' "$*" >&2; }
|
|
die() { log "$*"; exit 1; }
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
CATALOG_FILE="$REPO_ROOT/internal/imagecat/catalog.json"
|
|
|
|
RCLONE_REMOTE="${RCLONE_REMOTE:-banger-images}"
|
|
RCLONE_BUCKET="${RCLONE_BUCKET:-banger-images}"
|
|
BASE_URL="${BASE_URL:-https://images.thaloco.com}"
|
|
|
|
NAME="debian-bookworm"
|
|
KERNEL_REF="generic-6.12"
|
|
DISTRO="debian"
|
|
ARCH="x86_64"
|
|
DESCRIPTION=""
|
|
SIZE=""
|
|
PLATFORM="linux/amd64"
|
|
SKIP_UPLOAD=0
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--name) NAME="${2:-}"; shift 2;;
|
|
--kernel-ref) KERNEL_REF="${2:-}"; shift 2;;
|
|
--distro) DISTRO="${2:-}"; shift 2;;
|
|
--arch) ARCH="${2:-}"; shift 2;;
|
|
-d|--description) DESCRIPTION="${2:-}"; shift 2;;
|
|
--size) SIZE="${2:-}"; shift 2;;
|
|
--platform) PLATFORM="${2:-}"; shift 2;;
|
|
--skip-upload) SKIP_UPLOAD=1; shift;;
|
|
-h|--help)
|
|
sed -n '2,/^set -euo/p' "$0" | sed 's/^# \?//' | sed '$d'
|
|
exit 0
|
|
;;
|
|
*) die "unknown option: $1";;
|
|
esac
|
|
done
|
|
|
|
for tool in jq sha256sum stat; do
|
|
command -v "$tool" >/dev/null 2>&1 || die "missing required tool: $tool"
|
|
done
|
|
[[ -f "$CATALOG_FILE" ]] || die "catalog file not found: $CATALOG_FILE"
|
|
if [[ "$SKIP_UPLOAD" -ne 1 ]]; then
|
|
for tool in rclone curl; do
|
|
command -v "$tool" >/dev/null 2>&1 || die "missing required tool: $tool"
|
|
done
|
|
fi
|
|
|
|
STAGE="$(mktemp -d)"
|
|
trap 'rm -rf "$STAGE"' EXIT
|
|
# Build to a temp name; the content-addressed final name is chosen
|
|
# after sha256 is computed.
|
|
BUILD_OUT="$STAGE/build.tar.zst"
|
|
|
|
log "building bundle via make-golden-bundle.sh"
|
|
SIZE_FLAG=()
|
|
[[ -n "$SIZE" ]] && SIZE_FLAG=(--size "$SIZE")
|
|
"$SCRIPT_DIR/make-golden-bundle.sh" \
|
|
--name "$NAME" \
|
|
--kernel-ref "$KERNEL_REF" \
|
|
--distro "$DISTRO" \
|
|
--arch "$ARCH" \
|
|
--description "$DESCRIPTION" \
|
|
--platform "$PLATFORM" \
|
|
"${SIZE_FLAG[@]}" \
|
|
--out "$BUILD_OUT"
|
|
|
|
SHA256="$(sha256sum "$BUILD_OUT" | awk '{print $1}')"
|
|
SIZE_BYTES="$(stat -c '%s' "$BUILD_OUT")"
|
|
HUMAN="$(numfmt --to=iec --suffix=B "$SIZE_BYTES" 2>/dev/null || echo "${SIZE_BYTES}B")"
|
|
|
|
# Content-addressed filename: every rebuild lives at a unique URL, so
|
|
# stale CDN caches can never serve the wrong bytes for the URL the
|
|
# catalog points at. First 12 hex chars of sha256 is plenty of
|
|
# collision margin for this workload.
|
|
SHA_PREFIX="${SHA256:0:12}"
|
|
TARBALL_NAME="${NAME}-${ARCH}-${SHA_PREFIX}.tar.zst"
|
|
OUT="$STAGE/$TARBALL_NAME"
|
|
mv "$BUILD_OUT" "$OUT"
|
|
|
|
log "bundle ready: $TARBALL_NAME ($HUMAN, sha256 $SHA256)"
|
|
|
|
if [[ "$SKIP_UPLOAD" -eq 1 ]]; then
|
|
KEEP="$REPO_ROOT/dist/$TARBALL_NAME"
|
|
mkdir -p "$(dirname "$KEEP")"
|
|
cp -f "$OUT" "$KEEP"
|
|
log "--skip-upload set; catalog not patched"
|
|
log "bundle kept at: $KEEP"
|
|
exit 0
|
|
fi
|
|
|
|
log "uploading to $RCLONE_REMOTE:$RCLONE_BUCKET/$TARBALL_NAME"
|
|
# --s3-no-check-bucket skips the HeadBucket preflight; --no-check-dest
|
|
# skips the HeadObject preflight. Both fail with 403 on R2 tokens that
|
|
# only have PutObject + GetObject but not Head* — a common scoped-token
|
|
# setup.
|
|
rclone copyto \
|
|
--s3-no-check-bucket \
|
|
--no-check-dest \
|
|
"$OUT" "$RCLONE_REMOTE:$RCLONE_BUCKET/$TARBALL_NAME"
|
|
|
|
URL="$BASE_URL/$TARBALL_NAME"
|
|
log "verifying $URL is reachable"
|
|
HEAD_STATUS="$(curl -fsSI -o /dev/null -w '%{http_code}' "$URL" || true)"
|
|
if [[ "$HEAD_STATUS" != "200" ]]; then
|
|
die "uploaded tarball is not publicly reachable at $URL (HTTP $HEAD_STATUS); check bucket public-access config"
|
|
fi
|
|
|
|
log "patching $CATALOG_FILE"
|
|
NEW_ENTRY="$(jq -n \
|
|
--arg name "$NAME" \
|
|
--arg distro "$DISTRO" \
|
|
--arg arch "$ARCH" \
|
|
--arg kref "$KERNEL_REF" \
|
|
--arg url "$URL" \
|
|
--arg sha "$SHA256" \
|
|
--argjson size "$SIZE_BYTES" \
|
|
--arg desc "$DESCRIPTION" \
|
|
'{
|
|
name: $name,
|
|
distro: $distro,
|
|
arch: $arch,
|
|
kernel_ref: $kref,
|
|
tarball_url: $url,
|
|
tarball_sha256: $sha,
|
|
size_bytes: $size,
|
|
description: $desc
|
|
} | with_entries(select(.value != null and .value != ""))')"
|
|
|
|
CATALOG_TMP="$(mktemp)"
|
|
jq --arg name "$NAME" --argjson new "$NEW_ENTRY" '
|
|
.version = (.version // 1)
|
|
| .entries = (((.entries // []) | map(select(.name != $name))) + [$new])
|
|
| .entries |= sort_by(.name)
|
|
' "$CATALOG_FILE" > "$CATALOG_TMP"
|
|
mv "$CATALOG_TMP" "$CATALOG_FILE"
|
|
|
|
log "done"
|
|
log "next steps:"
|
|
log " git diff -- $CATALOG_FILE"
|
|
log " git add $CATALOG_FILE && git commit -m 'imagecat: publish $NAME'"
|
|
log " make build # rebuild banger so the new catalog is embedded"
|