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)
|
||||
characterization-runs/
|
||||
# NOTE on ordering: `scripts/*` (glob) rather than `scripts/` (dir
|
||||
# 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
|
||||
scripts/
|
||||
characterize-*.js
|
||||
|
||||
# Backup & temp files
|
||||
|
|
@ -57,11 +48,12 @@ dist/
|
|||
build/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
# Temporary staging dir created by scripts/packageDectRelayAgent.js.
|
||||
# The script cleans this up in a finally block, but a SIGKILL can
|
||||
# leave it behind. Ignoring means an interrupted run doesn't leak
|
||||
# staged files into future git commits.
|
||||
.package-relay-tmp/
|
||||
|
||||
# DECT relay agent deploy bundles produced by dect-relay-agent/bundle.sh.
|
||||
# The datestamped zip lands at repo root and shouldn't be committed —
|
||||
# it's ~40MB (Docker image tarball) and rebuildable on demand.
|
||||
dect-relay-agent-bundle-*.zip
|
||||
dect-relay-agent-bundle-*/
|
||||
|
||||
# Docker / Misc
|
||||
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
|
||||
#
|
||||
# DECT Relay Agent — production container image
|
||||
# DECT relay agent — production image.
|
||||
#
|
||||
# Build context: the bundle produced by scripts/packageDectRelayAgent.js.
|
||||
# The bundle contains a `workspace/` directory that mirrors just enough of
|
||||
# the parent repo to satisfy the agent's `../integrations/...` and
|
||||
# `../utils/...` imports without any source rewriting:
|
||||
# BUILD CONTEXT: the REPO ROOT (not this folder). The agent imports
|
||||
# `../integrations/cisco-dect/*` and `../utils/httpDigestAuth.js`, so
|
||||
# we mirror the repo's layout under /workspace/ inside the image and
|
||||
# the relative paths just work.
|
||||
#
|
||||
# workspace/
|
||||
# dect-relay-agent/ ← WORKDIR at runtime
|
||||
# package.json
|
||||
# index.js
|
||||
# integrations/cisco-dect/{client,probes,statusXml}.js
|
||||
# utils/httpDigestAuth.js
|
||||
# BUILD FROM REPO ROOT:
|
||||
# docker build \
|
||||
# --platform=linux/amd64 \
|
||||
# -f dect-relay-agent/Dockerfile \
|
||||
# -t collabsupport/dect-relay-agent:0.1.0 \
|
||||
# .
|
||||
#
|
||||
# Building this Dockerfile in the raw repo (`docker build dect-relay-agent/`)
|
||||
# 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
|
||||
# packager.
|
||||
# Or use bundle.sh which wraps this + `docker save` + zip.
|
||||
#
|
||||
# WHY NO `apk add`: corporate DCs commonly TLS-intercept HTTPS. Alpine's
|
||||
# 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 ──────────────────────────────────────
|
||||
# node:20-alpine keeps the final image ~55MB. Alpine's musl libc has
|
||||
# been fine for this agent's plain JS + axios + ws footprint (no
|
||||
# native modules) but if you ever add one that needs glibc, switch to
|
||||
# node:20-slim.
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /build
|
||||
# ─── Stage 1: builder ────────────────────────────────────────────────
|
||||
# Installs prod deps in a full node image (has python/build-essentials
|
||||
# just in case a native module needs building — currently `ws` ships
|
||||
# pre-built optional deps for common arches but we keep the option
|
||||
# open for future deps).
|
||||
|
||||
# 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.
|
||||
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
|
||||
# (the agent has three dependencies; every deploy resolving the same
|
||||
# `^` ranges is acceptable for this operational tool). Add
|
||||
# --ignore-scripts to refuse arbitrary lifecycle-script execution from
|
||||
# the registry — none of our current deps use lifecycle scripts.
|
||||
# Install only production deps. --ignore-scripts because we don't run
|
||||
# arbitrary postinstall from transitive deps in the container build;
|
||||
# any needed build steps are pinned in this Dockerfile.
|
||||
RUN npm install --omit=dev --ignore-scripts \
|
||||
&& npm cache clean --force
|
||||
|
||||
# ── Stage 2: runtime ────────────────────────────────────────────────
|
||||
# ─── Stage 2: runtime ────────────────────────────────────────────────
|
||||
# Same base as builder, but only the artifacts we actually need at
|
||||
# run time (node_modules + agent source + shared integrations + utils).
|
||||
|
||||
FROM node:20-alpine AS runtime
|
||||
|
||||
# tini gives us proper PID-1 signal handling (SIGTERM propagates
|
||||
# cleanly to node so our graceful shutdown path in index.js actually
|
||||
# runs on `docker stop`).
|
||||
RUN apk add --no-cache tini
|
||||
# node:20-alpine ships a `node` user (uid 1000) that we can just use —
|
||||
# no need to install anything extra. Running as a non-root user is a
|
||||
# baseline hardening we get essentially for free.
|
||||
USER node
|
||||
|
||||
# Non-root user. UID/GID pinned so bind-mounted volumes (if any) are
|
||||
# predictable across hosts.
|
||||
RUN addgroup -S -g 1500 dect \
|
||||
&& adduser -S -u 1500 -G dect -H -s /sbin/nologin dect
|
||||
# Match the repo layout so relative imports (`../integrations/...`)
|
||||
# resolve exactly as they do in development.
|
||||
WORKDIR /workspace/dect-relay-agent
|
||||
|
||||
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
|
||||
# `../integrations/...` and `../utils/...` resolve exactly as they do
|
||||
# in the source repo. See the bundle layout comment at the top of
|
||||
# this file.
|
||||
COPY --chown=dect:dect workspace/ ./
|
||||
# Agent source + manifest.
|
||||
COPY --chown=node:node dect-relay-agent/package.json ./package.json
|
||||
COPY --chown=node:node dect-relay-agent/index.js ./index.js
|
||||
|
||||
# Bring in the deps that stage 1 resolved.
|
||||
COPY --from=deps --chown=dect:dect /build/node_modules ./dect-relay-agent/node_modules
|
||||
# Shared modules the agent imports from the parent workspace.
|
||||
COPY --chown=node:node integrations/cisco-dect /workspace/integrations/cisco-dect
|
||||
COPY --chown=node:node utils/httpDigestAuth.js /workspace/utils/httpDigestAuth.js
|
||||
|
||||
USER dect
|
||||
WORKDIR /app/dect-relay-agent
|
||||
# Optional metadata that shows up in `docker inspect` output — useful
|
||||
# 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
|
||||
# `docker run --env-file ...`. Never bake secrets into the image.
|
||||
# The agent process itself validates required vars and exits 1 if any
|
||||
# are missing (see assertConfig() in 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"]
|
||||
# Node handles SIGTERM natively when the process installs handlers
|
||||
# (which we do in index.js). --enable-source-maps improves stack
|
||||
# traces if something crashes at runtime — cheap and always-on.
|
||||
CMD ["node", "--enable-source-maps", "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)
|
||||
|
||||
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
|
||||
# From the parent repo root:
|
||||
npm run package:relay
|
||||
# → writes dist/dect-relay-agent-bundle-<YYYYMMDD-HHMMSS>.zip
|
||||
# From the repo root — script is self-locating:
|
||||
./dect-relay-agent/bundle.sh
|
||||
# → 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)
|
||||
|
||||
|
|
@ -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` |
|
||||
| `.env.example` | Annotated env template |
|
||||
| `README.md` | This file |
|
||||
| `Dockerfile` | Multi-stage alpine build used by the deploy bundle |
|
||||
| `docker-compose.yml` | One-command deploy on the DC host |
|
||||
| `.dockerignore` | Defensive; the packager already excludes cruft |
|
||||
| `Dockerfile` | Multi-stage Alpine build. No `apk add`, no runtime `npm install`. |
|
||||
| `Dockerfile.dockerignore` | Per-Dockerfile ignore (BuildKit ≥ 23.0). Whitelist-based; keeps build context ~50KB. |
|
||||
| `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:
|
||||
# 1. Unzip the bundle produced by scripts/packageDectRelayAgent.js.
|
||||
# 2. `cp .env.example .env` and fill it in (bot URL, shared token,
|
||||
# DECT serviceability password). The .env file lives NEXT TO
|
||||
# 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.
|
||||
# This compose file assumes the image has already been loaded from
|
||||
# the shipped tarball (see install.sh: `docker load < image.tar.gz`).
|
||||
# It does NOT build — build happens on the dev machine via bundle.sh
|
||||
# so no npm-registry or Alpine-CDN traffic is needed inside the DC.
|
||||
#
|
||||
# The agent is an OUTBOUND client (it dials the bot at
|
||||
# DECT_RELAY_BOT_URL). No ports are exposed and no inbound firewall
|
||||
# rules are needed on the DC host — only egress to:
|
||||
# - the bot's public HTTPS/WSS URL
|
||||
# - every DBS-210 base station on 10.0.0.0/8 (TCP 443)
|
||||
# Usage in the DC:
|
||||
# 1. cp .env.example .env && $EDITOR .env
|
||||
# 2. ./install.sh
|
||||
# (or manually: docker load < image.tar.gz && docker compose up -d)
|
||||
# 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:
|
||||
dect-relay-agent:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: dect-relay-agent:latest
|
||||
# Tag comes from the image tarball shipped in the bundle; install.sh
|
||||
# sets IMAGE_TAG in .env to whatever was baked in. Never falls back
|
||||
# to :latest — that would silently swap in whatever's cached on the
|
||||
# DC host if the tarball didn't load correctly.
|
||||
image: ${IMAGE_TAG:-collabsupport/dect-relay-agent:0.1.0}
|
||||
container_name: dect-relay-agent
|
||||
|
||||
# Restart on any exit (crash, host reboot, `docker stop` doesn't
|
||||
# count). Matches how the rest of the collabSupport stack is run.
|
||||
restart: unless-stopped
|
||||
|
||||
# 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.
|
||||
# Read all config (bot URL, shared bearer, DBS-210 admin creds)
|
||||
# from the operator's .env in this same directory. Compose does
|
||||
# NOT auto-load .env into the container by default — env_file
|
||||
# is the explicit opt-in.
|
||||
env_file:
|
||||
- .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.
|
||||
# 10MB * 5 files = 50MB per container is plenty for
|
||||
# troubleshooting a week's worth of activity at info level.
|
||||
# Host networking so the agent can reach 10.x/8 without needing
|
||||
# docker userland proxy translation. The agent doesn't LISTEN on
|
||||
# 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:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
# Health check hits the agent's own process. Since the agent
|
||||
# doesn't expose an HTTP port, we probe by looking for the node
|
||||
# process — sufficient to catch crashes that the restart policy
|
||||
# will then fix. A future v2 could expose a tiny /healthz on a
|
||||
# localhost-only port with connection-state details.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep -f 'node index.js' > /dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
# Read-only root filesystem + a small writable /tmp. The agent
|
||||
# writes nothing to disk (all logs go to stdout / stderr), so
|
||||
# this is essentially free defense-in-depth.
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16M
|
||||
|
||||
# Optional: uncomment if your DC network requires host networking
|
||||
# for the agent to reach the 10.x bases (e.g. because a routed
|
||||
# docker bridge isn't set up). Adding host mode means the agent
|
||||
# inherits the host's routing table and IP stack directly.
|
||||
# network_mode: host
|
||||
# Minimal capabilities — the agent is just outbound HTTP client
|
||||
# traffic, no need for NET_RAW / SYS_ADMIN / etc.
|
||||
cap_drop:
|
||||
- ALL
|
||||
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:down": "docker compose down",
|
||||
"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"
|
||||
},
|
||||
"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