collabSupport/scripts/packageDectRelayAgent.js
Joseph McQueen e8500b4324 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.
2026-07-03 09:32:26 -04:00

346 lines
14 KiB
JavaScript
Executable file

#!/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);
});