# StoreHealthAnalyzer Remote Agent — Docker Standalone container for the remote agent that proxies SIW / MDM requests from an internal network back to the main StoreHealthAnalyzer server over an authenticated WebSocket. ## What ships in the image - `node:22-alpine` runtime with [`tini`](https://github.com/krallin/tini) as PID 1 so `docker stop` reaches Node's SIGTERM handler and the websocket closes cleanly. - Just the agent script (`remoteAgent.js`) and its three runtime deps (`ws`, `axios`, `dotenv`). No bot framework, no Express, no test tooling. - Runs as the unprivileged `node` user. Final image size is small (roughly 60–80 MB depending on architecture), compared to ~180 MB if the root `package.json` were installed. ## Files in this folder | File | Purpose | | --- | --- | | `Dockerfile` | Two-stage build (`deps` → `runtime`). Uses the repo root as the build context so it can pull in `remoteAgent.js`. | | `package.json` | Minimal manifest: `ws`, `axios`, `dotenv`. | | `docker-compose.yml` | Convenience wrapper for **local** builds; run from the repo root. | | `.env.example` | Copy to `.env`, fill in `WS_URL` + `WS_TOKEN`. | | `package.sh` | Builds the image and produces a self-contained deploy ZIP under `dist/`. | | `deploy/` | Files that get bundled into the deploy ZIP (runtime compose, `install.sh`, remote README). | | `dist/` | Generated ZIPs (gitignored). | ## Prerequisites - Docker 24+ (BuildKit is default and required for the `syntax=` line). - The main StoreHealthAnalyzer server reachable from the host that will run this container (outbound only — the agent doesn't listen on any port). - A shared `WS_TOKEN` value matching the one configured on the server. ## Build Always build from the **repository root** — the Dockerfile expects that context so it can copy `remoteAgent.js`: ```bash # From the repo root docker build \ -f docker/remote-agent/Dockerfile \ -t sha-remote-agent:latest \ . ``` Tag with a version too if you plan to ship it to a registry: ```bash docker tag sha-remote-agent:latest ghcr.io//sha-remote-agent:1.0.0 docker push ghcr.io//sha-remote-agent:1.0.0 ``` ## Configure ```bash cp docker/remote-agent/.env.example docker/remote-agent/.env $EDITOR docker/remote-agent/.env ``` Required values: - `WS_URL` — websocket URL of the main server (e.g. `wss://sha.example.com/ws`). - `WS_TOKEN` — shared secret matching the server's `WS_TOKEN`. Both `.env` and `.env.*` are excluded by the top-level `.dockerignore`, so the file is never baked into the image. ## Run ### Docker CLI ```bash docker run --rm -it \ --name sha-remote-agent \ --env-file docker/remote-agent/.env \ sha-remote-agent:latest ``` Add `-d` for detached mode and `--restart unless-stopped` if you want it to auto-recover on host reboots. ### Docker Compose (recommended) ```bash # From the repo root docker compose -f docker/remote-agent/docker-compose.yml up -d --build # Tail logs docker compose -f docker/remote-agent/docker-compose.yml logs -f # Stop docker compose -f docker/remote-agent/docker-compose.yml down ``` Compose sets `restart: unless-stopped` and 10 MB / 3-file JSON log rotation so the container survives host restarts and doesn't fill the disk with reconnect chatter. ## Deploy elsewhere (ZIP bundle — recommended) For hosts you can't reach with a registry, use the packaging script — it produces a single ZIP with the image, a runtime compose file, an installer, and a checksum: ```bash # From the repo root — defaults to building for linux/amd64 npm run agent:package # or, equivalently: ./docker/remote-agent/package.sh ``` Output lands in `docker/remote-agent/dist/sha-remote-agent-.zip` (the folder is gitignored). Transfer that one file to the remote host and: ```bash unzip sha-remote-agent-.zip cd sha-remote-agent- ./install.sh # loads the image, seeds .env, starts the container ``` Full remote-host instructions ship inside the ZIP as `README.md` and are also visible here for reference: [`deploy/README.md`](deploy/README.md). The script tags the image both `sha-remote-agent:` and `sha-remote-agent:latest`, so local `docker compose` still works after packaging. ### Target-platform selection (very important on Apple Silicon) Docker images are architecture-specific. If you build on an Apple Silicon Mac with `docker build`, you get an `arm64` image — which will **fail to start** on a typical x86_64 Linux server (RHEL, Rocky, CentOS, Ubuntu) with `exec /sbin/tini: exec format error`. The packaging script uses `docker buildx build --platform ...` to avoid that. The default target is `linux/amd64`. Override with `--platform` when your remote host is different: ```bash # x86_64 Linux (the default — Linux RH / Rocky / CentOS / Ubuntu on Intel/AMD) ./docker/remote-agent/package.sh --platform linux/amd64 # ARM Linux (Raspberry Pi 4/5, Ampere servers, etc.) ./docker/remote-agent/package.sh --platform linux/arm64 # For local testing on Apple Silicon ./docker/remote-agent/package.sh --platform linux/arm64 ``` `install.sh` on the remote host also detects `image_arch != host_arch` and refuses to start with a clear message pointing at the right rebuild command, so a wrong-arch ZIP fails fast instead of after `docker run`. Cross-building requires `docker buildx` — Docker Desktop ships it by default; on Linux install the `docker-buildx-plugin` package if it isn't already there. #### How the script avoids the "silent arm64 image" trap The default buildx builder on Docker Desktop uses the `docker` driver, which is bound to the daemon's native platform. Passing `--platform linux/amd64` to it from an Apple Silicon host can silently produce an `arm64` image (or, depending on the Desktop version, ignore the flag with only a warning). To sidestep that, `package.sh`: 1. Creates a dedicated `sha-remote-agent-builder` with the `docker-container` driver on first run (isolated BuildKit instance, cross-arch capable). 2. Best-effort installs `tonistiigi/binfmt` QEMU handlers when the target platform doesn't match the host. 3. Writes the image directly to a tarball via `--output type=docker,dest=...` instead of `--load` + `docker save`, so the local daemon's cross-arch storage limits are irrelevant. 4. **Verifies** the produced image's `Architecture` against `--platform` after the build and aborts the packaging run if they disagree — so a broken ZIP can never leave the build host. If the verification ever fires, install binfmt explicitly and rebuild: ```bash docker run --privileged --rm tonistiigi/binfmt --install all ./docker/remote-agent/package.sh --platform linux/amd64 ``` ### Manual export (without the packaging script) If you'd rather do it by hand: ```bash # Export from the build host docker save sha-remote-agent:latest | gzip > sha-remote-agent.tar.gz # Import on the target host gunzip -c sha-remote-agent.tar.gz | docker load # On the target: only .env is needed; no source tree required docker run --rm -d \ --name sha-remote-agent \ --restart unless-stopped \ --env-file /path/to/remote-agent.env \ sha-remote-agent:latest ``` ## Networking The agent is a **websocket client** — nothing listens inside the container, so there's no port to publish. You just need outbound network access from the container to: - The main StoreHealthAnalyzer server (`WS_URL`). - Whatever internal APIs the agent proxies for (SIW, MDM, ...). If the internal APIs live only on the container host's network (e.g. a private VLAN accessible only from the host), uncomment `network_mode: host` in `docker-compose.yml` (Linux only). On Docker Desktop for macOS/Windows, prefer running the container on a user-defined bridge network that has route access to the required endpoints. ## Verifying it works Startup logs from a healthy agent look like: ``` 🔄 Connecting to wss://sha.example.com/ws... ✅ Remote Agent connected to StoreHealthAnalyzer ``` On the main server side you should see a matching `Remote agent connected` log line. From then on, `st [number]` commands that need SIW data will succeed instead of degrading to the "Remote agent is not connected" banner. ## Signals and shutdown The agent handles `SIGTERM` and `SIGINT` explicitly (see `remoteAgent.js`), closing the websocket before exiting. Because we run `tini` as PID 1, `docker stop` (which sends `SIGTERM` then kills after the grace period) reaches Node correctly and the exit is clean. ## Rebuilding after code changes Because `remoteAgent.js` is copied in during the runtime stage, changing the script requires a rebuild (`--build` with compose, or a fresh `docker build`). The `deps` stage is cached whenever `package.json` is unchanged, so incremental rebuilds are fast.