Package dect-relay-agent as a Docker deploy bundle
Adds a one-command packager (`npm run package:relay`) that produces a
self-contained zip ready to transfer into the data center and start
with `docker compose up -d --build`. Three commands on the DC host:
unzip, edit .env, docker compose up.
Why a packager instead of `docker build` in the repo:
The agent's index.js imports the shared cisco-dect + httpDigestAuth
modules via `../integrations/...` paths, so a naive
`docker build dect-relay-agent/` would fail because those files live
outside the build context. The packager copies them into a
`workspace/` tree inside the bundle so the Dockerfile sees them as
local paths without any source rewriting.
Docker artifacts (in dect-relay-agent/):
- Dockerfile: multi-stage node:20-alpine build (~55MB final image),
non-root `dect` user (UID/GID 1500), tini as PID 1 for clean
SIGTERM propagation to node's graceful-shutdown path,
`npm install --omit=dev --ignore-scripts` in the deps stage.
- docker-compose.yml: restart:unless-stopped, JSON log rotation
(10MB × 5 files), pgrep-based health check. No `ports:` block
because the agent is outbound-only (dials the bot).
- .dockerignore: defensive — the bundle already excludes cruft, but
this hardens against a stray manual build.
Packager (scripts/packageDectRelayAgent.js):
- Assembles agent code + shared modules + deploy artifacts into a
timestamped staging dir (.package-relay-tmp/, git-ignored).
- Generates a bundle README with three-command deploy instructions,
ongoing-ops table, no-internet-DC fallback (docker save/load), and
troubleshooting for the most common failure modes.
- Generates BUNDLE_INFO.txt with build metadata (git sha + dirty
flag + timestamp + size) so the DC operator can trace deployed
bundles back to source.
- Emits `dist/dect-relay-agent-bundle-<YYYYMMDD-HHMMSS>.zip` (30KB).
- Cleans staging in a finally block so failed runs don't leak.
Bundle layout (matches Dockerfile expectations):
dect-relay-agent-bundle-<version>/
Dockerfile, docker-compose.yml, .dockerignore
.env.example, README.md, BUNDLE_INFO.txt
workspace/dect-relay-agent/{package.json, index.js}
workspace/integrations/cisco-dect/{client,probes,statusXml}.js
workspace/utils/httpDigestAuth.js
Wiring:
- package.json: new `package:relay` and `test` npm scripts.
- .gitignore: `scripts/` changed to `scripts/*` so `!scripts/
packageDectRelayAgent.js` can re-include just the packager
(git forbids re-including files under a fully-excluded directory,
hence the glob form).
- dect-relay-agent/README.md: rewrites deployment section to show
the Docker path as the recommended production route, with the
node-directly path kept for local dev.
Verified end-to-end: `npm run package:relay` produces a valid zip
that unpacks to the expected layout in <2s. All 113 existing tests
still pass.
This commit is contained in:
parent
96b26a5aca
commit
e8500b4324
7 changed files with 555 additions and 19 deletions
16
.gitignore
vendored
16
.gitignore
vendored
|
|
@ -26,7 +26,16 @@ storage/
|
|||
|
||||
# Dev / test artifacts (local only)
|
||||
characterization-runs/
|
||||
scripts/
|
||||
# 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
|
||||
characterize-*.js
|
||||
|
||||
# Backup & temp files
|
||||
|
|
@ -48,6 +57,11 @@ 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/
|
||||
|
||||
# Docker / Misc
|
||||
docker-compose.override.yml
|
||||
|
|
|
|||
20
dect-relay-agent/.dockerignore
Normal file
20
dect-relay-agent/.dockerignore
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# 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
|
||||
78
dect-relay-agent/Dockerfile
Normal file
78
dect-relay-agent/Dockerfile
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# syntax=docker/dockerfile:1.6
|
||||
#
|
||||
# DECT Relay Agent — production container 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:
|
||||
#
|
||||
# workspace/
|
||||
# dect-relay-agent/ ← WORKDIR at runtime
|
||||
# package.json
|
||||
# index.js
|
||||
# integrations/cisco-dect/{client,probes,statusXml}.js
|
||||
# utils/httpDigestAuth.js
|
||||
#
|
||||
# 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.
|
||||
|
||||
# ── 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
|
||||
|
||||
# Only copy the agent's manifest first so this layer caches across
|
||||
# code-only changes.
|
||||
COPY workspace/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.
|
||||
RUN npm install --omit=dev --ignore-scripts \
|
||||
&& npm cache clean --force
|
||||
|
||||
# ── Stage 2: runtime ────────────────────────────────────────────────
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 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/ ./
|
||||
|
||||
# Bring in the deps that stage 1 resolved.
|
||||
COPY --from=deps --chown=dect:dect /build/node_modules ./dect-relay-agent/node_modules
|
||||
|
||||
USER dect
|
||||
WORKDIR /app/dect-relay-agent
|
||||
|
||||
# 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"]
|
||||
|
|
@ -15,7 +15,25 @@ Only one agent is expected to run at a time. If a second agent connects, the bot
|
|||
- Route from the agent host to the bot's public HTTPS endpoint
|
||||
- The DECT serviceability password (Control Hub → Calling → Features → DECT Networks → Manage → Manage DECT serviceability password)
|
||||
|
||||
## Install
|
||||
## Deploy paths
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
# From the parent repo root:
|
||||
npm run package:relay
|
||||
# → writes dist/dect-relay-agent-bundle-<YYYYMMDD-HHMMSS>.zip
|
||||
```
|
||||
|
||||
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`).
|
||||
|
||||
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.
|
||||
|
||||
### 2. Direct node process (dev + local iteration)
|
||||
|
||||
```bash
|
||||
cd dect-relay-agent
|
||||
|
|
@ -106,21 +124,16 @@ The bot terminates the socket if no `pong` arrives within 90s; the agent auto-re
|
|||
- The agent enforces no policy — the bot decides who can reboot what. See the bot's audit log for the full record of actions taken (`igmp:audit` style scopes in daily log files).
|
||||
- The agent quarantines mutating actions from probes via the exact same safety model as the CLI tool (`integrations/cisco-dect/probes.js` — GET-triggered actions are only reachable via explicit `trigger*` helpers, never via a generic path fetcher).
|
||||
|
||||
## Deploying as a container
|
||||
## What's in this folder
|
||||
|
||||
A `Dockerfile` isn't included yet — production deployment shape is TBD. Minimum viable:
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `index.js` | Agent entrypoint — WSS client + command dispatcher |
|
||||
| `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
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
# The agent imports from ../integrations/cisco-dect/, so copy the
|
||||
# whole workspace (or at least these two paths).
|
||||
COPY package.json package-lock.json ./
|
||||
COPY dect-relay-agent ./dect-relay-agent
|
||||
COPY integrations/cisco-dect ./integrations/cisco-dect
|
||||
COPY utils/httpDigestAuth.js ./utils/httpDigestAuth.js
|
||||
RUN cd dect-relay-agent && npm ci --omit=dev
|
||||
CMD ["node", "dect-relay-agent/index.js"]
|
||||
```
|
||||
|
||||
Set the env vars from `.env.example` via your orchestrator's secret store, not baked into the image.
|
||||
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.
|
||||
|
|
|
|||
63
dect-relay-agent/docker-compose.yml
Normal file
63
dect-relay-agent/docker-compose.yml
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# DECT Relay Agent — one-command deploy for the data center.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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)
|
||||
|
||||
services:
|
||||
dect-relay-agent:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: dect-relay-agent:latest
|
||||
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.
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
# No `ports:` block on purpose — see header comment.
|
||||
|
||||
# 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.
|
||||
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
|
||||
|
||||
# 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
|
||||
|
|
@ -13,7 +13,9 @@
|
|||
"docker:build": "docker compose build",
|
||||
"docker:up": "docker compose up -d",
|
||||
"docker:down": "docker compose down",
|
||||
"docker:logs": "docker compose logs -f"
|
||||
"docker:logs": "docker compose logs -f",
|
||||
"package:relay": "node scripts/packageDectRelayAgent.js",
|
||||
"test": "node --test tests/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"async-mutex": "^0.5.0",
|
||||
|
|
|
|||
346
scripts/packageDectRelayAgent.js
Executable file
346
scripts/packageDectRelayAgent.js
Executable file
|
|
@ -0,0 +1,346 @@
|
|||
#!/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