#!/usr/bin/env bash # # Package the wbxStoreProvision 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 `wbxprov-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 -------------------------------------------------------------- # The build context is this directory (docker/remote-agent), which contains # both the Dockerfile and the single agent source file. This is intentionally # self-contained — the agent doesn't need any of the bot's source. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONTEXT_DIR="$SCRIPT_DIR" 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" CHECK_ONLY=0 while [[ $# -gt 0 ]]; do case "$1" in --tag) shift VERSION="${1:-}" shift || true ;; --platform) shift PLATFORM="${1:-}" shift || true ;; --check) # Preflight only: verify docker/buildx/binfmt/builder are set up # for the requested --platform. Do not build anything. CHECK_ONLY=1 shift ;; -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 wbxprov-remote-agent version: ${VERSION}" log "Target platform: ${PLATFORM}" IMAGE_TAG="wbxprov-remote-agent:${VERSION}" BUNDLE_NAME="wbxprov-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="wbxprov-remote-agent-builder" CURRENT_BUILDER_DRIVER="" if docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then CURRENT_BUILDER_DRIVER="$(docker buildx inspect "$BUILDER_NAME" 2>/dev/null | awk -F': *' '/^Driver:/ {print $2; exit}')" fi if [[ -z "$CURRENT_BUILDER_DRIVER" ]]; then log "Creating dedicated buildx builder '${BUILDER_NAME}' (docker-container driver)..." docker buildx create \ --name "$BUILDER_NAME" \ --driver docker-container \ --bootstrap >/dev/null elif [[ "$CURRENT_BUILDER_DRIVER" != "docker-container" ]]; then # A builder with this exact name exists but uses the wrong driver — the # plain `docker` driver, most likely — which is the exact silent-fail # source: it's pinned to the daemon's native arch and will happily ignore # --platform. Recreate it correctly. warn "Existing builder '${BUILDER_NAME}' uses driver '${CURRENT_BUILDER_DRIVER}', not 'docker-container'." warn "Recreating it so cross-arch builds actually respect --platform..." docker buildx rm "$BUILDER_NAME" >/dev/null 2>&1 || true docker buildx create \ --name "$BUILDER_NAME" \ --driver docker-container \ --bootstrap >/dev/null else # Right builder, right driver — just make sure it's up. 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 # --- 2a. --check preflight short-circuit ------------------------------------ # If --check was passed, we've now verified: docker daemon reachable, buildx # available, a docker-container builder exists (or was just recreated) for # cross-arch, and binfmt handlers were attempted. Report and exit without # building anything. if (( CHECK_ONLY == 1 )); then log "" log "==========================================================" log " Preflight OK for target platform: ${PLATFORM}" log " host platform: ${HOST_LINUX_PLATFORM}" log " builder: ${BUILDER_NAME} (docker-container)" if [[ "$PLATFORM" != "$HOST_LINUX_PLATFORM" ]]; then log " cross-arch: yes (binfmt registered above)" else log " cross-arch: no (native build)" fi log "==========================================================" log "Re-run without --check to actually produce a bundle." exit 0 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 = ${CONTEXT_DIR})..." docker buildx build \ --builder "$BUILDER_NAME" \ --platform "$PLATFORM" \ --output "type=docker,dest=${UNZIPPED_TAR},name=${IMAGE_TAG}" \ -f "$DOCKERFILE" \ "$CONTEXT_DIR" [[ -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" "wbxprov-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. Independent post-build inspection ---------------------------------- # Final belt-and-suspenders: run inspect-bundle.sh against the ZIP we just # produced. This reads the image config straight out of the tarball WITHOUT # going through the docker daemon, so it catches anything a misbehaving # daemon or `docker image inspect` might have hidden earlier. INSPECT_SCRIPT="$SCRIPT_DIR/inspect-bundle.sh" if [[ -x "$INSPECT_SCRIPT" ]]; then log "Running independent (no-docker) inspection on the final ZIP..." EXPECTED_OS=linux EXPECTED_ARCH="$EXPECTED_ARCH" \ "$INSPECT_SCRIPT" "$ZIP_PATH" \ || die "Independent inspection FAILED. Bundle at $ZIP_PATH is not fit to ship." else warn "inspect-bundle.sh not found or not executable; skipping independent verification." fi # --- 10. 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"