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>
270 lines
11 KiB
Bash
Executable file
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"
|