netanalyzer/docker/remote-agent/package.sh
Joseph McQueen 74b3a9fcb6 fix(docker): actually cross-build linux/amd64 image on Apple Silicon
The default buildx builder (docker driver) is bound to the daemon's native
platform, so on an Apple Silicon Mac `--platform linux/amd64 --load` was
silently producing an arm64 image. The bundle then failed on the linux/amd64
target host with the "exec format error" that install.sh's arch sanity
check now surfaces as "Image architecture (arm64) does not match this host
(amd64)".

package.sh now:
- Creates a dedicated `sha-remote-agent-builder` (docker-container driver)
  on first run so cross-arch builds actually work.
- Best-effort installs tonistiigi/binfmt QEMU handlers when the target
  platform differs from the host.
- Uses `--output type=docker,dest=...` instead of `--load` + `docker save`,
  bypassing the local daemon's cross-arch storage limits entirely.
- Verifies the produced image's Architecture against --platform after the
  build and aborts if they disagree, so a broken ZIP can never leave the
  build host.
- Only re-tags :latest when the built platform matches the host, to avoid
  leaving a broken cross-arch :latest in the local daemon.

README documents the trap and the mitigations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 10:02:43 -04:00

270 lines
11 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# Package the StoreHealthAnalyzer remote agent into a self-contained ZIP
# for offline / manual transfer to a remote Docker host.
#
# What this script does:
# 1. Reads the version from docker/remote-agent/package.json.
# 2. Builds `sha-remote-agent:<version>` from the local source tree.
# 3. `docker save`s the image, gzip-compressed, into a temp staging dir.
# 4. Copies deploy/docker-compose.yml, deploy/install.sh, deploy/README.md,
# and .env.example into the staging dir. Rewrites the compose file's
# __VERSION__ placeholder to match the built image tag.
# 5. Writes VERSION and SHA256SUMS files for identification / integrity.
# 6. Zips the whole staging dir into docker/remote-agent/dist/.
#
# Usage:
# ./docker/remote-agent/package.sh # tag=package.json, platform=linux/amd64
# ./docker/remote-agent/package.sh --tag 1.0.1 # override tag
# ./docker/remote-agent/package.sh --platform linux/arm64 # ARM Linux target
# ./docker/remote-agent/package.sh --platform linux/amd64 # explicit default (Linux RH/Rocky/CentOS/Ubuntu on Intel)
#
# The image is ALWAYS built for the target platform via `docker buildx
# build --platform ...` so the tarball you ship matches the remote host.
# Default is linux/amd64 because that's the overwhelmingly common Linux
# server architecture; override with --platform if your remote host is
# something else (e.g. linux/arm64 for a Raspberry Pi or ARM-based server).
#
# Requires: docker (with buildx), zip, node (for reading package.json),
# sha256sum OR shasum (macOS ships shasum by default).
set -euo pipefail
# --- Locations --------------------------------------------------------------
# Absolute path to this script's directory; then one level up is the repo root.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
DIST_DIR="$SCRIPT_DIR/dist"
DEPLOY_DIR="$SCRIPT_DIR/deploy"
DOCKERFILE="$SCRIPT_DIR/Dockerfile"
# --- Colors -----------------------------------------------------------------
GRN=$'\033[0;32m'
YLW=$'\033[1;33m'
RED=$'\033[0;31m'
RST=$'\033[0m'
log() { printf '%s[package]%s %s\n' "$GRN" "$RST" "$*"; }
warn() { printf '%s[package]%s %s\n' "$YLW" "$RST" "$*"; }
die() { printf '%s[package]%s %s\n' "$RED" "$RST" "$*" >&2; exit 1; }
# --- Argument parsing -------------------------------------------------------
VERSION=""
# Default target platform. Overwhelming majority of Linux server hosts
# (RHEL, Rocky, CentOS, Ubuntu, Debian) run on x86_64. Override with
# --platform for ARM Linux (linux/arm64) or anything else.
PLATFORM="linux/amd64"
while [[ $# -gt 0 ]]; do
case "$1" in
--tag)
shift
VERSION="${1:-}"
shift || true
;;
--platform)
shift
PLATFORM="${1:-}"
shift || true
;;
-h|--help)
grep '^#' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
die "Unknown argument: $1 (try --help)"
;;
esac
done
[[ -n "$PLATFORM" ]] || die "--platform requires a value (e.g. linux/amd64, linux/arm64)"
# --- Preflight --------------------------------------------------------------
command -v docker >/dev/null 2>&1 || die "docker not found on PATH."
command -v zip >/dev/null 2>&1 || die "zip not found on PATH."
command -v node >/dev/null 2>&1 || die "node not found on PATH."
# buildx is required so we can cross-build for a specific target platform
# on any host (e.g. build linux/amd64 from an Apple Silicon Mac).
docker buildx version >/dev/null 2>&1 \
|| die "docker buildx not available. Install Docker Desktop or the buildx plugin."
if [[ -z "$VERSION" ]]; then
VERSION="$(node -p "require('$SCRIPT_DIR/package.json').version")"
fi
[[ -n "$VERSION" ]] || die "Could not determine version."
log "Packaging sha-remote-agent version: ${VERSION}"
log "Target platform: ${PLATFORM}"
IMAGE_TAG="sha-remote-agent:${VERSION}"
BUNDLE_NAME="sha-remote-agent-${VERSION}"
STAGING_DIR="$(mktemp -d)"
STAGING_ROOT="$STAGING_DIR/$BUNDLE_NAME"
mkdir -p "$STAGING_ROOT"
# Guarantee cleanup even on error.
cleanup() { rm -rf "$STAGING_DIR"; }
trap cleanup EXIT
# Normalize host arch into a linux/* platform string for cross-build detection.
HOST_ARCH_RAW="$(uname -m)"
case "$HOST_ARCH_RAW" in
x86_64|amd64) HOST_LINUX_PLATFORM="linux/amd64" ;;
aarch64|arm64) HOST_LINUX_PLATFORM="linux/arm64" ;;
armv7l) HOST_LINUX_PLATFORM="linux/arm/v7" ;;
*) HOST_LINUX_PLATFORM="linux/${HOST_ARCH_RAW}" ;;
esac
EXPECTED_ARCH="${PLATFORM##*/}"
# --- 1. Ensure a cross-arch-capable buildx builder --------------------------
# The DEFAULT buildx builder on Docker Desktop uses the "docker" driver, which
# is tied to the daemon's native platform. Passing --platform linux/amd64 on
# an arm64 host with that driver can silently produce an arm64 image (which
# is exactly the "image arch does not match host" failure the install.sh
# sanity check catches on the target machine).
#
# We work around it by creating (once) a dedicated builder with the
# "docker-container" driver, which spins up an isolated BuildKit instance
# capable of cross-arch builds when QEMU/binfmt is available.
BUILDER_NAME="sha-remote-agent-builder"
if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
log "Creating dedicated buildx builder '${BUILDER_NAME}' (docker-container driver)..."
docker buildx create \
--name "$BUILDER_NAME" \
--driver docker-container \
--bootstrap >/dev/null
else
# Make sure the builder is up (bootstrap is a no-op if it already is).
docker buildx inspect --bootstrap "$BUILDER_NAME" >/dev/null
fi
# --- 2. Cross-arch binfmt (only when needed) --------------------------------
# Docker Desktop ships QEMU/binfmt handlers by default so this usually no-ops,
# but plain Docker Engine, Colima, or rootless setups often don't. When we're
# cross-building, best-effort install binfmt for the target arch. If it fails
# (e.g. no --privileged, no internet, no image), warn but continue — the
# subsequent build step will fail fast with a clearer error if binfmt truly
# is missing.
if [[ "$PLATFORM" != "$HOST_LINUX_PLATFORM" ]]; then
log "Cross-arch build (${HOST_LINUX_PLATFORM} -> ${PLATFORM}); ensuring binfmt handlers..."
if ! docker run --privileged --rm tonistiigi/binfmt --install "$EXPECTED_ARCH" >/dev/null 2>&1; then
warn "Could not auto-install binfmt for ${EXPECTED_ARCH}. If the build fails, install it manually:"
warn " docker run --privileged --rm tonistiigi/binfmt --install all"
fi
fi
# --- 3. Build directly to a portable tarball --------------------------------
# `--output type=docker,dest=...` writes a `docker load`-compatible tarball
# straight to disk. This intentionally bypasses `--load` (and therefore the
# question of whether the local daemon can even store cross-arch images).
UNZIPPED_TAR="$STAGING_ROOT/${BUNDLE_NAME}.tar"
log "Building ${IMAGE_TAG} for ${PLATFORM} -> $(basename "$UNZIPPED_TAR") (context = ${REPO_ROOT})..."
docker buildx build \
--builder "$BUILDER_NAME" \
--platform "$PLATFORM" \
--output "type=docker,dest=${UNZIPPED_TAR},name=${IMAGE_TAG}" \
-f "$DOCKERFILE" \
"$REPO_ROOT"
[[ -s "$UNZIPPED_TAR" ]] || die "buildx produced no output tarball. Aborting."
# --- 4. Verify the built image actually matches --platform ------------------
# Regression guard: if buildx (or binfmt) silently ignored the requested
# platform, catch it here instead of shipping a broken bundle that only
# fails on the remote host with an "exec format error".
log "Verifying built image architecture..."
docker load -i "$UNZIPPED_TAR" >/dev/null
ACTUAL_ARCH="$(docker image inspect --format '{{.Architecture}}' "$IMAGE_TAG")"
if [[ "$ACTUAL_ARCH" != "$EXPECTED_ARCH" ]]; then
die "Built image architecture is '${ACTUAL_ARCH}' but '${EXPECTED_ARCH}' was requested.
This usually means buildx couldn't cross-compile for ${PLATFORM}.
Try installing binfmt handlers explicitly:
docker run --privileged --rm tonistiigi/binfmt --install all
Then re-run:
$0 --platform ${PLATFORM}"
fi
log "Verified: image architecture is ${ACTUAL_ARCH} (matches requested ${EXPECTED_ARCH})."
# Also tag :latest locally for convenience (only when it matches the host,
# so we don't leave a broken cross-arch :latest sitting in the daemon).
if [[ "$PLATFORM" == "$HOST_LINUX_PLATFORM" ]]; then
docker tag "$IMAGE_TAG" "sha-remote-agent:latest" 2>/dev/null || true
fi
# --- 5. Compress the tarball ------------------------------------------------
IMAGE_TARBALL="${BUNDLE_NAME}.tar.gz"
log "Compressing image tarball -> ${IMAGE_TARBALL}..."
gzip -c "$UNZIPPED_TAR" > "$STAGING_ROOT/$IMAGE_TARBALL"
rm -f "$UNZIPPED_TAR"
TAR_SIZE_MB="$(du -m "$STAGING_ROOT/$IMAGE_TARBALL" | cut -f1)"
log "Image tarball size: ${TAR_SIZE_MB} MB"
# --- 6. Copy deploy assets --------------------------------------------------
log "Copying deploy assets into bundle..."
cp "$SCRIPT_DIR/.env.example" "$STAGING_ROOT/.env.example"
cp "$DEPLOY_DIR/install.sh" "$STAGING_ROOT/install.sh"
cp "$DEPLOY_DIR/README.md" "$STAGING_ROOT/README.md"
# Template the version into the runtime compose file so it references the
# specific image tag we just built.
sed "s|__VERSION__|${VERSION}|g" \
"$DEPLOY_DIR/docker-compose.yml" > "$STAGING_ROOT/docker-compose.yml"
chmod +x "$STAGING_ROOT/install.sh"
# --- 7. VERSION + SHA256SUMS -----------------------------------------------
printf '%s\n' "$VERSION" > "$STAGING_ROOT/VERSION"
log "Computing SHA-256 checksum for the image tarball..."
pushd "$STAGING_ROOT" >/dev/null
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$IMAGE_TARBALL" > SHA256SUMS
elif command -v shasum >/dev/null 2>&1; then
# macOS: shasum -a 256 emits the same "<hash> <filename>" format
# `sha256sum -c` understands.
shasum -a 256 "$IMAGE_TARBALL" > SHA256SUMS
else
warn "No sha256sum/shasum available — skipping checksum file."
fi
popd >/dev/null
# --- 8. Zip -----------------------------------------------------------------
mkdir -p "$DIST_DIR"
ZIP_PATH="$DIST_DIR/${BUNDLE_NAME}.zip"
rm -f "$ZIP_PATH"
log "Creating ZIP: ${ZIP_PATH}"
# Zip from inside the temp dir so the archive contains the top-level folder
# with the bundle name (matching what install.sh expects when unzipped).
(cd "$STAGING_DIR" && zip -qr "$ZIP_PATH" "$BUNDLE_NAME")
ZIP_SIZE_MB="$(du -m "$ZIP_PATH" | cut -f1)"
# --- 9. Done ---------------------------------------------------------------
log ""
log "=========================================================="
log " Bundle ready:"
log " $ZIP_PATH"
log " (${ZIP_SIZE_MB} MB, built for ${PLATFORM})"
log "=========================================================="
log ""
log "Transfer to the remote host, then:"
log " unzip ${BUNDLE_NAME}.zip"
log " cd ${BUNDLE_NAME}"
log " ./install.sh"