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.
This commit is contained in:
parent
e8500b4324
commit
4f9ebdb5fb
10 changed files with 573 additions and 496 deletions
22
.gitignore
vendored
22
.gitignore
vendored
|
|
@ -26,16 +26,7 @@ storage/
|
||||||
|
|
||||||
# Dev / test artifacts (local only)
|
# Dev / test artifacts (local only)
|
||||||
characterization-runs/
|
characterization-runs/
|
||||||
# NOTE on ordering: `scripts/*` (glob) rather than `scripts/` (dir
|
scripts/
|
||||||
# exclusion) because git gitignore semantics forbid re-including a
|
|
||||||
# file inside an excluded directory. Using `scripts/*` still excludes
|
|
||||||
# every file inside scripts/ by default, but leaves the door open for
|
|
||||||
# `!` overrides below.
|
|
||||||
scripts/*
|
|
||||||
# Un-ignored: the DECT relay packager is part of the deploy workflow
|
|
||||||
# and needs to be tracked so anyone with a fresh clone can build the
|
|
||||||
# data-center bundle via `npm run package:relay`.
|
|
||||||
!scripts/packageDectRelayAgent.js
|
|
||||||
characterize-*.js
|
characterize-*.js
|
||||||
|
|
||||||
# Backup & temp files
|
# Backup & temp files
|
||||||
|
|
@ -57,11 +48,12 @@ dist/
|
||||||
build/
|
build/
|
||||||
coverage/
|
coverage/
|
||||||
.nyc_output/
|
.nyc_output/
|
||||||
# Temporary staging dir created by scripts/packageDectRelayAgent.js.
|
|
||||||
# The script cleans this up in a finally block, but a SIGKILL can
|
# DECT relay agent deploy bundles produced by dect-relay-agent/bundle.sh.
|
||||||
# leave it behind. Ignoring means an interrupted run doesn't leak
|
# The datestamped zip lands at repo root and shouldn't be committed —
|
||||||
# staged files into future git commits.
|
# it's ~40MB (Docker image tarball) and rebuildable on demand.
|
||||||
.package-relay-tmp/
|
dect-relay-agent-bundle-*.zip
|
||||||
|
dect-relay-agent-bundle-*/
|
||||||
|
|
||||||
# Docker / Misc
|
# Docker / Misc
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
# Everything the bundle-side Dockerfile does NOT need.
|
|
||||||
# The bundle produced by scripts/packageDectRelayAgent.js only ever
|
|
||||||
# contains: workspace/, Dockerfile, docker-compose.yml, .env.example,
|
|
||||||
# .dockerignore, and README.deploy.md — so this file is mostly
|
|
||||||
# defensive (belt-and-suspenders against a stray copy or an operator
|
|
||||||
# running `docker build` in a manually-assembled bundle).
|
|
||||||
|
|
||||||
# Never ship secrets or local overrides.
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Never ship a local node_modules — the Dockerfile installs fresh.
|
|
||||||
**/node_modules
|
|
||||||
|
|
||||||
# Never ship logs, dev artifacts, or IDE cruft.
|
|
||||||
**/*.log
|
|
||||||
**/.DS_Store
|
|
||||||
**/.git
|
|
||||||
**/.gitignore
|
|
||||||
|
|
@ -1,78 +1,95 @@
|
||||||
# syntax=docker/dockerfile:1.6
|
# syntax=docker/dockerfile:1.6
|
||||||
#
|
#
|
||||||
# DECT Relay Agent — production container image
|
# DECT relay agent — production image.
|
||||||
#
|
#
|
||||||
# Build context: the bundle produced by scripts/packageDectRelayAgent.js.
|
# BUILD CONTEXT: the REPO ROOT (not this folder). The agent imports
|
||||||
# The bundle contains a `workspace/` directory that mirrors just enough of
|
# `../integrations/cisco-dect/*` and `../utils/httpDigestAuth.js`, so
|
||||||
# the parent repo to satisfy the agent's `../integrations/...` and
|
# we mirror the repo's layout under /workspace/ inside the image and
|
||||||
# `../utils/...` imports without any source rewriting:
|
# the relative paths just work.
|
||||||
#
|
#
|
||||||
# workspace/
|
# BUILD FROM REPO ROOT:
|
||||||
# dect-relay-agent/ ← WORKDIR at runtime
|
# docker build \
|
||||||
# package.json
|
# --platform=linux/amd64 \
|
||||||
# index.js
|
# -f dect-relay-agent/Dockerfile \
|
||||||
# integrations/cisco-dect/{client,probes,statusXml}.js
|
# -t collabsupport/dect-relay-agent:0.1.0 \
|
||||||
# utils/httpDigestAuth.js
|
# .
|
||||||
#
|
#
|
||||||
# Building this Dockerfile in the raw repo (`docker build dect-relay-agent/`)
|
# Or use bundle.sh which wraps this + `docker save` + zip.
|
||||||
# WILL NOT WORK — the shared modules live one directory up and would be
|
#
|
||||||
# outside the build context. Always build from a bundle produced by the
|
# WHY NO `apk add`: corporate DCs commonly TLS-intercept HTTPS. Alpine's
|
||||||
# packager.
|
# apk fetch of dl-cdn.alpinelinux.org fails inside the container when
|
||||||
|
# the CA chain includes a proxy cert the container doesn't trust. We
|
||||||
|
# avoid the problem entirely by not fetching anything from Alpine at
|
||||||
|
# build time. Signal handling (SIGTERM / SIGINT / SIGUSR2) is done in
|
||||||
|
# index.js so we don't need tini/dumb-init.
|
||||||
|
#
|
||||||
|
# WHY NO RUNTIME `npm install`: the bundle.sh workflow builds this
|
||||||
|
# image ONCE outside the DC (where npm registry access works), saves
|
||||||
|
# it as a tarball, and ships the tarball. The DC only runs
|
||||||
|
# `docker load` + `docker compose up -d` — zero network calls beyond
|
||||||
|
# the initial docker load.
|
||||||
|
|
||||||
# ── Stage 1: install prod deps ──────────────────────────────────────
|
# ─── Stage 1: builder ────────────────────────────────────────────────
|
||||||
# node:20-alpine keeps the final image ~55MB. Alpine's musl libc has
|
# Installs prod deps in a full node image (has python/build-essentials
|
||||||
# been fine for this agent's plain JS + axios + ws footprint (no
|
# just in case a native module needs building — currently `ws` ships
|
||||||
# native modules) but if you ever add one that needs glibc, switch to
|
# pre-built optional deps for common arches but we keep the option
|
||||||
# node:20-slim.
|
# open for future deps).
|
||||||
FROM node:20-alpine AS deps
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
# Only copy the agent's manifest first so this layer caches across
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /workspace/dect-relay-agent
|
||||||
|
|
||||||
|
# Copy just the package manifest first so this layer caches across
|
||||||
# code-only changes.
|
# code-only changes.
|
||||||
COPY workspace/dect-relay-agent/package.json ./package.json
|
COPY dect-relay-agent/package.json ./package.json
|
||||||
|
|
||||||
# `npm install --omit=dev` because there's no committed lockfile
|
# Install only production deps. --ignore-scripts because we don't run
|
||||||
# (the agent has three dependencies; every deploy resolving the same
|
# arbitrary postinstall from transitive deps in the container build;
|
||||||
# `^` ranges is acceptable for this operational tool). Add
|
# any needed build steps are pinned in this Dockerfile.
|
||||||
# --ignore-scripts to refuse arbitrary lifecycle-script execution from
|
|
||||||
# the registry — none of our current deps use lifecycle scripts.
|
|
||||||
RUN npm install --omit=dev --ignore-scripts \
|
RUN npm install --omit=dev --ignore-scripts \
|
||||||
&& npm cache clean --force
|
&& npm cache clean --force
|
||||||
|
|
||||||
|
# ─── Stage 2: runtime ────────────────────────────────────────────────
|
||||||
|
# Same base as builder, but only the artifacts we actually need at
|
||||||
|
# run time (node_modules + agent source + shared integrations + utils).
|
||||||
|
|
||||||
# ── Stage 2: runtime ────────────────────────────────────────────────
|
|
||||||
FROM node:20-alpine AS runtime
|
FROM node:20-alpine AS runtime
|
||||||
|
|
||||||
# tini gives us proper PID-1 signal handling (SIGTERM propagates
|
# node:20-alpine ships a `node` user (uid 1000) that we can just use —
|
||||||
# cleanly to node so our graceful shutdown path in index.js actually
|
# no need to install anything extra. Running as a non-root user is a
|
||||||
# runs on `docker stop`).
|
# baseline hardening we get essentially for free.
|
||||||
RUN apk add --no-cache tini
|
USER node
|
||||||
|
|
||||||
# Non-root user. UID/GID pinned so bind-mounted volumes (if any) are
|
# Match the repo layout so relative imports (`../integrations/...`)
|
||||||
# predictable across hosts.
|
# resolve exactly as they do in development.
|
||||||
RUN addgroup -S -g 1500 dect \
|
WORKDIR /workspace/dect-relay-agent
|
||||||
&& adduser -S -u 1500 -G dect -H -s /sbin/nologin dect
|
|
||||||
|
|
||||||
WORKDIR /app
|
# Ship the node_modules we built in stage 1. Ownership goes to `node`
|
||||||
|
# so the process can read them without needing root.
|
||||||
|
COPY --from=builder --chown=node:node /workspace/dect-relay-agent/node_modules ./node_modules
|
||||||
|
|
||||||
# Copy the shared workspace tree — the agent's imports of
|
# Agent source + manifest.
|
||||||
# `../integrations/...` and `../utils/...` resolve exactly as they do
|
COPY --chown=node:node dect-relay-agent/package.json ./package.json
|
||||||
# in the source repo. See the bundle layout comment at the top of
|
COPY --chown=node:node dect-relay-agent/index.js ./index.js
|
||||||
# this file.
|
|
||||||
COPY --chown=dect:dect workspace/ ./
|
|
||||||
|
|
||||||
# Bring in the deps that stage 1 resolved.
|
# Shared modules the agent imports from the parent workspace.
|
||||||
COPY --from=deps --chown=dect:dect /build/node_modules ./dect-relay-agent/node_modules
|
COPY --chown=node:node integrations/cisco-dect /workspace/integrations/cisco-dect
|
||||||
|
COPY --chown=node:node utils/httpDigestAuth.js /workspace/utils/httpDigestAuth.js
|
||||||
|
|
||||||
USER dect
|
# Optional metadata that shows up in `docker inspect` output — useful
|
||||||
WORKDIR /app/dect-relay-agent
|
# in the DC for "which build am I running?" without needing to poke
|
||||||
|
# inside the container.
|
||||||
|
ARG AGENT_VERSION=dev
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ARG GIT_COMMIT
|
||||||
|
LABEL org.opencontainers.image.title="dect-relay-agent" \
|
||||||
|
org.opencontainers.image.description="Data-center-resident WSS bridge from CollabSupport bot (cloud) to Cisco DBS-210 DECT base stations on 10.x/8" \
|
||||||
|
org.opencontainers.image.version="${AGENT_VERSION}" \
|
||||||
|
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||||
|
org.opencontainers.image.revision="${GIT_COMMIT}" \
|
||||||
|
org.opencontainers.image.source="https://git.joesjavajoint.com/jmcqueen/collabSupport"
|
||||||
|
|
||||||
# Runtime config comes from `docker compose` (--env-file .env) or
|
# Node handles SIGTERM natively when the process installs handlers
|
||||||
# `docker run --env-file ...`. Never bake secrets into the image.
|
# (which we do in index.js). --enable-source-maps improves stack
|
||||||
# The agent process itself validates required vars and exits 1 if any
|
# traces if something crashes at runtime — cheap and always-on.
|
||||||
# are missing (see assertConfig() in index.js).
|
CMD ["node", "--enable-source-maps", "index.js"]
|
||||||
|
|
||||||
# tini reaps zombies + forwards SIGTERM. `-g` puts tini in the same
|
|
||||||
# process group as node so `docker stop` also delivers SIGTERM to
|
|
||||||
# child processes if any are spawned in the future.
|
|
||||||
ENTRYPOINT ["/sbin/tini", "-g", "--"]
|
|
||||||
CMD ["node", "index.js"]
|
|
||||||
|
|
|
||||||
30
dect-relay-agent/Dockerfile.dockerignore
Normal file
30
dect-relay-agent/Dockerfile.dockerignore
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Per-Dockerfile ignore, picked up by BuildKit ≥ 23.0 when this
|
||||||
|
# Dockerfile is used (see https://docs.docker.com/build/concepts/context/#filename-and-location).
|
||||||
|
# For older Docker daemons, the repo-root /.dockerignore is used
|
||||||
|
# instead (it already excludes node_modules, .env*, logs/, etc., so
|
||||||
|
# nothing sensitive would leak — this file is a size/speed win, not
|
||||||
|
# a security requirement).
|
||||||
|
#
|
||||||
|
# The build context is the REPO ROOT. We whitelist only the paths
|
||||||
|
# the Dockerfile actually COPYs. That keeps the transferred context
|
||||||
|
# tiny (a few dozen KB instead of the whole repo) and makes builds
|
||||||
|
# noticeably faster on slow disks / VPN uplinks.
|
||||||
|
|
||||||
|
*
|
||||||
|
|
||||||
|
# ─── Whitelist (paths the Dockerfile needs) ─────────────────────────
|
||||||
|
!dect-relay-agent/package.json
|
||||||
|
!dect-relay-agent/index.js
|
||||||
|
!integrations/cisco-dect/**
|
||||||
|
!utils/httpDigestAuth.js
|
||||||
|
|
||||||
|
# ─── Never-ship, even inside whitelisted trees ──────────────────────
|
||||||
|
**/node_modules
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
!**/.env.example
|
||||||
|
**/*.log
|
||||||
|
**/logs
|
||||||
|
**/.git
|
||||||
|
**/.DS_Store
|
||||||
|
**/coverage
|
||||||
|
|
@ -21,17 +21,48 @@ There are two ways to run this. Pick one based on where you're deploying.
|
||||||
|
|
||||||
### 1. Docker container in the data center (recommended for production)
|
### 1. Docker container in the data center (recommended for production)
|
||||||
|
|
||||||
Run the packager on your dev machine to produce a self-contained zip that includes the shared modules the agent imports:
|
The DC host makes **zero network calls** during install — the image is built on your dev machine, saved as a tarball, and shipped inside a self-contained ZIP. This sidesteps the TLS-interception problem that breaks `apk add` and `npm install` inside containers on corporate networks.
|
||||||
|
|
||||||
|
**On your dev machine (with Docker Desktop / internet access):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From the parent repo root:
|
# From the repo root — script is self-locating:
|
||||||
npm run package:relay
|
./dect-relay-agent/bundle.sh
|
||||||
# → writes dist/dect-relay-agent-bundle-<YYYYMMDD-HHMMSS>.zip
|
# → writes dect-relay-agent-bundle-<YYYYMMDD-HHMMSS>.zip (~40-60MB)
|
||||||
```
|
```
|
||||||
|
|
||||||
Transfer the zip to the DC host and follow the bundle's own `README.md` — three commands (`unzip`, edit `.env`, `docker compose up -d --build`).
|
Optional overrides:
|
||||||
|
|
||||||
If your DC host can't reach the npm registry, see the "No-internet DC option" section in the bundle's README — you can build the image on an internet-connected machine, `docker save` it to a tarball, and ship that instead.
|
```bash
|
||||||
|
./dect-relay-agent/bundle.sh --tag 0.2.0 # override version
|
||||||
|
./dect-relay-agent/bundle.sh --platform linux/arm64 # if the DC is ARM
|
||||||
|
```
|
||||||
|
|
||||||
|
**On the DC host** (once you've transferred the ZIP):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unzip dect-relay-agent-bundle-*.zip
|
||||||
|
cd dect-relay-agent-bundle-*
|
||||||
|
cp .env.example .env
|
||||||
|
$EDITOR .env # set BOT_URL + AGENT_TOKEN + ADMIN_PASSWORD
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`install.sh` is idempotent — re-run it after transferring a newer bundle to upgrade. It:
|
||||||
|
1. `docker load`s the image tarball
|
||||||
|
2. Pins the loaded tag into `.env` (so compose never falls back to a stale local image)
|
||||||
|
3. Validates required env values are set (not still placeholder strings)
|
||||||
|
4. `docker compose up -d`
|
||||||
|
5. Tails the last 40 log lines so you can see the "Connected — sending hello" message
|
||||||
|
|
||||||
|
**What the runtime container looks like:**
|
||||||
|
|
||||||
|
- Non-root `node` user (uid 1000)
|
||||||
|
- Read-only root filesystem, 16MB tmpfs at `/tmp`
|
||||||
|
- All Linux capabilities dropped, `no-new-privileges`
|
||||||
|
- Host networking (so it can reach `10.x/8` without userland proxy translation)
|
||||||
|
- No listening ports — outbound-only WSS to the bot
|
||||||
|
- Log rotation: 10MB × 5 files max
|
||||||
|
|
||||||
### 2. Direct node process (dev + local iteration)
|
### 2. Direct node process (dev + local iteration)
|
||||||
|
|
||||||
|
|
@ -132,8 +163,30 @@ The bot terminates the socket if no `pong` arrives within 90s; the agent auto-re
|
||||||
| `package.json` | Deps: `ws`, `axios`, `dotenv` |
|
| `package.json` | Deps: `ws`, `axios`, `dotenv` |
|
||||||
| `.env.example` | Annotated env template |
|
| `.env.example` | Annotated env template |
|
||||||
| `README.md` | This file |
|
| `README.md` | This file |
|
||||||
| `Dockerfile` | Multi-stage alpine build used by the deploy bundle |
|
| `Dockerfile` | Multi-stage Alpine build. No `apk add`, no runtime `npm install`. |
|
||||||
| `docker-compose.yml` | One-command deploy on the DC host |
|
| `Dockerfile.dockerignore` | Per-Dockerfile ignore (BuildKit ≥ 23.0). Whitelist-based; keeps build context ~50KB. |
|
||||||
| `.dockerignore` | Defensive; the packager already excludes cruft |
|
| `docker-compose.yml` | DC-side runtime shape (read-only rootfs, host net, capability drop, log rotation). |
|
||||||
|
| `bundle.sh` | Dev-machine packager: `docker build` → `docker save` → `zip`. Runs on your machine. |
|
||||||
|
| `install.sh` | DC-host installer: `docker load` → validate `.env` → `docker compose up -d`. Ships inside the bundle. |
|
||||||
|
|
||||||
The Dockerfile is intentionally NOT designed to be built from this folder directly (`docker build dect-relay-agent/` will fail — the shared modules live one level up and are outside the build context). Always build from a bundle produced by `npm run package:relay`, which assembles a `workspace/` tree that makes the shared imports resolvable inside the build context.
|
**Important**: the `Dockerfile` is designed to be built from the **repo root**, not from this folder, because it needs `../integrations/cisco-dect/*` and `../utils/httpDigestAuth.js` in the build context. `bundle.sh` does this correctly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f dect-relay-agent/Dockerfile -t ... . # note the trailing `.`
|
||||||
|
```
|
||||||
|
|
||||||
|
Building with `docker build dect-relay-agent/` will fail (missing shared modules) — always use `bundle.sh`, or invoke `docker build` from the repo root with `-f dect-relay-agent/Dockerfile`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**`WARNING: fetching … TLS: server certificate not trusted` during build**
|
||||||
|
You're building inside a TLS-intercepting corporate network. Don't — build on your dev machine and ship the tarball via `bundle.sh`. That's the whole point of this workflow.
|
||||||
|
|
||||||
|
**`docker: permission denied while trying to connect to the Docker daemon socket`**
|
||||||
|
The user running `install.sh` needs to be in the `docker` group. Either `sudo usermod -aG docker $USER` (log out/in after) or `sudo ./install.sh`.
|
||||||
|
|
||||||
|
**Agent connects then immediately disconnects with 401**
|
||||||
|
Bearer token mismatch. `DECT_RELAY_AGENT_TOKEN` on the bot side must match the agent's `.env` exactly. Rotate both together.
|
||||||
|
|
||||||
|
**Agent connects but every `collect` returns `DIGEST_401`**
|
||||||
|
DBS-210 admin password is wrong. Verify in Control Hub → Calling → Features → DECT Networks → Manage → Manage DECT serviceability password, update `DECT_ADMIN_PASSWORD` in `.env`, then `docker compose restart dect-relay-agent`.
|
||||||
|
|
|
||||||
204
dect-relay-agent/bundle.sh
Executable file
204
dect-relay-agent/bundle.sh
Executable file
|
|
@ -0,0 +1,204 @@
|
||||||
|
#!/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 "════════════════════════════════════════════════════════════════"
|
||||||
|
|
@ -1,63 +1,80 @@
|
||||||
# DECT Relay Agent — one-command deploy for the data center.
|
# DECT relay agent — data-center deployment.
|
||||||
#
|
#
|
||||||
# Deploy flow:
|
# This compose file assumes the image has already been loaded from
|
||||||
# 1. Unzip the bundle produced by scripts/packageDectRelayAgent.js.
|
# the shipped tarball (see install.sh: `docker load < image.tar.gz`).
|
||||||
# 2. `cp .env.example .env` and fill it in (bot URL, shared token,
|
# It does NOT build — build happens on the dev machine via bundle.sh
|
||||||
# DECT serviceability password). The .env file lives NEXT TO
|
# so no npm-registry or Alpine-CDN traffic is needed inside the DC.
|
||||||
# this compose file and is git-ignored.
|
|
||||||
# 3. `docker compose up -d --build`
|
|
||||||
# 4. `docker compose logs -f` and confirm you see the bot log
|
|
||||||
# "Agent connected from ..." on the other side.
|
|
||||||
#
|
#
|
||||||
# The agent is an OUTBOUND client (it dials the bot at
|
# Usage in the DC:
|
||||||
# DECT_RELAY_BOT_URL). No ports are exposed and no inbound firewall
|
# 1. cp .env.example .env && $EDITOR .env
|
||||||
# rules are needed on the DC host — only egress to:
|
# 2. ./install.sh
|
||||||
# - the bot's public HTTPS/WSS URL
|
# (or manually: docker load < image.tar.gz && docker compose up -d)
|
||||||
# - every DBS-210 base station on 10.0.0.0/8 (TCP 443)
|
# 3. docker compose logs -f dect-relay-agent
|
||||||
|
#
|
||||||
|
# The IMAGE_TAG env var lets install.sh pin whatever tag the bundle
|
||||||
|
# ships (the bundle writes it into .env on install). Falls back to
|
||||||
|
# the current default so `docker compose up` still works standalone.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
dect-relay-agent:
|
dect-relay-agent:
|
||||||
build:
|
# Tag comes from the image tarball shipped in the bundle; install.sh
|
||||||
context: .
|
# sets IMAGE_TAG in .env to whatever was baked in. Never falls back
|
||||||
dockerfile: Dockerfile
|
# to :latest — that would silently swap in whatever's cached on the
|
||||||
image: dect-relay-agent:latest
|
# DC host if the tarball didn't load correctly.
|
||||||
|
image: ${IMAGE_TAG:-collabsupport/dect-relay-agent:0.1.0}
|
||||||
container_name: dect-relay-agent
|
container_name: dect-relay-agent
|
||||||
|
|
||||||
# Restart on any exit (crash, host reboot, `docker stop` doesn't
|
# Read all config (bot URL, shared bearer, DBS-210 admin creds)
|
||||||
# count). Matches how the rest of the collabSupport stack is run.
|
# from the operator's .env in this same directory. Compose does
|
||||||
restart: unless-stopped
|
# NOT auto-load .env into the container by default — env_file
|
||||||
|
# is the explicit opt-in.
|
||||||
# All runtime config comes from .env — never bake secrets into
|
|
||||||
# the image. .env is created by the operator from .env.example
|
|
||||||
# and is git-ignored by convention.
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|
||||||
# No `ports:` block on purpose — see header comment.
|
# Restart on crash or reboot. `unless-stopped` respects an
|
||||||
|
# operator `docker compose stop` (so it doesn't come back until
|
||||||
|
# they say so) while surviving host reboots.
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
# Cap log volume so a chatty reconnect loop can't fill /var/log.
|
# Host networking so the agent can reach 10.x/8 without needing
|
||||||
# 10MB * 5 files = 50MB per container is plenty for
|
# docker userland proxy translation. The agent doesn't LISTEN on
|
||||||
# troubleshooting a week's worth of activity at info level.
|
# anything — it dials outbound WSS to the bot — so this doesn't
|
||||||
|
# expose any port to the host's network.
|
||||||
|
#
|
||||||
|
# If your DC prefers bridge networking, remove this line. The
|
||||||
|
# only requirement is that the container can egress to (a) the
|
||||||
|
# bot's public HTTPS endpoint and (b) 10.0.0.0/8 on TCP 443.
|
||||||
|
network_mode: host
|
||||||
|
|
||||||
|
# Log rotation — keeps container logs from filling the disk on
|
||||||
|
# long-running deployments. 10 MB × 5 files = 50 MB max per agent.
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "5"
|
max-file: "5"
|
||||||
|
|
||||||
# Health check hits the agent's own process. Since the agent
|
# Read-only root filesystem + a small writable /tmp. The agent
|
||||||
# doesn't expose an HTTP port, we probe by looking for the node
|
# writes nothing to disk (all logs go to stdout / stderr), so
|
||||||
# process — sufficient to catch crashes that the restart policy
|
# this is essentially free defense-in-depth.
|
||||||
# will then fix. A future v2 could expose a tiny /healthz on a
|
read_only: true
|
||||||
# localhost-only port with connection-state details.
|
tmpfs:
|
||||||
healthcheck:
|
- /tmp:size=16M
|
||||||
test: ["CMD-SHELL", "pgrep -f 'node index.js' > /dev/null || exit 1"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 10s
|
|
||||||
|
|
||||||
# Optional: uncomment if your DC network requires host networking
|
# Minimal capabilities — the agent is just outbound HTTP client
|
||||||
# for the agent to reach the 10.x bases (e.g. because a routed
|
# traffic, no need for NET_RAW / SYS_ADMIN / etc.
|
||||||
# docker bridge isn't set up). Adding host mode means the agent
|
cap_drop:
|
||||||
# inherits the host's routing table and IP stack directly.
|
- ALL
|
||||||
# network_mode: host
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
# Basic health check: the agent process being alive is a good
|
||||||
|
# proxy for "we're at least trying to reconnect". A deeper check
|
||||||
|
# (last successful hello with the bot < 2min ago) would need
|
||||||
|
# code the agent doesn't expose yet.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "process.exit(0)"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 10s
|
||||||
|
retries: 3
|
||||||
|
|
|
||||||
130
dect-relay-agent/install.sh
Executable file
130
dect-relay-agent/install.sh
Executable file
|
|
@ -0,0 +1,130 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
# DECT relay agent — data-center install / upgrade helper.
|
||||||
|
#
|
||||||
|
# Run this inside the unzipped bundle directory on the DC host. It:
|
||||||
|
# 1. Sanity-checks that Docker + Compose are installed.
|
||||||
|
# 2. Loads the shipped image tarball into the local Docker daemon.
|
||||||
|
# 3. Pins IMAGE_TAG in .env to whatever tag was baked into the
|
||||||
|
# tarball (so compose can never fall back to a stale local
|
||||||
|
# cache without you noticing).
|
||||||
|
# 4. Verifies .env exists and has the required keys populated.
|
||||||
|
# 5. Runs `docker compose up -d` and tails the last 40 lines.
|
||||||
|
#
|
||||||
|
# Safe to run repeatedly — it's a straightforward upgrade too:
|
||||||
|
# unzip -o new-bundle.zip -d dect-relay-agent-bundle
|
||||||
|
# cd dect-relay-agent-bundle
|
||||||
|
# ./install.sh
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Colors, only if stdout is a TTY. Corporate SSH sessions often are;
|
||||||
|
# CI / pipe-to-file are not.
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
BOLD=$'\033[1m'; DIM=$'\033[2m'; RED=$'\033[31m'; GREEN=$'\033[32m'
|
||||||
|
YELLOW=$'\033[33m'; RESET=$'\033[0m'
|
||||||
|
else
|
||||||
|
BOLD=''; DIM=''; RED=''; GREEN=''; YELLOW=''; RESET=''
|
||||||
|
fi
|
||||||
|
|
||||||
|
log() { echo "${BOLD}[install]${RESET} $*"; }
|
||||||
|
die() { echo "${RED}[install] ERROR:${RESET} $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Ensure we're running from the bundle dir (compose file must be here).
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# ─── 1. Preflight ──────────────────────────────────────────────────
|
||||||
|
log "Preflight checks"
|
||||||
|
command -v docker >/dev/null 2>&1 || die "docker is not installed or not on PATH"
|
||||||
|
|
||||||
|
# Compose v2 is `docker compose` (space); v1 is `docker-compose` (dash).
|
||||||
|
# Prefer v2. bail if neither is available.
|
||||||
|
if docker compose version >/dev/null 2>&1; then
|
||||||
|
COMPOSE="docker compose"
|
||||||
|
elif command -v docker-compose >/dev/null 2>&1; then
|
||||||
|
COMPOSE="docker-compose"
|
||||||
|
echo "${YELLOW}[install] Using legacy docker-compose v1. Consider upgrading to Compose v2.${RESET}"
|
||||||
|
else
|
||||||
|
die "docker compose (v2) not found and docker-compose (v1) not on PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
die "docker daemon is not reachable. Are you in the 'docker' group, or should you re-run with sudo?"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -f image.tar.gz ]] || die "image.tar.gz not found in $(pwd) — is the bundle complete?"
|
||||||
|
[[ -f docker-compose.yml ]] || die "docker-compose.yml not found — is the bundle complete?"
|
||||||
|
|
||||||
|
# ─── 2. Load image ─────────────────────────────────────────────────
|
||||||
|
log "Loading Docker image from image.tar.gz (this is the only step that touches the docker daemon's image store)"
|
||||||
|
# `docker load` prints "Loaded image: <tag>" for each tag in the archive.
|
||||||
|
# We tee to stderr so the operator sees it, and grep the tag out for
|
||||||
|
# use in the .env pin step below.
|
||||||
|
LOAD_OUTPUT="$(gunzip -c image.tar.gz | docker load)"
|
||||||
|
echo "$LOAD_OUTPUT"
|
||||||
|
LOADED_TAG="$(echo "$LOAD_OUTPUT" | awk -F': ' '/Loaded image/ {print $2; exit}')"
|
||||||
|
[[ -n "$LOADED_TAG" ]] || die "docker load did not report a loaded image tag"
|
||||||
|
log "Loaded image: ${GREEN}${LOADED_TAG}${RESET}"
|
||||||
|
|
||||||
|
# ─── 3. .env setup ─────────────────────────────────────────────────
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo "${YELLOW}[install] Created .env from .env.example. Edit it now with real values, then re-run this script.${RESET}"
|
||||||
|
echo " Required: DECT_RELAY_BOT_URL, DECT_RELAY_AGENT_TOKEN, DECT_ADMIN_PASSWORD"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pin IMAGE_TAG in .env to the tag we just loaded. Idempotent —
|
||||||
|
# rewrites the line each run so upgrades to a new tarball tag Just
|
||||||
|
# Work without operator intervention.
|
||||||
|
if grep -q '^IMAGE_TAG=' .env; then
|
||||||
|
# Portable in-place sed (works on both GNU sed and BSD sed on macOS).
|
||||||
|
# The `.bak` tempfile is removed at end.
|
||||||
|
sed -i.bak "s|^IMAGE_TAG=.*|IMAGE_TAG=${LOADED_TAG}|" .env
|
||||||
|
rm -f .env.bak
|
||||||
|
else
|
||||||
|
printf '\n# Pinned automatically by install.sh on %s\nIMAGE_TAG=%s\n' \
|
||||||
|
"$(date -u +%FT%TZ)" "$LOADED_TAG" >> .env
|
||||||
|
fi
|
||||||
|
log "Pinned IMAGE_TAG=${LOADED_TAG} in .env"
|
||||||
|
|
||||||
|
# Validate the operator has actually filled in the required values —
|
||||||
|
# .env.example ships with placeholders that would blow up at runtime
|
||||||
|
# with a less friendly error.
|
||||||
|
MISSING=()
|
||||||
|
required_var() {
|
||||||
|
local key="$1" val
|
||||||
|
val="$(grep -E "^${key}=" .env | tail -1 | cut -d= -f2-)"
|
||||||
|
# Strip surrounding quotes and whitespace so both bare and quoted
|
||||||
|
# values validate the same.
|
||||||
|
val="${val#\"}"; val="${val%\"}"
|
||||||
|
val="${val#\'}"; val="${val%\'}"
|
||||||
|
val="${val## }"; val="${val%% }"
|
||||||
|
if [[ -z "$val" ]] || [[ "$val" == "replace-with-shared-secret" ]] \
|
||||||
|
|| [[ "$val" == "replace-with-dect-serviceability-password" ]] \
|
||||||
|
|| [[ "$val" == "wss://your-bot-host.example.com/dect-relay/ws" ]]; then
|
||||||
|
MISSING+=("$key")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
required_var DECT_RELAY_BOT_URL
|
||||||
|
required_var DECT_RELAY_AGENT_TOKEN
|
||||||
|
required_var DECT_ADMIN_PASSWORD
|
||||||
|
|
||||||
|
if [[ ${#MISSING[@]} -gt 0 ]]; then
|
||||||
|
echo "${RED}[install] .env is missing required values or still has placeholder text:${RESET}"
|
||||||
|
for k in "${MISSING[@]}"; do echo " - $k"; done
|
||||||
|
echo " Edit .env and re-run this script."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── 4. Compose up ─────────────────────────────────────────────────
|
||||||
|
log "Starting container via ${COMPOSE} up -d"
|
||||||
|
$COMPOSE up -d
|
||||||
|
|
||||||
|
log "Container started. Recent logs:"
|
||||||
|
sleep 2
|
||||||
|
$COMPOSE logs --tail=40 dect-relay-agent || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
log "${GREEN}Done.${RESET} Follow live logs with: ${DIM}${COMPOSE} logs -f dect-relay-agent${RESET}"
|
||||||
|
log "Stop the agent with: ${DIM}${COMPOSE} down${RESET}"
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
"docker:up": "docker compose up -d",
|
"docker:up": "docker compose up -d",
|
||||||
"docker:down": "docker compose down",
|
"docker:down": "docker compose down",
|
||||||
"docker:logs": "docker compose logs -f",
|
"docker:logs": "docker compose logs -f",
|
||||||
"package:relay": "node scripts/packageDectRelayAgent.js",
|
"package:relay": "bash dect-relay-agent/bundle.sh",
|
||||||
"test": "node --test tests/*.test.js"
|
"test": "node --test tests/*.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,346 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Assemble the DECT relay agent into a Docker-ready deploy bundle
|
|
||||||
* and zip it up for transfer into the data center.
|
|
||||||
*
|
|
||||||
* Why a packager instead of `docker build` in the repo:
|
|
||||||
* The agent's index.js imports the shared cisco-dect + httpDigestAuth
|
|
||||||
* modules via relative paths (`../integrations/cisco-dect/...`,
|
|
||||||
* `../utils/httpDigestAuth.js`). A raw `docker build dect-relay-agent/`
|
|
||||||
* would fail because the shared files live OUTSIDE the build context.
|
|
||||||
* This packager copies them into a self-contained `workspace/` tree
|
|
||||||
* inside the bundle so the container build sees them as local paths
|
|
||||||
* without any source rewriting.
|
|
||||||
*
|
|
||||||
* What ends up in the bundle:
|
|
||||||
* dect-relay-agent-bundle/
|
|
||||||
* Dockerfile (from dect-relay-agent/Dockerfile)
|
|
||||||
* docker-compose.yml (from dect-relay-agent/docker-compose.yml)
|
|
||||||
* .dockerignore (from dect-relay-agent/.dockerignore)
|
|
||||||
* .env.example (from dect-relay-agent/.env.example)
|
|
||||||
* README.md (deploy-focused; generated below)
|
|
||||||
* BUNDLE_INFO.txt (build metadata: git sha, timestamp, sizes)
|
|
||||||
* workspace/
|
|
||||||
* dect-relay-agent/
|
|
||||||
* package.json
|
|
||||||
* index.js
|
|
||||||
* integrations/cisco-dect/{client,probes,statusXml}.js
|
|
||||||
* utils/httpDigestAuth.js
|
|
||||||
*
|
|
||||||
* Output:
|
|
||||||
* dist/dect-relay-agent-bundle-<YYYYMMDD-HHMMSS>.zip
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* npm run package:relay
|
|
||||||
* node scripts/packageDectRelayAgent.js [--out dist] [--name my-bundle.zip]
|
|
||||||
*
|
|
||||||
* Requirements:
|
|
||||||
* - Node 20+
|
|
||||||
* - `zip` on the PATH (macOS + every mainstream Linux distro ship it)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { spawnSync, execSync } from 'node:child_process';
|
|
||||||
import { mkdirSync, cpSync, writeFileSync, rmSync, existsSync, statSync, readdirSync } from 'node:fs';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
||||||
|
|
||||||
// ─── CLI parsing (tiny) ────────────────────────────────────────────
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
function argVal(flag, fallback) {
|
|
||||||
const i = args.indexOf(flag);
|
|
||||||
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
|
||||||
}
|
|
||||||
const outDir = path.resolve(REPO_ROOT, argVal('--out', 'dist'));
|
|
||||||
const explicitZipName = argVal('--name', null);
|
|
||||||
|
|
||||||
// ─── Sources to copy into the bundle ───────────────────────────────
|
|
||||||
//
|
|
||||||
// Each entry is `[repo-relative source, bundle-relative destination]`.
|
|
||||||
// Kept as a plain list rather than a glob so it's obvious what's
|
|
||||||
// shipped — an accidental include of secrets or the entire repo
|
|
||||||
// would be a review-visible diff here.
|
|
||||||
|
|
||||||
const AGENT_FILES = [
|
|
||||||
['dect-relay-agent/package.json', 'workspace/dect-relay-agent/package.json'],
|
|
||||||
['dect-relay-agent/index.js', 'workspace/dect-relay-agent/index.js'],
|
|
||||||
];
|
|
||||||
|
|
||||||
const SHARED_FILES = [
|
|
||||||
['integrations/cisco-dect/client.js', 'workspace/integrations/cisco-dect/client.js'],
|
|
||||||
['integrations/cisco-dect/probes.js', 'workspace/integrations/cisco-dect/probes.js'],
|
|
||||||
['integrations/cisco-dect/statusXml.js', 'workspace/integrations/cisco-dect/statusXml.js'],
|
|
||||||
['utils/httpDigestAuth.js', 'workspace/utils/httpDigestAuth.js'],
|
|
||||||
];
|
|
||||||
|
|
||||||
// Docker artifacts + operator-facing files live NEXT TO the workspace
|
|
||||||
// (not inside it) because Dockerfile's `COPY workspace/ ./` treats
|
|
||||||
// workspace as the entire in-container /app tree.
|
|
||||||
const DEPLOY_FILES = [
|
|
||||||
['dect-relay-agent/Dockerfile', 'Dockerfile'],
|
|
||||||
['dect-relay-agent/docker-compose.yml', 'docker-compose.yml'],
|
|
||||||
['dect-relay-agent/.dockerignore', '.dockerignore'],
|
|
||||||
['dect-relay-agent/.env.example', '.env.example'],
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── Helpers ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function log(msg) { process.stdout.write(`[package-relay] ${msg}\n`); }
|
|
||||||
function err(msg) { process.stderr.write(`[package-relay] ${msg}\n`); }
|
|
||||||
|
|
||||||
function stamp() {
|
|
||||||
const d = new Date();
|
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
|
||||||
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-` +
|
|
||||||
`${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function gitSha() {
|
|
||||||
try {
|
|
||||||
return execSync('git rev-parse --short HEAD', { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
||||||
.toString().trim();
|
|
||||||
} catch { return 'unknown'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function gitDirty() {
|
|
||||||
try {
|
|
||||||
const out = execSync('git status --porcelain', { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
||||||
.toString().trim();
|
|
||||||
return out.length > 0;
|
|
||||||
} catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyMany(pairs, bundleDir) {
|
|
||||||
for (const [src, dst] of pairs) {
|
|
||||||
const absSrc = path.join(REPO_ROOT, src);
|
|
||||||
const absDst = path.join(bundleDir, dst);
|
|
||||||
if (!existsSync(absSrc)) {
|
|
||||||
throw new Error(`Missing required source file: ${src}`);
|
|
||||||
}
|
|
||||||
mkdirSync(path.dirname(absDst), { recursive: true });
|
|
||||||
cpSync(absSrc, absDst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function humanBytes(n) {
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
let i = 0, v = n;
|
|
||||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
||||||
return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dirSize(dir) {
|
|
||||||
let total = 0;
|
|
||||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
||||||
const p = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) total += dirSize(p);
|
|
||||||
else if (entry.isFile()) total += statSync(p).size;
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Bundle README ─────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Generated fresh at package time so it can reference the actual bundle
|
|
||||||
// version + build sha. Kept short — the operator's whole workflow is
|
|
||||||
// three commands.
|
|
||||||
|
|
||||||
function renderBundleReadme({ version, sha, dirty }) {
|
|
||||||
return `# DECT Relay Agent — deploy bundle
|
|
||||||
|
|
||||||
**Version:** \`${version}\`
|
|
||||||
**Source commit:** \`${sha}\`${dirty ? ' _(uncommitted changes present at build time)_' : ''}
|
|
||||||
|
|
||||||
This zip contains everything needed to run the DECT relay agent as a
|
|
||||||
Docker container inside the data center. The bot side (public cloud)
|
|
||||||
must already have \`DECT_RELAY_AGENT_TOKEN\` set to the same value
|
|
||||||
you'll use in \`.env\` below.
|
|
||||||
|
|
||||||
## Deploy
|
|
||||||
|
|
||||||
\`\`\`bash
|
|
||||||
# 1. On the DC host — unzip somewhere sensible
|
|
||||||
unzip dect-relay-agent-bundle-${version}.zip
|
|
||||||
cd dect-relay-agent-bundle-${version}
|
|
||||||
|
|
||||||
# 2. Configure — never commit this .env
|
|
||||||
cp .env.example .env
|
|
||||||
\${EDITOR:-vi} .env
|
|
||||||
# Required:
|
|
||||||
# DECT_RELAY_BOT_URL (wss://<your-bot-host>/dect-relay/ws)
|
|
||||||
# DECT_RELAY_AGENT_TOKEN (same value as the bot's .env)
|
|
||||||
# DECT_ADMIN_PASSWORD (DBS-210 serviceability password)
|
|
||||||
|
|
||||||
# 3. Build + start
|
|
||||||
docker compose up -d --build
|
|
||||||
|
|
||||||
# 4. Verify — you should see "Connected — sending hello"
|
|
||||||
docker compose logs -f
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
On the bot side, look for these log lines to confirm the socket is up:
|
|
||||||
|
|
||||||
\`\`\`
|
|
||||||
[dect:relay-hub] Agent connected from <dc-ip>
|
|
||||||
[dect:relay-hub] Agent hello: version=0.1.0 host=<hostname> caps=collect,reboot,...
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## Ongoing operations
|
|
||||||
|
|
||||||
| Task | Command |
|
|
||||||
|---|---|
|
|
||||||
| View live logs | \`docker compose logs -f\` |
|
|
||||||
| Restart agent | \`docker compose restart\` |
|
|
||||||
| Stop agent | \`docker compose down\` |
|
|
||||||
| Upgrade | Unzip the new bundle over the existing dir, then \`docker compose up -d --build\` |
|
|
||||||
| Rotate token | Change \`DECT_RELAY_AGENT_TOKEN\` in both \`.env\` files (agent + bot); \`docker compose restart\` on the agent side, restart the bot; expect ~1 reconnect gap |
|
|
||||||
|
|
||||||
## What's in this bundle
|
|
||||||
|
|
||||||
\`\`\`
|
|
||||||
Dockerfile multi-stage alpine build, non-root user, tini entrypoint
|
|
||||||
docker-compose.yml restart:unless-stopped, JSON log rotation, health check
|
|
||||||
.dockerignore defensive; the bundle already excludes cruft
|
|
||||||
.env.example annotated template — copy to .env
|
|
||||||
README.md this file
|
|
||||||
BUNDLE_INFO.txt build metadata (git sha, timestamp, file sizes)
|
|
||||||
workspace/ in-container /app tree
|
|
||||||
dect-relay-agent/ the agent's own code + package.json
|
|
||||||
integrations/ shared cisco-dect modules (parity with the bot)
|
|
||||||
utils/ shared HTTP Digest auth utility
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## No-internet DC option
|
|
||||||
|
|
||||||
If the DC host can't reach the npm registry to install dependencies
|
|
||||||
during \`docker build\`, build the image on a machine WITH internet
|
|
||||||
access and ship the image tarball:
|
|
||||||
|
|
||||||
\`\`\`bash
|
|
||||||
# On the internet-connected machine:
|
|
||||||
docker compose build
|
|
||||||
docker save dect-relay-agent:latest | gzip > dect-relay-agent-image.tar.gz
|
|
||||||
|
|
||||||
# Transfer dect-relay-agent-image.tar.gz + docker-compose.yml + .env to
|
|
||||||
# the DC host, then:
|
|
||||||
gunzip -c dect-relay-agent-image.tar.gz | docker load
|
|
||||||
docker compose up -d # skips build; uses loaded image
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
**\`RELAY_NOT_CONNECTED\` on the bot side.** Agent isn't reaching the
|
|
||||||
bot. Check \`docker compose logs\` — most common causes: wrong
|
|
||||||
\`DECT_RELAY_BOT_URL\`, wrong \`DECT_RELAY_AGENT_TOKEN\` (bot logs
|
|
||||||
\`Unauthorized upgrade attempt\`), or corporate proxy blocking
|
|
||||||
outbound wss. Test with \`docker compose exec dect-relay-agent wget -S -O- https://<bot-host>/health\`.
|
|
||||||
|
|
||||||
**Agent reconnects in a loop.** Check the agent's logs for a specific
|
|
||||||
\`error\` line. If it says the bot returned 401, the token is wrong.
|
|
||||||
If TLS errors, the bot's cert chain isn't trusted inside the
|
|
||||||
container — mount \`/etc/ssl/certs\` from the DC host into the
|
|
||||||
container.
|
|
||||||
|
|
||||||
**\`DIGEST_401\` in agent logs when servicing a \`collect\`.** Wrong
|
|
||||||
\`DECT_ADMIN_PASSWORD\`. Confirm against Control Hub → Calling →
|
|
||||||
Features → DECT Networks → Manage → Manage DECT serviceability
|
|
||||||
password.
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const version = stamp();
|
|
||||||
const sha = gitSha();
|
|
||||||
const dirty = gitDirty();
|
|
||||||
|
|
||||||
const stagingRoot = path.join(REPO_ROOT, '.package-relay-tmp');
|
|
||||||
const bundleName = `dect-relay-agent-bundle-${version}`;
|
|
||||||
const bundleDir = path.join(stagingRoot, bundleName);
|
|
||||||
const zipName = explicitZipName || `${bundleName}.zip`;
|
|
||||||
const zipPath = path.join(outDir, zipName);
|
|
||||||
|
|
||||||
// Fresh staging tree every run.
|
|
||||||
if (existsSync(stagingRoot)) rmSync(stagingRoot, { recursive: true, force: true });
|
|
||||||
mkdirSync(bundleDir, { recursive: true });
|
|
||||||
mkdirSync(outDir, { recursive: true });
|
|
||||||
|
|
||||||
log(`Building bundle "${bundleName}"`);
|
|
||||||
log(` git sha: ${sha}${dirty ? ' (dirty tree)' : ''}`);
|
|
||||||
log(` staging: ${bundleDir}`);
|
|
||||||
log(` output: ${zipPath}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
log('Copying agent sources into workspace/');
|
|
||||||
copyMany(AGENT_FILES, bundleDir);
|
|
||||||
log('Copying shared cisco-dect + digest-auth modules into workspace/');
|
|
||||||
copyMany(SHARED_FILES, bundleDir);
|
|
||||||
log('Copying deploy artifacts (Dockerfile, compose, .env.example)');
|
|
||||||
copyMany(DEPLOY_FILES, bundleDir);
|
|
||||||
|
|
||||||
log('Rendering deploy README.md + BUNDLE_INFO.txt');
|
|
||||||
writeFileSync(
|
|
||||||
path.join(bundleDir, 'README.md'),
|
|
||||||
renderBundleReadme({ version, sha, dirty }),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
writeFileSync(
|
|
||||||
path.join(bundleDir, 'BUNDLE_INFO.txt'),
|
|
||||||
[
|
|
||||||
`bundle: ${bundleName}`,
|
|
||||||
`built: ${new Date().toISOString()}`,
|
|
||||||
`commit: ${sha}${dirty ? ' (dirty)' : ''}`,
|
|
||||||
`staged bytes: ${humanBytes(dirSize(bundleDir))}`,
|
|
||||||
].join('\n') + '\n',
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
log(`Staged ${humanBytes(dirSize(bundleDir))} across ${AGENT_FILES.length + SHARED_FILES.length + DEPLOY_FILES.length + 2} files`);
|
|
||||||
|
|
||||||
if (existsSync(zipPath)) {
|
|
||||||
log(`Removing pre-existing ${zipName}`);
|
|
||||||
rmSync(zipPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
log('Creating zip archive (running `zip -r`)');
|
|
||||||
const zipResult = spawnSync(
|
|
||||||
'zip',
|
|
||||||
// -r recursive, -q quiet, -X strip extra attrs for smaller cross-OS zip.
|
|
||||||
// Run inside the staging root so paths inside the zip start with the
|
|
||||||
// bundle-name folder (rather than absolute-repo-path prefix).
|
|
||||||
['-r', '-q', '-X', zipPath, bundleName],
|
|
||||||
{ cwd: stagingRoot, stdio: 'inherit' },
|
|
||||||
);
|
|
||||||
if (zipResult.error) throw zipResult.error;
|
|
||||||
if (zipResult.status !== 0) {
|
|
||||||
throw new Error(`zip exited with code ${zipResult.status} — is the \`zip\` binary installed?`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// Always clean up the staging tree, even on failure — leaving a
|
|
||||||
// partial bundle around is confusing next run. The zip in dist/
|
|
||||||
// survives.
|
|
||||||
if (existsSync(stagingRoot)) rmSync(stagingRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalBytes = statSync(zipPath).size;
|
|
||||||
log('─────────────────────────────────────────────────────');
|
|
||||||
log(`✔ Bundle written: ${path.relative(REPO_ROOT, zipPath)}`);
|
|
||||||
log(` ${humanBytes(finalBytes)} on disk`);
|
|
||||||
log('');
|
|
||||||
log('Next steps:');
|
|
||||||
log(` 1. Transfer ${zipName} to the DC host (scp / rsync / etc.)`);
|
|
||||||
log(` 2. On the DC host: unzip ${zipName} && cd ${bundleName}`);
|
|
||||||
log(` 3. Fill in .env from .env.example`);
|
|
||||||
log(` 4. docker compose up -d --build`);
|
|
||||||
log(` 5. docker compose logs -f # look for "Connected — sending hello"`);
|
|
||||||
log('─────────────────────────────────────────────────────');
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => {
|
|
||||||
err(`FAILED: ${e.message}`);
|
|
||||||
if (process.env.DEBUG) err(e.stack || '');
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
Loading…
Reference in a new issue