From c996d5d32ef2c745de39784a8f20aae38be1cb9c Mon Sep 17 00:00:00 2001 From: Joseph McQueen Date: Tue, 7 Jul 2026 15:02:04 -0400 Subject: [PATCH] =?UTF-8?q?Add=20scripts/reclaimWebexHosts.js=20=E2=80=94?= =?UTF-8?q?=20bulk=20host=20license=20reclaim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 4 +- scripts/reclaimWebexHosts.js | 527 +++++++++++++++++++++++++++++++++++ 2 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 scripts/reclaimWebexHosts.js diff --git a/.gitignore b/.gitignore index b42463a..3494995 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,9 @@ storage/ # Dev / test artifacts (local only) characterization-runs/ -scripts/ +scripts/* +# Tracked operational scripts (whitelisted; keep local dev helpers ignored above) +!scripts/reclaimWebexHosts.js characterize-*.js # Backup & temp files diff --git a/scripts/reclaimWebexHosts.js b/scripts/reclaimWebexHosts.js new file mode 100644 index 0000000..c982f9d --- /dev/null +++ b/scripts/reclaimWebexHosts.js @@ -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 ] \ + * [--free-license-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. 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 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 .`, + ); + 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 (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 (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); +});