collabSupport/dect-relay-agent/bundle.sh
Joseph McQueen 4f9ebdb5fb Rework DECT relay bundle to ship a pre-built Docker image
The previous packager (scripts/packageDectRelayAgent.js) shipped a
source-only bundle and expected the DC host to build the image with
`docker compose up --build`. That fails hard in corporate DCs with
TLS-intercepted egress: Alpine's apk fetch of dl-cdn.alpinelinux.org
can't verify the intercepted certificate ("apk: TLS: server
certificate not trusted"), and npm install would fail the same way
if apk had succeeded.

New approach: build the image ONCE on the dev machine (where TLS
works), save it as a gzipped tarball, and ship a ZIP whose install
step is `docker load` + `docker compose up -d`. Zero network calls
inside the DC container, ever.

Bundling (dev-machine):
- dect-relay-agent/bundle.sh: build → docker save → gzip → zip.
  Auto-derives version from package.json, records git sha + dirty
  flag + build date into image labels. Cross-arch friendly
  (--platform=linux/amd64 by default; --platform linux/arm64 for
  ARM DCs). Output: dect-relay-agent-bundle-<YYYYMMDD-HHMMSS>.zip
  at repo root (typically 40-60MB).
- dect-relay-agent/Dockerfile: multi-stage node:20-alpine build.
  No apk add. No runtime npm install. Non-root `node` user (uid
  1000). Node handles SIGTERM natively via index.js handlers, so
  no tini/dumb-init needed. Designed to build from the REPO ROOT
  (not the agent folder) because the agent imports shared modules
  from ../integrations/cisco-dect and ../utils.
- dect-relay-agent/Dockerfile.dockerignore: per-Dockerfile ignore
  (BuildKit ≥ 23.0) with a whitelist that keeps the build context
  to ~50KB. Older Docker daemons fall through to the repo-root
  .dockerignore, which already excludes secrets — nothing sensitive
  can leak either way.
- package.json: `npm run package:relay` now invokes bundle.sh.

Runtime (DC-host):
- dect-relay-agent/docker-compose.yml: pins IMAGE_TAG from .env
  (install.sh writes it there — never falls back to :latest), reads
  the rest of the config via env_file, restart: unless-stopped,
  host networking (needed to reach 10.x/8 without userland proxy
  translation, and the agent doesn't listen on anything). Hardened:
  read_only: true rootfs with a 16MB /tmp tmpfs, cap_drop: ALL,
  no-new-privileges, log rotation at 10MB × 5 files.
- dect-relay-agent/install.sh: preflight (docker + compose present,
  daemon reachable, bundle files intact), docker load, pin loaded
  tag into .env, validate .env has the three required values not
  still set to placeholder strings, docker compose up -d, tail last
  40 log lines. Idempotent — safe to re-run on upgrades.

Cleanup:
- scripts/packageDectRelayAgent.js: deleted (superseded).
- .gitignore: drops the scripts/* + !packageDectRelayAgent.js dance
  since we no longer need to whitelist that one file; add pattern
  for the datestamped bundle zips + staging dirs at repo root.
- dect-relay-agent/README.md: replaces the deploy section with the
  new dev-machine-build → DC-host-load workflow, plus a
  troubleshooting section keyed on the exact error messages seen
  during the failed in-DC build (TLS cert not trusted, docker perm
  denied, DIGEST_401).

Verified: all 113 existing tests still pass. Docker build itself
requires a Docker daemon (dev machine) so can't be exercised in
this sandbox — the bash scripts pass `bash -n` syntax checks.
2026-07-03 10:05:05 -04:00

204 lines
9.6 KiB
Bash
Executable file

#!/usr/bin/env bash
# ────────────────────────────────────────────────────────────────────
# DECT relay agent — dev-machine bundler.
#
# Builds the Docker image for linux/amd64, saves it as a gzipped
# tarball, and zips it up with the compose file + .env template +
# install script. Output is a self-contained ZIP the DC operator
# can transfer over any file-copy channel (email, S3, USB, git-lfs)
# and install with a single `./install.sh` invocation.
#
# Requirements on the dev machine:
# - Docker Desktop / Docker Engine
# - Internet access to pull node:20-alpine + npm registry
# - `zip` (macOS + most Linux distros already have it; if not,
# `apt install zip` / `brew install zip`)
#
# Requirements on the DC host:
# - Docker + Docker Compose v2 (v1 also works)
# - Ability to `docker load` (i.e. member of the docker group or
# root)
# - Outbound HTTPS to the bot + 10.0.0.0/8 on TCP 443
# - That's it. No npm, no python, no Alpine mirrors.
#
# Usage (from repo root OR from this folder — the script figures it out):
# ./dect-relay-agent/bundle.sh # uses version from package.json
# ./dect-relay-agent/bundle.sh --tag 0.2.0 # override version
# ./dect-relay-agent/bundle.sh --platform linux/arm64
# ────────────────────────────────────────────────────────────────────
set -euo pipefail
# ─── Argument parsing ─────────────────────────────────────────────
TAG_OVERRIDE=""
PLATFORM="linux/amd64" # standard x86_64 Linux server. Override if DC is arm.
while [[ $# -gt 0 ]]; do
case "$1" in
--tag) TAG_OVERRIDE="$2"; shift 2 ;;
--platform) PLATFORM="$2"; shift 2 ;;
-h|--help)
grep -E '^# ' "$0" | sed 's/^# \?//'
exit 0
;;
*) echo "Unknown flag: $1" >&2; exit 2 ;;
esac
done
# ─── Locate paths (works from repo root or agent dir) ─────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
AGENT_DIR="$SCRIPT_DIR"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Sanity: the Dockerfile expects to build from the repo root.
[[ -d "$REPO_ROOT/integrations/cisco-dect" ]] || {
echo "ERROR: $REPO_ROOT does not look like the collabSupport repo root (no integrations/cisco-dect/)" >&2
exit 1
}
# ─── Determine version tag ────────────────────────────────────────
if [[ -n "$TAG_OVERRIDE" ]]; then
VERSION="$TAG_OVERRIDE"
else
# Pull version from agent's package.json without needing jq. The
# regex is deliberately tolerant of trailing commas / whitespace.
VERSION="$(sed -nE 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$AGENT_DIR/package.json" | head -1)"
[[ -n "$VERSION" ]] || { echo "ERROR: could not read version from package.json" >&2; exit 1; }
fi
# Try to record the git commit into the image labels — useful in
# production for "which build am I running?". Missing git is fine.
GIT_COMMIT="unknown"
if command -v git >/dev/null 2>&1 && git -C "$REPO_ROOT" rev-parse --short HEAD >/dev/null 2>&1; then
GIT_COMMIT="$(git -C "$REPO_ROOT" rev-parse --short HEAD)"
# Mark as dirty if the working tree has uncommitted changes —
# catches "I built from local edits" surprises in production.
if ! git -C "$REPO_ROOT" diff --quiet 2>/dev/null || \
! git -C "$REPO_ROOT" diff --cached --quiet 2>/dev/null; then
GIT_COMMIT="${GIT_COMMIT}-dirty"
fi
fi
BUILD_DATE="$(date -u +%FT%TZ)"
IMAGE_TAG="collabsupport/dect-relay-agent:${VERSION}"
BUNDLE_STAMP="$(date +%Y%m%d-%H%M%S)"
BUNDLE_DIR="dect-relay-agent-bundle-${BUNDLE_STAMP}"
BUNDLE_ZIP="${BUNDLE_DIR}.zip"
echo "════════════════════════════════════════════════════════════════"
echo " Building DECT relay agent bundle"
echo "────────────────────────────────────────────────────────────────"
echo " Version: ${VERSION}"
echo " Image tag: ${IMAGE_TAG}"
echo " Platform: ${PLATFORM}"
echo " Git commit: ${GIT_COMMIT}"
echo " Build date: ${BUILD_DATE}"
echo " Bundle dir: ${BUNDLE_DIR}/"
echo " Bundle ZIP: ${BUNDLE_ZIP}"
echo "════════════════════════════════════════════════════════════════"
# ─── Docker build ─────────────────────────────────────────────────
# --platform pins the arch so building on Apple Silicon still
# produces an x86_64 image the DC can run. Docker uses QEMU to
# emulate cross-arch — slower than native but Just Works.
echo
echo "[1/4] Building image..."
docker build \
--platform="${PLATFORM}" \
--file "${AGENT_DIR}/Dockerfile" \
--tag "${IMAGE_TAG}" \
--build-arg "AGENT_VERSION=${VERSION}" \
--build-arg "BUILD_DATE=${BUILD_DATE}" \
--build-arg "GIT_COMMIT=${GIT_COMMIT}" \
"${REPO_ROOT}"
# ─── Stage the bundle ─────────────────────────────────────────────
# Work in the parent of the agent dir so the resulting ZIP + dir
# both land somewhere obvious (the repo root by convention).
echo
echo "[2/4] Staging bundle in ${REPO_ROOT}/${BUNDLE_DIR}/"
rm -rf "${REPO_ROOT:?}/${BUNDLE_DIR}"
mkdir -p "${REPO_ROOT}/${BUNDLE_DIR}"
# docker save streams a tarball to stdout; pipe through gzip to
# shrink it substantially (typically ~40% smaller for Node images).
echo
echo "[3/4] Saving image to ${BUNDLE_DIR}/image.tar.gz (this can take a minute)"
docker save "${IMAGE_TAG}" | gzip -9 > "${REPO_ROOT}/${BUNDLE_DIR}/image.tar.gz"
# Copy the operator-facing files. We do NOT copy Dockerfile / bundle.sh
# — those are dev-machine concerns.
cp "${AGENT_DIR}/docker-compose.yml" "${REPO_ROOT}/${BUNDLE_DIR}/"
cp "${AGENT_DIR}/.env.example" "${REPO_ROOT}/${BUNDLE_DIR}/"
cp "${AGENT_DIR}/install.sh" "${REPO_ROOT}/${BUNDLE_DIR}/"
cp "${AGENT_DIR}/README.md" "${REPO_ROOT}/${BUNDLE_DIR}/AGENT-README.md"
# Write a bundle-specific README that's short and tells the operator
# what to do in this exact folder. Keeps AGENT-README.md as the deep
# reference without cluttering the top-of-bundle experience.
cat > "${REPO_ROOT}/${BUNDLE_DIR}/README.txt" <<EOF
DECT Relay Agent — deployment bundle
====================================
Version: ${VERSION}
Image tag: ${IMAGE_TAG}
Built: ${BUILD_DATE}
Git commit: ${GIT_COMMIT}
Platform: ${PLATFORM}
To install on this data-center host:
1. cp .env.example .env
2. Edit .env — set:
- DECT_RELAY_BOT_URL (wss:// URL to the bot)
- DECT_RELAY_AGENT_TOKEN (shared bearer, same as bot's env)
- DECT_ADMIN_PASSWORD (DECT serviceability password)
3. ./install.sh
4. Watch it come up: docker compose logs -f dect-relay-agent
Files in this bundle:
image.tar.gz Prebuilt Docker image (gzipped, ~40MB)
docker-compose.yml Compose file — read-only rootfs, host network,
log rotation. Loaded by install.sh.
.env.example Config template.
install.sh Runs 'docker load' then 'docker compose up -d'.
Safe to re-run for upgrades.
AGENT-README.md Full agent docs — wire protocol, safety model,
run instructions.
README.txt This file.
No internet access required after the image is loaded. The container
runs with a read-only root filesystem, drops all Linux capabilities,
and uses the 'node' non-root user.
Upgrading:
Unzip the new bundle in a new folder, or overwrite this one, and
re-run ./install.sh. install.sh pins the new image tag into .env
automatically.
EOF
# ─── Zip it up ────────────────────────────────────────────────────
echo
echo "[4/4] Zipping bundle → ${BUNDLE_ZIP}"
(
cd "${REPO_ROOT}"
# -r recursive, -X strip Mac resource forks so we don't ship
# __MACOSX/ folders that confuse Linux operators.
zip -rqX "${BUNDLE_ZIP}" "${BUNDLE_DIR}"
)
# Cleanup: keep the ZIP, remove the staging dir. Operator only wants
# the ZIP to transfer.
rm -rf "${REPO_ROOT:?}/${BUNDLE_DIR}"
SIZE="$(du -h "${REPO_ROOT}/${BUNDLE_ZIP}" | cut -f1)"
echo
echo "════════════════════════════════════════════════════════════════"
echo " Bundle ready: ${REPO_ROOT}/${BUNDLE_ZIP} (${SIZE})"
echo "────────────────────────────────────────────────────────────────"
echo " Transfer to the DC and:"
echo " unzip ${BUNDLE_ZIP}"
echo " cd ${BUNDLE_DIR}"
echo " cp .env.example .env && \$EDITOR .env"
echo " ./install.sh"
echo "════════════════════════════════════════════════════════════════"