initial commit
This commit is contained in:
commit
0eb9b55717
11 changed files with 3969 additions and 0 deletions
102
src/bank-strategy.js
Normal file
102
src/bank-strategy.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// Bank-selection helpers for MCMS ONU firmware upgrades.
|
||||
//
|
||||
// MCMS stores firmware in two slots on the ONU:
|
||||
// ONU.FW Bank Files: [slot 0 filename, slot 1 filename]
|
||||
// ONU.FW Bank Versions: [slot 0 version, slot 1 version]
|
||||
// ONU.FW Bank Ptr: 0 | 1 | 65535 (unset)
|
||||
//
|
||||
// FW Bank Ptr identifies the *active* bank. To avoid forcing a reboot mid-campaign,
|
||||
// we stage new firmware to the inactive bank. MCMS still flips the pointer after
|
||||
// a successful download, which triggers one reboot per ONU — but because we're
|
||||
// only committing to the *other* bank, the ONU boots into the new image on its
|
||||
// next natural reboot window. If you want to stage-only (no activation), clear
|
||||
// the new file from the target slot *after* the download completes; that mode
|
||||
// is out of scope for the initial tool and called out in the UI.
|
||||
|
||||
const UNSET_PTR = 65535;
|
||||
|
||||
/**
|
||||
* Given a current FW Bank Ptr, decide which slot we should write to so we're
|
||||
* not overwriting the active image.
|
||||
*
|
||||
* @param {number | undefined | null} activePtr Current ONU.FW Bank Ptr
|
||||
* @returns {0 | 1}
|
||||
*/
|
||||
function inactiveBank(activePtr) {
|
||||
if (activePtr === 0) return 1;
|
||||
if (activePtr === 1) return 0;
|
||||
// 65535 / null / undefined — ONU has never been upgraded via MCMS.
|
||||
// Slot 0 is the factory image location; writing to slot 1 keeps slot 0 as
|
||||
// a safety fallback, which matches Procedure 7's example in the dev guide.
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the version string in a given slot, tolerating sparse arrays that
|
||||
* MCMS returns when only one slot is populated.
|
||||
*/
|
||||
function versionInSlot(onuCfg, slot) {
|
||||
const versions = onuCfg?.ONU?.['FW Bank Versions'] || [];
|
||||
return versions[slot] || '';
|
||||
}
|
||||
|
||||
function filenameInSlot(onuCfg, slot) {
|
||||
const files = onuCfg?.ONU?.['FW Bank Files'] || [];
|
||||
return files[slot] || '';
|
||||
}
|
||||
|
||||
function activeBankPtr(onuCfg) {
|
||||
const ptr = onuCfg?.ONU?.['FW Bank Ptr'];
|
||||
return typeof ptr === 'number' ? ptr : UNSET_PTR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the mutation we'll apply to an ONU config to stage `targetFile`
|
||||
* (with `targetVersion`) into the inactive bank. Returns a new ONU-CFG
|
||||
* object with only the FW fields changed — caller should merge into the
|
||||
* existing document fetched via GET /v1/onus/configs/<id>/ before PUTting.
|
||||
*/
|
||||
function planUpgrade(onuCfg, { targetFile, targetVersion }) {
|
||||
const activePtr = activeBankPtr(onuCfg);
|
||||
const writeSlot = inactiveBank(activePtr);
|
||||
|
||||
// Preserve existing slot contents; only overwrite the slot we're targeting.
|
||||
const existingFiles = onuCfg?.ONU?.['FW Bank Files'] || ['', ''];
|
||||
const existingVersions = onuCfg?.ONU?.['FW Bank Versions'] || ['', ''];
|
||||
|
||||
const newFiles = [existingFiles[0] || '', existingFiles[1] || ''];
|
||||
const newVersions = [existingVersions[0] || '', existingVersions[1] || ''];
|
||||
newFiles[writeSlot] = targetFile;
|
||||
newVersions[writeSlot] = targetVersion;
|
||||
|
||||
return {
|
||||
writeSlot,
|
||||
activePtr,
|
||||
// MCMS triggers a download by writing the target slot's Files/Versions AND
|
||||
// flipping FW Bank Ptr to that slot. Per Procedure 7 in the dev guide,
|
||||
// setting FW Bank Ptr to the new slot is what initiates the upgrade.
|
||||
fwFields: {
|
||||
'FW Bank Files': newFiles,
|
||||
'FW Bank Versions': newVersions,
|
||||
'FW Bank Ptr': writeSlot,
|
||||
},
|
||||
// Human-readable summary for the dry-run preview.
|
||||
summary: {
|
||||
willWriteSlot: writeSlot,
|
||||
previousActiveSlot: activePtr === UNSET_PTR ? 'unset' : activePtr,
|
||||
currentVersionInSlot0: existingVersions[0] || '(empty)',
|
||||
currentVersionInSlot1: existingVersions[1] || '(empty)',
|
||||
targetVersion,
|
||||
targetFile,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
UNSET_PTR,
|
||||
inactiveBank,
|
||||
versionInSlot,
|
||||
filenameInSlot,
|
||||
activeBankPtr,
|
||||
planUpgrade,
|
||||
};
|
||||
648
src/mcms-api.js
Normal file
648
src/mcms-api.js
Normal file
|
|
@ -0,0 +1,648 @@
|
|||
// MCMS 6.2 REST API client.
|
||||
//
|
||||
// Sequence (from 323-1961-306, Chapter 3 "Request Sequence"):
|
||||
// 1. POST /api/v1/users/authenticate/ -> Set-Cookie: sessionid + csrftoken
|
||||
// 2. (optional) PUT /api/v1/databases/selection/
|
||||
// 3. GETs/PUTs with Cookie + X-CSRFToken + Referer headers
|
||||
// 4. GET /api/v1/users/logout/
|
||||
//
|
||||
// Notes:
|
||||
// - All HTTP paths are prefixed with `/api`. The dev guide's sequence
|
||||
// diagrams write "/v1/users/authenticate/" as shorthand, but the actual
|
||||
// HTTP route (see the curl examples in the same guide) is
|
||||
// "/api/v1/users/authenticate/". Paths below include the prefix.
|
||||
// - MCMS deployments commonly use self-signed certs on an internal IP. We
|
||||
// expose `rejectUnauthorized` as a per-session toggle rather than globally
|
||||
// neutering TLS.
|
||||
// - All PUT/POST bodies wrap the payload in a `data` envelope: { "data": {...} }.
|
||||
// - Response envelope is { "status": "success"|"fail"|..., "data"?: ..., "details"?: ... }.
|
||||
|
||||
const https = require('https');
|
||||
const { URL } = require('url');
|
||||
const { CookieJar } = require('tough-cookie');
|
||||
|
||||
// ---------- Time helpers ----------------------------------------------
|
||||
// MCMS emits and accepts timestamps as "YYYY-MM-DD HH:MM:SS[.ffffff]" in UTC.
|
||||
// (Example: ONU-STATE.Time, OLT alarm timestamps, AUTO-TASK Scheduled Start.)
|
||||
// We use the same format on outgoing query params to /onus/stats/.
|
||||
|
||||
function formatMcmsTime(d) {
|
||||
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 parseMcmsTime(s) {
|
||||
if (!s) return 0;
|
||||
// Accept "YYYY-MM-DD HH:MM:SS[.ffffff]" (UTC). Date() treats the space-form
|
||||
// as local in some Node versions, so coerce to ISO with a Z suffix.
|
||||
const iso = String(s).replace(' ', 'T') + (String(s).endsWith('Z') ? '' : 'Z');
|
||||
const t = Date.parse(iso);
|
||||
return Number.isFinite(t) ? t : 0;
|
||||
}
|
||||
|
||||
// ---------- ONU link-health reduction ---------------------------------
|
||||
// Reduce an ONU-STATE document to one of:
|
||||
// 'ok' | 'pre' | 'post' | 'buggy' | 'nodata'
|
||||
//
|
||||
// Field locations are based on observed traffic from MCMS 6.2 +
|
||||
// Interos Everest ONUs. The ONU summary endpoint (and the bulk
|
||||
// /v3/onus/states/ list) returns:
|
||||
//
|
||||
// STATE.STATS["ONU-PON"]["RX Pre-FEC BER"]
|
||||
// STATE.STATS["ONU-PON"]["RX Post-FEC BER"]
|
||||
// STATE.STATS["ONU-PON"]["RX Optical Level"] (dBm)
|
||||
// STATE.STATS["ONU-PON"]["TX Optical Level"] (dBm)
|
||||
// STATE.STATS["OLT-PON"]["RX Pre-FEC BER"] (upstream view)
|
||||
// STATE.STATS["OLT-PON"]["RX Post-FEC BER"]
|
||||
//
|
||||
// On at least one MCMS UI helper the document is wrapped as
|
||||
// `{ state_collection: { STATS: ... } }` — accept that shape too.
|
||||
//
|
||||
// Health rules (matching the user-guide green-LED criteria on p149
|
||||
// plus a sanity check for ONU firmware bugs):
|
||||
// ONU pre>0 AND post>0 AND post/pre >= 0.95 -> 'buggy'
|
||||
// Real FEC essentially always reduces errors. A working ONU
|
||||
// shows post=0 (or near-0) even with pre>0; if post is within
|
||||
// 5% of pre there's no correction happening. Jon observed an
|
||||
// example pair like 22.388e9/22.379e9 (post/pre = 0.9996) on
|
||||
// a GNXS Everest ONU, which is the firmware bug we want to
|
||||
// surface separately from a genuinely broken link.
|
||||
// ONU or OLT post-FEC > 0 -> 'post' (uncorrected errors)
|
||||
// ONU or OLT pre-FEC > 0 -> 'pre' (marginal link, FEC working)
|
||||
// All four counters present and zero -> 'ok'
|
||||
// No FEC counters found -> 'nodata'
|
||||
//
|
||||
// Returns:
|
||||
// { flag, counters: [{tail, value, kind, scope}], optical: {rx, tx},
|
||||
// detail }
|
||||
|
||||
const NEAR_IDENTICAL_RATIO = 0.95;
|
||||
|
||||
function extractOnuHealth(stateDoc) {
|
||||
if (!stateDoc || typeof stateDoc !== 'object') {
|
||||
return { flag: 'nodata', counters: [], optical: {} };
|
||||
}
|
||||
// Accept both the bare state doc and the `{ state_collection: {...} }`
|
||||
// wrapper exposed by /api/onu/summary.
|
||||
const stats =
|
||||
stateDoc.STATS ||
|
||||
stateDoc?.state_collection?.STATS ||
|
||||
null;
|
||||
if (!stats || typeof stats !== 'object') {
|
||||
return { flag: 'nodata', counters: [], optical: {} };
|
||||
}
|
||||
|
||||
const onuPon = stats['ONU-PON'] || {};
|
||||
const oltPon = stats['OLT-PON'] || {};
|
||||
|
||||
const onuPre = numOrNull(onuPon['RX Pre-FEC BER']);
|
||||
const onuPost = numOrNull(onuPon['RX Post-FEC BER']);
|
||||
const oltPre = numOrNull(oltPon['RX Pre-FEC BER']);
|
||||
const oltPost = numOrNull(oltPon['RX Post-FEC BER']);
|
||||
const rxOpt = numOrNull(onuPon['RX Optical Level']);
|
||||
const txOpt = numOrNull(onuPon['TX Optical Level']);
|
||||
|
||||
/** @type {{tail:string, value:number, kind:string, scope:string}[]} */
|
||||
const counters = [];
|
||||
if (onuPre !== null) counters.push({ tail: 'ONU RX Pre-FEC BER', value: onuPre, kind: 'pre', scope: 'onu' });
|
||||
if (onuPost !== null) counters.push({ tail: 'ONU RX Post-FEC BER', value: onuPost, kind: 'post', scope: 'onu' });
|
||||
if (oltPre !== null) counters.push({ tail: 'OLT RX Pre-FEC BER', value: oltPre, kind: 'pre', scope: 'olt' });
|
||||
if (oltPost !== null) counters.push({ tail: 'OLT RX Post-FEC BER', value: oltPost, kind: 'post', scope: 'olt' });
|
||||
|
||||
if (counters.length === 0) {
|
||||
return { flag: 'nodata', counters: [], optical: { rx: rxOpt, tx: txOpt } };
|
||||
}
|
||||
|
||||
// Buggy detector: ONU side only — that's the side that's known to
|
||||
// mis-report on Interos Everest ONUs. The OLT side is computed by
|
||||
// the OLT firmware which we trust.
|
||||
const buggy =
|
||||
onuPre !== null && onuPost !== null &&
|
||||
onuPre > 0 && onuPost > 0 &&
|
||||
onuPost / onuPre >= NEAR_IDENTICAL_RATIO;
|
||||
|
||||
const anyPostNonzero = (onuPost ?? 0) > 0 || (oltPost ?? 0) > 0;
|
||||
const anyPreNonzero = (onuPre ?? 0) > 0 || (oltPre ?? 0) > 0;
|
||||
|
||||
let flag;
|
||||
if (buggy) flag = 'buggy';
|
||||
else if (anyPostNonzero) flag = 'post';
|
||||
else if (anyPreNonzero) flag = 'pre';
|
||||
else flag = 'ok';
|
||||
|
||||
// Compact tooltip / CSV detail. Show all four counters with the
|
||||
// exact field names from the API so an operator can correlate
|
||||
// with the PON Manager UI by eye.
|
||||
const detail = counters
|
||||
.map((c) => `${c.tail}=${formatFecNumber(c.value)}`)
|
||||
.join('; ');
|
||||
|
||||
return { flag, counters, optical: { rx: rxOpt, tx: txOpt }, detail };
|
||||
}
|
||||
|
||||
// Backwards-compatible alias for any older callers / tests that still
|
||||
// reach for extractFecHealth. The new code path runs through
|
||||
// extractOnuHealth, but calling extractFecHealth on a raw STATS sub-
|
||||
// document still works because we look for a `STATS` wrapper first.
|
||||
function extractFecHealth(doc) {
|
||||
if (doc && typeof doc === 'object' && (doc.STATS || doc.state_collection)) {
|
||||
return extractOnuHealth(doc);
|
||||
}
|
||||
// Old callers passed a STATS sub-doc directly — wrap it.
|
||||
return extractOnuHealth({ STATS: doc });
|
||||
}
|
||||
|
||||
function numOrNull(v) {
|
||||
if (v === null || v === undefined) return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
if (typeof v === 'string' && /^-?[\d.]+(?:[eE][+-]?\d+)?$/.test(v)) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Format a number for the FEC display: keep integers as-is, render very
|
||||
// small or very large floats in scientific notation, otherwise show 3
|
||||
// significant digits. BER values are typically in the 1e-12 .. 1e-3 range
|
||||
// while error *counts* are integers — both should look readable.
|
||||
function formatFecNumber(n) {
|
||||
if (n === 0) return '0';
|
||||
const abs = Math.abs(n);
|
||||
if (Number.isInteger(n)) return String(n);
|
||||
if (abs < 0.01 || abs >= 1e6) return n.toExponential(2);
|
||||
return Number(n.toPrecision(3)).toString();
|
||||
}
|
||||
|
||||
class McmsApiError extends Error {
|
||||
constructor(message, { status, body } = {}) {
|
||||
super(message);
|
||||
this.name = 'McmsApiError';
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
class McmsClient {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.baseUrl e.g. "https://mcms.example.internal"
|
||||
* @param {boolean} [opts.rejectUnauthorized=true] set false for self-signed
|
||||
* @param {boolean} [opts.verbose=false] console.log each request + response
|
||||
*/
|
||||
constructor({ baseUrl, rejectUnauthorized = true, verbose = false }) {
|
||||
if (!baseUrl) throw new Error('baseUrl is required');
|
||||
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
||||
this.rejectUnauthorized = rejectUnauthorized;
|
||||
this.verbose = verbose;
|
||||
this.jar = new CookieJar();
|
||||
this.csrfToken = null;
|
||||
}
|
||||
|
||||
// --- Low-level request ------------------------------------------------
|
||||
|
||||
_resolvePath(path) {
|
||||
// MCMS exposes the REST API under /api/. The guide writes paths as
|
||||
// /v1/... but the actual HTTP route is /api/v1/... . Accept either and
|
||||
// normalize here so callers don't have to care.
|
||||
if (path.startsWith('/api/')) return path;
|
||||
if (path.startsWith('/')) return '/api' + path;
|
||||
return '/api/' + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a query string using percent-encoding that MCMS accepts. Node's
|
||||
* URLSearchParams encodes spaces as `+` (form-urlencoded), but MCMS's
|
||||
* URL parser requires `%20`. encodeURIComponent gives us `%20` for
|
||||
* spaces in both keys and values.
|
||||
*/
|
||||
_encodeQuery(params) {
|
||||
const parts = [];
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === '') continue;
|
||||
const val = typeof v === 'string' ? v : String(v);
|
||||
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(val)}`);
|
||||
}
|
||||
return parts.join('&');
|
||||
}
|
||||
|
||||
async _request(method, path, { body, contentType = 'application/json', query } = {}) {
|
||||
const resolvedPath = this._resolvePath(path);
|
||||
const qs = query ? this._encodeQuery(query) : '';
|
||||
const fullUrl = new URL(this.baseUrl + resolvedPath + (qs ? '?' + qs : ''));
|
||||
|
||||
const cookieHeader = await this.jar.getCookieString(fullUrl.toString());
|
||||
const headers = {
|
||||
'Accept': 'application/json',
|
||||
'Referer': this.baseUrl + '/',
|
||||
// Node's https.request sends no User-Agent by default. Some MCMS
|
||||
// middlewares (and third-party WAF layers) deref HTTP_USER_AGENT
|
||||
// without a guard and 500 when it's absent. Set an explicit UA so
|
||||
// we look like a normal HTTP client.
|
||||
'User-Agent': 'pon-fleet-upgrader/0.1.0',
|
||||
};
|
||||
if (cookieHeader) headers['Cookie'] = cookieHeader;
|
||||
if (this.csrfToken) headers['X-CSRFToken'] = this.csrfToken;
|
||||
|
||||
let payload;
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = contentType;
|
||||
payload = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
headers['Content-Length'] = Buffer.byteLength(payload).toString();
|
||||
}
|
||||
|
||||
if (this.verbose) {
|
||||
const cookieNames = cookieHeader
|
||||
? cookieHeader.split(';').map((c) => c.split('=')[0].trim()).join(',')
|
||||
: '(none)';
|
||||
console.log(
|
||||
`[mcms] -> ${method} ${fullUrl.pathname}${fullUrl.search} ` +
|
||||
`cookies=[${cookieNames}] csrf=${this.csrfToken ? 'yes' : 'no'}`,
|
||||
);
|
||||
// Dump all outgoing headers so we can diff against a working curl.
|
||||
// Cookie values are redacted — only names are interesting here.
|
||||
const safeHeaders = { ...headers };
|
||||
if (safeHeaders.Cookie) safeHeaders.Cookie = `<${cookieNames}>`;
|
||||
if (safeHeaders['X-CSRFToken']) safeHeaders['X-CSRFToken'] = '<redacted>';
|
||||
console.log('[mcms] headers:', safeHeaders);
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(
|
||||
{
|
||||
method,
|
||||
hostname: fullUrl.hostname,
|
||||
port: fullUrl.port || 443,
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
headers,
|
||||
rejectUnauthorized: this.rejectUnauthorized,
|
||||
timeout: 60000, // 60s — MCMS lists can be slow over a big fleet
|
||||
},
|
||||
async (res) => {
|
||||
// Record Set-Cookie headers in our jar.
|
||||
const setCookie = res.headers['set-cookie'] || [];
|
||||
for (const c of setCookie) {
|
||||
try {
|
||||
await this.jar.setCookie(c, fullUrl.toString());
|
||||
} catch (e) {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
// Capture CSRF token when it's refreshed. MCMS 6.2 sets the
|
||||
// cookie as `__Host-csrftoken` (RFC 6265bis __Host- prefix for
|
||||
// Secure + Path=/ cookies); older builds may use bare `csrftoken`.
|
||||
const jarCookies = await this.jar.getCookies(fullUrl.toString());
|
||||
const csrf = jarCookies.find(
|
||||
(c) => c.key === 'csrftoken' || c.key === '__Host-csrftoken',
|
||||
);
|
||||
if (csrf) this.csrfToken = csrf.value;
|
||||
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
parsed = raw;
|
||||
}
|
||||
if (this.verbose) {
|
||||
const ms = Date.now() - startedAt;
|
||||
const size = raw ? raw.length : 0;
|
||||
console.log(
|
||||
`[mcms] <- ${res.statusCode} ${method} ${fullUrl.pathname} ` +
|
||||
`(${ms}ms, ${size}B)`,
|
||||
);
|
||||
// On any non-2xx, dump both the headers and the raw body so
|
||||
// we can see what openresty/Apache/Django actually said. We
|
||||
// want the WHOLE body, not a 200-char truncation, because
|
||||
// proxy errors are often short HTML snippets that tell you
|
||||
// exactly which middleware rejected the request.
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
console.log('[mcms] response headers:', res.headers);
|
||||
console.log('[mcms] response body:', raw);
|
||||
}
|
||||
}
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve({ status: res.statusCode, body: parsed, headers: res.headers });
|
||||
} else {
|
||||
// Surface whatever the server put in the response — often
|
||||
// details.message or details.error describes the real cause
|
||||
// (bad credentials, stale CSRF, missing header, etc.).
|
||||
let detail = '';
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const d = parsed.details || parsed;
|
||||
if (typeof d === 'string') detail = d;
|
||||
else if (d.message) detail = d.message;
|
||||
else if (d.detail) detail = d.detail;
|
||||
else if (d.error) detail = typeof d.error === 'string' ? d.error : JSON.stringify(d.error);
|
||||
else detail = JSON.stringify(d).slice(0, 200);
|
||||
} else if (typeof parsed === 'string' && parsed) {
|
||||
detail = parsed.slice(0, 200);
|
||||
}
|
||||
const suffix = detail ? ` — ${detail}` : '';
|
||||
reject(
|
||||
new McmsApiError(
|
||||
`MCMS ${method} ${fullUrl.pathname} failed: HTTP ${res.statusCode}${suffix}`,
|
||||
{ status: res.statusCode, body: parsed },
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', (err) => {
|
||||
if (this.verbose) console.log(`[mcms] xx ${method} ${fullUrl.pathname} error:`, err?.message || err);
|
||||
reject(err);
|
||||
});
|
||||
req.on('timeout', () => {
|
||||
if (this.verbose) console.log(`[mcms] xx ${method} ${fullUrl.pathname} TIMEOUT after 60s`);
|
||||
req.destroy(new Error('Request timeout (60s)'));
|
||||
});
|
||||
if (payload) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Auth -------------------------------------------------------------
|
||||
|
||||
async login(username, password) {
|
||||
// The PON Manager web app POSTs { "data": { "email":..., "password":... } }
|
||||
// directly — no CSRF prime GET needed, and this is the body shape that
|
||||
// actually establishes a session on observed MCMS builds. If the server
|
||||
// rejects it with 400, try the dev-guide-documented unwrapped shape.
|
||||
const credentials = { email: username, password };
|
||||
try {
|
||||
const res = await this._request('POST', '/v1/users/authenticate/', {
|
||||
body: { data: credentials },
|
||||
});
|
||||
return res.body;
|
||||
} catch (e) {
|
||||
if (e instanceof McmsApiError && e.status === 400) {
|
||||
const res = await this._request('POST', '/v1/users/authenticate/', {
|
||||
body: credentials,
|
||||
});
|
||||
return res.body;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await this._request('GET', '/v1/users/logout/');
|
||||
} catch (_) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
async selectDatabase(databaseId) {
|
||||
return this._request('PUT', '/v1/databases/selection/', {
|
||||
body: { data: databaseId },
|
||||
});
|
||||
}
|
||||
|
||||
// --- ONUs -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List ONU configurations with an optional server-side Mongo-style filter.
|
||||
* MCMS supports query/projection/sort/limit URL params; we pass them
|
||||
* through as-is (the server JSON-parses them).
|
||||
*/
|
||||
async listOnuConfigs({ query, projection, sort, limit, skip } = {}) {
|
||||
const res = await this._request('GET', '/v1/onus/configs/', {
|
||||
query: { query, projection, sort, limit, skip },
|
||||
});
|
||||
return res.body?.data || [];
|
||||
}
|
||||
|
||||
async getOnuConfig(onuId) {
|
||||
const res = await this._request('GET', `/v1/onus/configs/${encodeURIComponent(onuId)}/`);
|
||||
return res.body?.data;
|
||||
}
|
||||
|
||||
async listOnuStates({ query, projection, limit } = {}) {
|
||||
const res = await this._request('GET', '/v1/onus/states/', {
|
||||
query: { query, projection, limit },
|
||||
});
|
||||
return res.body?.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginate a list endpoint using the `next` cursor (not `skip` — `skip`
|
||||
* has been observed 500'ing on this MCMS build). Each page passes the
|
||||
* last document's _id as `next`, which excludes all IDs up to and
|
||||
* including that value.
|
||||
*/
|
||||
async _listPaginatedByNext(path, { pageSize = 1000, maxTotal = 100000 } = {}) {
|
||||
const all = [];
|
||||
let next;
|
||||
for (;;) {
|
||||
const query = { limit: pageSize };
|
||||
if (next) query.next = next;
|
||||
const res = await this._request('GET', path, { query });
|
||||
const batch = Array.isArray(res.body?.data) ? res.body.data : [];
|
||||
if (batch.length === 0) break;
|
||||
all.push(...batch);
|
||||
if (batch.length < pageSize) break;
|
||||
next = batch[batch.length - 1]?._id;
|
||||
if (!next) break;
|
||||
if (all.length >= maxTotal) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull ALL ONU-CFG documents using `next`-cursor pagination at a small
|
||||
* page size. Large single-page fetches have been observed timing out
|
||||
* at Apache (before Django logs the request) on ~1500-ONU fleets
|
||||
* because full ONU-CFG docs are ~30KB each. 100/page keeps each
|
||||
* response under ~3MB and well under the proxy timeout.
|
||||
*/
|
||||
async listAllOnuConfigs({ pageSize = 100 } = {}) {
|
||||
return this._listPaginatedByNext('/v3/onus/configs/', { pageSize });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single ONU-STATE document. Uses v3 (the only path observed
|
||||
* working on this MCMS build for per-ONU state).
|
||||
*/
|
||||
async getOnuState(onuId) {
|
||||
try {
|
||||
const res = await this._request('GET', `/v3/onus/states/${encodeURIComponent(onuId)}/`);
|
||||
return res.body?.data;
|
||||
} catch (e) {
|
||||
if (e instanceof McmsApiError && e.status === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-fetch ALL ONU-STATE documents. Same pagination strategy as
|
||||
* configs — page size 100 via `next` cursor keeps each response
|
||||
* small enough for the proxy not to time out.
|
||||
*/
|
||||
async listAllOnuStates({ pageSize = 100 } = {}) {
|
||||
return this._listPaginatedByNext('/v3/onus/states/', { pageSize });
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an ONU-CFG document. The caller supplies the full document
|
||||
* (typically fetched, mutated, then passed here) — MCMS does not accept
|
||||
* partial updates on this endpoint.
|
||||
*/
|
||||
async putOnuConfig(onuId, fullDoc) {
|
||||
const res = await this._request('PUT', `/v1/onus/configs/${encodeURIComponent(onuId)}/`, {
|
||||
body: { data: fullDoc },
|
||||
});
|
||||
return res.body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an ONU-CFG document. This is what Procedure 43 "Deleting an ONU"
|
||||
* in the PON Manager User Guide does. The STATE document may linger until
|
||||
* its own TTL — call `deleteOnuState` too if you want a clean sweep.
|
||||
*/
|
||||
async deleteOnuConfig(onuId) {
|
||||
const res = await this._request('DELETE', `/v1/onus/configs/${encodeURIComponent(onuId)}/`);
|
||||
return res.body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the ONU-STATE document. Best-effort — the OLT will rewrite it
|
||||
* if the ONU comes back online, so this is only useful in combination
|
||||
* with deleting the CFG (or if you know the ONU is physically gone).
|
||||
*/
|
||||
async deleteOnuState(onuId) {
|
||||
const res = await this._request('DELETE', `/v1/onus/states/${encodeURIComponent(onuId)}/`);
|
||||
return res.body;
|
||||
}
|
||||
|
||||
// --- OLTs (for ONU registration status) ------------------------------
|
||||
|
||||
/**
|
||||
* OLT-STATE documents carry an `ONU States` field that buckets every
|
||||
* known ONU by its current registration bucket:
|
||||
* Registered | Deregistered | Dying Gasp | Disabled |
|
||||
* Disallowed Admin | Disallowed Error | Disallowed Reg ID |
|
||||
* Unspecified | Unprovisioned
|
||||
* See 323-1961-306 Procedure 2 ("Determining the registration status of
|
||||
* an ONU"). The PON Manager web UI uses this same data to label ONUs.
|
||||
*/
|
||||
async listAllOltStates({ pageSize = 100 } = {}) {
|
||||
return this._listPaginatedByNext('/v3/olts/states/', { pageSize });
|
||||
}
|
||||
|
||||
// --- Firmware inventory ----------------------------------------------
|
||||
|
||||
async listOnuFirmware() {
|
||||
const res = await this._request('GET', '/v1/files/onu-firmware/');
|
||||
return res.body?.data || [];
|
||||
}
|
||||
|
||||
async uploadOnuFirmware(filename, base64Contents, metadata) {
|
||||
const res = await this._request(
|
||||
'POST',
|
||||
`/v1/files/onu-firmware/${encodeURIComponent(filename)}/`,
|
||||
{
|
||||
body: {
|
||||
data: {
|
||||
file: base64Contents,
|
||||
metadata: metadata || {},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
return res.body;
|
||||
}
|
||||
|
||||
// --- Bulk upgrade task (Procedure 8) ---------------------------------
|
||||
|
||||
/**
|
||||
* Create or replace an AUTO-TASK-CFG that schedules a firmware upgrade
|
||||
* across many ONUs. MCMS owns retries/staging once this is written.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.taskId Unique task ID (caller-generated).
|
||||
* @param {string[]} opts.serials ONU serial numbers.
|
||||
* @param {string} opts.scheduledStart UTC, "YYYY-MM-DD HH:MM:SS"
|
||||
* @param {number} opts.fwBankPtr 0 | 1 — target (inactive) bank.
|
||||
* @param {string} opts.targetFile Filename as it appears in onu-firmware.
|
||||
* @param {string} opts.targetVersion Matching version string.
|
||||
* @param {string} [opts.companionFile] Existing file name for the *other* slot
|
||||
* (so we don't blank it by sending []).
|
||||
* @param {string} [opts.companionVersion]
|
||||
*/
|
||||
async createFirmwareTask({
|
||||
taskId,
|
||||
serials,
|
||||
scheduledStart,
|
||||
fwBankPtr,
|
||||
targetFile,
|
||||
targetVersion,
|
||||
companionFile = '',
|
||||
companionVersion = '',
|
||||
}) {
|
||||
const files = ['', ''];
|
||||
const versions = ['', ''];
|
||||
files[fwBankPtr] = targetFile;
|
||||
versions[fwBankPtr] = targetVersion;
|
||||
const otherSlot = fwBankPtr === 0 ? 1 : 0;
|
||||
files[otherSlot] = companionFile;
|
||||
versions[otherSlot] = companionVersion;
|
||||
|
||||
const doc = {
|
||||
_id: taskId,
|
||||
Task: {
|
||||
'Device Type': 'ONU',
|
||||
'Operation': 'Firmware Upgrade',
|
||||
'Scheduled Start Time': scheduledStart,
|
||||
},
|
||||
'Task Details': {
|
||||
Devices: serials,
|
||||
ONU: {
|
||||
'FW Bank Ptr': fwBankPtr,
|
||||
'FW Bank Files': { 0: files[0], 1: files[1] },
|
||||
'FW Bank Versions': { 0: versions[0], 1: versions[1] },
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = await this._request('PUT', `/v3/tasks/configs/${encodeURIComponent(taskId)}/`, {
|
||||
body: { data: doc },
|
||||
});
|
||||
return res.body;
|
||||
}
|
||||
|
||||
async getTaskConfig(taskId) {
|
||||
const res = await this._request('GET', `/v3/tasks/configs/${encodeURIComponent(taskId)}/`);
|
||||
return res.body?.data;
|
||||
}
|
||||
|
||||
// --- Per-ONU upgrade status ------------------------------------------
|
||||
|
||||
/**
|
||||
* The status endpoint the user pasted in the original spec. Path differs
|
||||
* from /v1/... — it's a helper MCMS exposes for polling an in-flight
|
||||
* download. If the deployment doesn't expose this shape, prefer polling
|
||||
* the ONU-CFG document to watch FW Bank Versions/Ptr flip.
|
||||
*/
|
||||
async getOnuUpgradeStatus(onuId) {
|
||||
const res = await this._request('GET', `/v1/onus/${encodeURIComponent(onuId)}/upgrade/status/`);
|
||||
return res.body?.data || res.body;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
McmsClient,
|
||||
McmsApiError,
|
||||
formatMcmsTime,
|
||||
parseMcmsTime,
|
||||
extractOnuHealth,
|
||||
extractFecHealth, // legacy alias — calls extractOnuHealth
|
||||
formatFecNumber,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue