Add scripts/reclaimWebexHosts.js — bulk host license reclaim

Reads a Control Hub "Meetings Inactive Users" CSV, filters to
IS_HOST=Y AND DAYS_SINCE_LAST_ACTIVE > --min-days (default 120),
cross-references against the current holders of --host-license-id
(so no per-user /people lookup), then PATCHes /v1/licenses/users to
atomically remove the host license and either (a) add a specific
free-tier license (--free-license-id) or (b) add attendee-only
siteUrl on --site (--free-attendee).

Dry-run by default; enumerates every license on the site so the
operator can pick the free tier. Bounded concurrency with 429/503
retry, optional --offset/--limit for staged rollouts, per-user
outcome CSV via --report, and full audit trail via the existing
webex:reclaim:audit log scope.

Whitelisted in .gitignore so it stays version-controlled alongside
the other tracked operational scripts.
This commit is contained in:
Joseph McQueen 2026-07-07 15:02:04 -04:00
parent 59460b849b
commit c996d5d32e
2 changed files with 530 additions and 1 deletions

4
.gitignore vendored
View file

@ -26,7 +26,9 @@ storage/
# Dev / test artifacts (local only) # Dev / test artifacts (local only)
characterization-runs/ characterization-runs/
scripts/ scripts/*
# Tracked operational scripts (whitelisted; keep local dev helpers ignored above)
!scripts/reclaimWebexHosts.js
characterize-*.js characterize-*.js
# Backup & temp files # Backup & temp files

View file

@ -0,0 +1,527 @@
#!/usr/bin/env node
/**
* Reclaim Webex Meetings host licenses from long-inactive users.
*
* Feeds off a Control Hub "Meetings Inactive Users" report CSV
* (Analyzer Meetings Inactive Users, Export CSV). For every row
* that is currently marked as a host (IS_HOST=Y) AND has been inactive
* for more than `--min-days` days, we PATCH `/v1/licenses/users` to:
* 1. remove the configured host license on the target site, and
* 2. atomically add either a specific "free tier" license, or
* an attendee-only siteUrl on the site (accountType=attendee).
* Both operations go in one PATCH so the user is never briefly
* license-less.
*
* Detection is authoritative: we fetch the assignee roster of the host
* license once up-front (paginated) and cross-reference the CSV emails
* against it. Anyone in the CSV who isn't currently a holder is skipped
* silently no per-user /people search needed, and the personId comes
* straight off the assignee record.
*
* DRY-RUN by default. Nothing mutates without `--execute`. In dry-run,
* the script lists every license on the site so you can pick the
* `--free-license-id` (or decide to use `--free-attendee` instead).
*
* Usage:
* node scripts/reclaimWebexHosts.js \
* --csv "/path/to/Meetings Inactive Users_....csv" \
* [--site aeo2go.webex.com] \
* [--min-days 120] \
* [--host-license-id <id>] \
* [--free-license-id <id> | --free-attendee] \
* [--concurrency 5] \
* [--limit N] [--offset N] \
* [--report reclaim-report.csv] \
* [--execute]
*
* Environment defaults (read from .env):
* WEBEX_HOST_SITE_URL --site (default aeo2go.webex.com)
* WEBEX_HOST_LICENSE_ID --host-license-id
* WEBEX_FREE_LICENSE_ID --free-license-id (optional)
*
* Required Webex service-app scopes:
* spark-admin:licenses_read
* spark-admin:people_read
* spark-admin:people_write
*/
import 'dotenv/config';
import fs from 'node:fs';
import path from 'node:path';
import { logger } from '../utils/logger.js';
import webex from '../integrations/webex/WebexClient.js';
// ─────────────────────────────────────────────────────────────────────────────
// CLI parsing
// ─────────────────────────────────────────────────────────────────────────────
function parseArgs(argv) {
const out = {
csv: null,
site: process.env.WEBEX_HOST_SITE_URL || 'aeo2go.webex.com',
minDays: 120,
hostLicenseId: process.env.WEBEX_HOST_LICENSE_ID || null,
freeLicenseId: process.env.WEBEX_FREE_LICENSE_ID || null,
freeAttendee: false,
concurrency: 5,
limit: null,
offset: 0,
report: null,
execute: false,
help: false,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const next = () => argv[++i];
switch (a) {
case '--csv': out.csv = next(); break;
case '--site': out.site = next().toLowerCase(); break;
case '--min-days': out.minDays = Number(next()); break;
case '--host-license-id': out.hostLicenseId = next(); break;
case '--free-license-id': out.freeLicenseId = next(); break;
case '--free-attendee': out.freeAttendee = true; break;
case '--concurrency': out.concurrency = Math.max(1, Number(next())); break;
case '--limit': out.limit = Number(next()); break;
case '--offset': out.offset = Number(next()); break;
case '--report': out.report = next(); break;
case '--execute': out.execute = true; break;
case '-h': case '--help': out.help = true; break;
default:
if (a.startsWith('--')) {
console.error(`Unknown flag: ${a}`);
process.exit(2);
}
}
}
return out;
}
function printHelp() {
// Print the file's top comment block so `--help` matches source docs.
const src = fs.readFileSync(new URL(import.meta.url), 'utf8');
const m = src.match(/\/\*\*([\s\S]*?)\*\//);
if (m) console.log(m[1].replace(/^\s*\*\s?/gm, ''));
}
// ─────────────────────────────────────────────────────────────────────────────
// CSV parsing (RFC 4180-ish; handles quoted fields, escaped "")
// ─────────────────────────────────────────────────────────────────────────────
function parseCsvLine(line) {
const cells = [];
let cur = '';
let inQ = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (inQ) {
if (c === '"' && line[i + 1] === '"') { cur += '"'; i++; }
else if (c === '"') inQ = false;
else cur += c;
} else {
if (c === '"') inQ = true;
else if (c === ',') { cells.push(cur); cur = ''; }
else cur += c;
}
}
cells.push(cur);
return cells;
}
function readCsv(filePath) {
const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
if (lines.length === 0) return { header: [], rows: [] };
const header = parseCsvLine(lines[0]).map((h) => h.trim().toUpperCase());
const rows = lines.slice(1).map((l) => {
const cells = parseCsvLine(l);
const row = {};
for (let i = 0; i < header.length; i++) row[header[i]] = cells[i] ?? '';
return row;
});
return { header, rows };
}
// ─────────────────────────────────────────────────────────────────────────────
// Bounded-concurrency worker pool
// ─────────────────────────────────────────────────────────────────────────────
async function runPool(items, limit, worker) {
const results = new Array(items.length);
let idx = 0;
const workers = new Array(Math.min(limit, items.length)).fill(null).map(async () => {
while (true) {
const i = idx++;
if (i >= items.length) return;
try {
results[i] = { ok: true, value: await worker(items[i], i) };
} catch (err) {
results[i] = { ok: false, error: err };
}
}
});
await Promise.all(workers);
return results;
}
// ─────────────────────────────────────────────────────────────────────────────
// Webex helpers
// ─────────────────────────────────────────────────────────────────────────────
async function fetchSiteLicenses(siteUrl) {
const data = await webex.listLicenses();
const items = Array.isArray(data?.items) ? data.items : [];
return items.filter((l) => (l.siteUrl || '').toLowerCase() === siteUrl.toLowerCase());
}
function seatsFree(l) {
const total = Number(l.totalUnits ?? 0);
const used = Number(l.consumedUnits ?? 0);
return Math.max(0, total - used);
}
async function fetchHostAssignees(licenseId) {
// Returns Map<lowercased-email, { id, displayName, email }>. If an
// assignee record has no email (shouldn't happen for internal users)
// it's dropped — the CSV keys on email so we couldn't match anyway.
const users = await webex.listLicenseAssignees(licenseId);
const byEmail = new Map();
for (const u of users) {
const email = (u?.email || '').toLowerCase();
if (!email || !u?.id) continue;
byEmail.set(email, { id: u.id, displayName: u.displayName || email, email });
}
return byEmail;
}
function explainWebexError(err) {
const status = err?.response?.status;
const apiMsg =
err?.response?.data?.message ||
err?.response?.data?.errors?.[0]?.description ||
err?.message ||
String(err);
return status ? `${apiMsg} (HTTP ${status})` : apiMsg;
}
// Simple 429-aware retry. Webex returns Retry-After (seconds).
async function callWithRetry(fn, { tries = 4, baseDelayMs = 500 } = {}) {
let lastErr;
for (let attempt = 0; attempt < tries; attempt++) {
try {
return await fn();
} catch (err) {
lastErr = err;
const status = err?.response?.status;
if (status !== 429 && status !== 503) throw err;
const retryAfter = Number(err?.response?.headers?.['retry-after']);
const wait = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: baseDelayMs * Math.pow(2, attempt);
await new Promise((r) => setTimeout(r, wait));
}
}
throw lastErr;
}
// ─────────────────────────────────────────────────────────────────────────────
// Main
// ─────────────────────────────────────────────────────────────────────────────
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) { printHelp(); process.exit(0); }
if (!args.csv) {
console.error('❌ --csv <path> is required. Use --help for usage.');
process.exit(2);
}
const csvPath = path.resolve(args.csv);
if (!fs.existsSync(csvPath)) {
console.error(`❌ CSV not found: ${csvPath}`);
process.exit(2);
}
console.log(`📄 Reading ${csvPath}`);
const { header, rows } = readCsv(csvPath);
console.log(`${rows.length} rows, columns: ${header.join(', ')}`);
const required = ['EMAIL', 'IS_HOST', 'DAYS_SINCE_LAST_ACTIVE'];
const missing = required.filter((c) => !header.includes(c));
if (missing.length > 0) {
console.error(`❌ CSV missing required columns: ${missing.join(', ')}`);
console.error(` Expected a Control Hub "Meetings Inactive Users" export.`);
process.exit(2);
}
// Filter: hosts inactive over --min-days. Emails are lowercased for
// the assignee cross-reference below.
const candidates = [];
let skippedNotHost = 0;
let skippedRecent = 0;
for (const r of rows) {
const isHost = (r.IS_HOST || '').trim().toUpperCase() === 'Y';
const days = Number(r.DAYS_SINCE_LAST_ACTIVE || 0);
const email = (r.EMAIL || '').trim().toLowerCase();
if (!email) continue;
if (!isHost) { skippedNotHost++; continue; }
if (!(days > args.minDays)) { skippedRecent++; continue; }
candidates.push({
email,
days,
firstName: r.FIRST_NAME || '',
lastName: r.LAST_NAME || '',
lastActive: r.LAST_ACTIVE_DATE || '',
});
}
console.log(
`${candidates.length} candidates (host + >${args.minDays}d inactive); ` +
`skipped ${skippedNotHost} non-host, ${skippedRecent} recent`,
);
// Enumerate the site's licenses so we can (a) verify the host license
// id, (b) let the operator pick the free license id in dry-run, and
// (c) show seat headroom (relevant if --free-license-id is finite).
console.log(`\n🔎 Fetching Webex Meetings licenses on \`${args.site}\``);
let siteLicenses;
try {
siteLicenses = await fetchSiteLicenses(args.site);
} catch (err) {
console.error(`❌ Failed to list licenses: ${explainWebexError(err)}`);
process.exit(1);
}
if (siteLicenses.length === 0) {
console.error(`❌ No licenses found on \`${args.site}\`. Wrong site URL?`);
process.exit(1);
}
console.log(` Licenses on \`${args.site}\`:`);
for (const l of siteLicenses) {
const free = seatsFree(l);
const markers = [];
if (l.id === args.hostLicenseId) markers.push('HOST (to reclaim)');
if (l.id === args.freeLicenseId) markers.push('FREE (to assign)');
const mark = markers.length ? `${markers.join(', ')}` : '';
console.log(`${l.name}${free}/${l.totalUnits} free — id=${l.id}${mark}`);
}
if (!args.hostLicenseId) {
console.error(
`\n❌ --host-license-id not set (and WEBEX_HOST_LICENSE_ID env is empty).\n` +
` Pick the "host" license id from the list above and re-run with\n` +
` --host-license-id <id>.`,
);
process.exit(2);
}
const hostLic = siteLicenses.find((l) => l.id === args.hostLicenseId);
if (!hostLic) {
console.error(
`\n❌ --host-license-id ${args.hostLicenseId} does not match any license\n` +
` on \`${args.site}\`. Double-check the id.`,
);
process.exit(2);
}
let freeLic = null;
if (args.freeLicenseId) {
freeLic = siteLicenses.find((l) => l.id === args.freeLicenseId);
if (!freeLic) {
console.error(
`\n❌ --free-license-id ${args.freeLicenseId} does not match any license\n` +
` on \`${args.site}\`. Double-check the id.`,
);
process.exit(2);
}
}
// Cross-reference: pull the host license's current assignee roster
// and keep only the CSV candidates who actually still hold it.
console.log(`\n📥 Fetching current holders of \`${hostLic.name}\` (paginated)…`);
let holders;
try {
holders = await fetchHostAssignees(hostLic.id);
} catch (err) {
console.error(`❌ Failed to fetch assignees: ${explainWebexError(err)}`);
process.exit(1);
}
console.log(`${holders.size} current holders`);
const toReclaim = [];
let skippedNotHolder = 0;
for (const c of candidates) {
const holder = holders.get(c.email);
if (!holder) { skippedNotHolder++; continue; }
toReclaim.push({ ...c, personId: holder.id, displayName: holder.displayName });
}
console.log(
`${toReclaim.length} to reclaim ` +
`(${skippedNotHolder} CSV candidates no longer hold the license)`,
);
// Optional slicing for staged rollouts / restart-after-failure.
const sliced = toReclaim.slice(args.offset, args.limit ? args.offset + args.limit : undefined);
if (sliced.length !== toReclaim.length) {
console.log(` → sliced to ${sliced.length} (offset=${args.offset}, limit=${args.limit ?? 'none'})`);
}
// Plan sanity: if a specific free license is provided, ensure it has
// enough seats for the run. (If it doesn't we still let --execute
// proceed, but Webex will start rejecting after seats run out — flag
// it up top so the operator can pick a different license or split.)
if (freeLic) {
const free = seatsFree(freeLic);
if (free < sliced.length) {
console.warn(
`\n⚠️ Free license \`${freeLic.name}\` has ${free} free seats but ` +
`${sliced.length} assignments are planned. Extras will fail.`,
);
}
}
// Decide what mutation each user will get.
const mutation = describeMutation(args, hostLic, freeLic);
console.log(`\n🛠 Planned mutation per user: ${mutation.human}`);
// Dry-run bail-out.
if (!args.execute) {
console.log(`\n🚦 DRY-RUN (no changes made). Re-run with --execute to commit.`);
if (!freeLic && !args.freeAttendee) {
console.log(
` Note: neither --free-license-id nor --free-attendee provided.\n` +
` Pick one before --execute:\n` +
` --free-license-id <id> (assigns a specific meetings license)\n` +
` --free-attendee (attendee-only on ${args.site}, no license)`,
);
}
if (sliced.length > 0) {
console.log(`\n Sample of first 5 candidates:`);
for (const s of sliced.slice(0, 5)) {
console.log(` - ${s.displayName} <${s.email}> (${s.days}d inactive, personId=${s.personId})`);
}
}
process.exit(0);
}
if (!freeLic && !args.freeAttendee) {
console.error(
`\n❌ --execute requires one of:\n` +
` --free-license-id <id> (assign a specific meetings license)\n` +
` --free-attendee (attendee-only on ${args.site}, no license)`,
);
process.exit(2);
}
// Execute with bounded concurrency + 429 retry.
console.log(
`\n🚀 EXECUTING against ${sliced.length} users ` +
`(concurrency=${args.concurrency}). Ctrl-C to abort.\n`,
);
logger(
'webex:reclaim:audit',
`START reclaim: site=${args.site} host=${hostLic.id} ` +
`free=${freeLic ? freeLic.id : args.freeAttendee ? 'ATTENDEE' : 'NONE'} ` +
`count=${sliced.length} csv=${path.basename(csvPath)}`,
);
let processed = 0;
const results = await runPool(sliced, args.concurrency, async (user) => {
const body = {
personId: user.personId,
licenses: [{ id: hostLic.id, operation: 'remove' }],
};
if (freeLic) body.licenses.push({ id: freeLic.id, operation: 'add' });
if (args.freeAttendee) {
body.siteUrls = [{ siteUrl: args.site, accountType: 'attendee', operation: 'add' }];
}
const resp = await callWithRetry(() => webex.assignLicensesToUser(body));
processed++;
if (processed % 25 === 0 || processed === sliced.length) {
console.log(`${processed}/${sliced.length}`);
}
return resp;
});
// Summarise + audit.
let ok = 0;
let failed = 0;
const failures = [];
const perUser = [];
for (let i = 0; i < results.length; i++) {
const r = results[i];
const u = sliced[i];
if (r.ok) {
ok++;
const grantedIds = new Set(r.value?.licenses || []);
const removedOk = !new Set(r.value?.licenses || []).has(hostLic.id);
const freeOk = !freeLic || grantedIds.has(freeLic.id);
const outcome = removedOk && freeOk ? 'granted' : 'partial';
logger(
'webex:reclaim:audit',
`OK ${u.email} personId=${u.personId} outcome=${outcome}`,
);
perUser.push({
email: u.email,
displayName: u.displayName,
days_inactive: u.days,
personId: u.personId,
outcome,
error: '',
});
} else {
failed++;
const msg = explainWebexError(r.error);
failures.push({ user: u, msg });
logger(
'webex:reclaim:audit',
`FAIL ${u.email} personId=${u.personId}: ${msg}`,
'error',
);
perUser.push({
email: u.email,
displayName: u.displayName,
days_inactive: u.days,
personId: u.personId,
outcome: 'error',
error: msg,
});
}
}
console.log(`\n✅ Done. ${ok} succeeded, ${failed} failed, ${sliced.length} total.`);
if (failures.length > 0) {
console.log(`\nFirst up to 10 failures:`);
for (const f of failures.slice(0, 10)) {
console.log(` - ${f.user.email}: ${f.msg}`);
}
}
if (args.report) {
const reportPath = path.resolve(args.report);
const cols = ['email', 'displayName', 'days_inactive', 'personId', 'outcome', 'error'];
const escape = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
const lines = [cols.join(',')];
for (const p of perUser) lines.push(cols.map((c) => escape(p[c])).join(','));
fs.writeFileSync(reportPath, lines.join('\n') + '\n', 'utf8');
console.log(`\n📝 Report written to ${reportPath}`);
}
logger(
'webex:reclaim:audit',
`END reclaim: ok=${ok} failed=${failed} total=${sliced.length}`,
);
process.exit(failed === 0 ? 0 : 1);
}
function describeMutation(args, hostLic, freeLic) {
const parts = [`remove \`${hostLic.name}\``];
if (freeLic) parts.push(`add \`${freeLic.name}\``);
else if (args.freeAttendee) parts.push(`add attendee-only on \`${args.site}\``);
else parts.push(`(no replacement — TBD)`);
return { human: parts.join(', ') };
}
main().catch((err) => {
console.error(`\n💥 Unhandled: ${err?.stack || err}`);
process.exit(1);
});