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>
248 lines
8.6 KiB
Markdown
248 lines
8.6 KiB
Markdown
# 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/<owner>/sha-remote-agent:1.0.0
|
||
docker push ghcr.io/<owner>/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-<version>.zip`
|
||
(the folder is gitignored). Transfer that one file to the remote host and:
|
||
|
||
```bash
|
||
unzip sha-remote-agent-<version>.zip
|
||
cd sha-remote-agent-<version>
|
||
./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:<version>` 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.
|