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.
346 lines
14 KiB
JavaScript
Executable file
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);
|
|
});
|