#!/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:` 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 " " 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"