collabSupport/integrations/paloalto/sites.js
jmcqueen 90a56c4640 Fix Prisma overlay tunnel discovery with site-scoped query_params.
Use topology/links and anynetlinks filters with eq/in operators so
store WAN follow-ups return real peer tunnels instead of unscoped dumps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 09:01:40 -04:00

548 lines
20 KiB
JavaScript

// src/integrations/paloalto/sites.js
//
// Site + element discovery for Prisma SD-WAN. Prisma's data model
// is: tenant → site → element(s) → interface(s)/path(s). A "site" is
// what maps 1:1 to a store; an "element" is a physical/virtual
// Prisma appliance at that site.
//
// AE convention (confirmed by operator): site names are
// `CG${storeNum.padStart(5, '0')}` — e.g. store 782 → `CG00782`,
// store 2477 → `CG02477`, store 305 → `CG00305`. Exact match, no
// fuzzy scoring needed. This is materially simpler than the Meraki
// resolver (which has to substring-search).
//
// Cache strategy:
// - Sites list: 4-hour TTL (same as Meraki networks cache). Sites
// don't come and go often; a store add/remove is a Prisma-side
// lifecycle event that we tolerate up to 4 hours of staleness
// on. `refreshSitesCache()` re-pulls on demand.
// - Elements per site: cached alongside the site the first time
// they're asked for. Same 4-hour TTL. Elements at a site can
// change (RMA replacement), but this is rare and the same
// staleness bound applies.
//
// Read-only. The client only calls GET here; there's no mutation
// surface exposed.
import { paloAltoAxios } from './client.js';
import { logger } from '../../utils/logger.js';
const CACHE_TTL_MS = 4 * 60 * 60 * 1000;
// Sites list cache (one entry for the whole tenant).
let sitesCache = null;
let sitesCacheAt = 0;
// Tenant-wide elements cache. Rationale (verified against live
// tenant): the `?site_id=X` GET filter on /elements is NOT honored
// server-side — Prisma returns the full inventory (1229 elements in
// the observed tenant) regardless of the query param, and we filter
// client-side. Making one tenant-wide fetch and indexing by site_id
// in-memory eliminates 1200+ redundant records per subsequent call
// and cuts /voicediag's Prisma call count by ~90%.
let elementsCache = null; // { elementsBySite: Map<siteId, elements[]>, fetchedAt }
// Per-site waninterfaces cache (Map<siteId, { waninterfaces, fetchedAt }>).
// This one stays per-site because /sites/{siteId}/waninterfaces IS
// properly scoped server-side — no wasteful over-fetch.
const waninterfacesCache = new Map();
// ──────────────────────────────────────────────
// Site resolution
// ──────────────────────────────────────────────
/**
* Convert a 2-4 digit store number into its expected Prisma site
* name using the `CG${pad5}` convention.
*
* @param {string|number} storeNum
* @returns {string} e.g. 'CG00782'
*/
export function siteNameForStore(storeNum) {
const digits = String(storeNum ?? '').match(/\d+/)?.[0] || '';
if (!digits) {
throw new Error(`siteNameForStore: no digits in "${storeNum}"`);
}
const padded = digits.padStart(5, '0').slice(-5);
return `CG${padded}`;
}
/**
* Return all Prisma SD-WAN sites in the tenant, refreshing the
* cache when stale. Uses the GET-based list endpoint:
*
* GET /sdwan/v4.13/api/sites (SASE unified — "Get Sites Of Tenant")
* GET /v4.13/api/sites (legacy)
*
* Why GET instead of the sibling `POST /sites/query`:
*
* The observed SASE tenant (2026-07-08) runs a schema variant of
* `POST /sites/query` where the request-body validator rejects
* fields with cryptic type-mismatch errors — first `getDeleted`
* ("expected boolean, got object"), then `total_count` ("expected
* long, got object"), and likely more if we probed further. The
* SASE proxy appears to auto-inject fields with `{}` defaults into
* the body before schema validation, so ANY minimal body triggers
* a different validation failure on some auto-injected field.
*
* `GET /sites` sidesteps the entire body-schema minefield: no body
* = no auto-injection = no drifting field types to guess. The
* endpoint returns the same `{ items: [...] }` shape and is
* documented on pan.dev as "Get Sites Of Tenant (v4.13)".
*
* Pagination: the GET endpoint supports `?limit=` and cursor
* follow-up if Prisma paginates its response. We handle both
* `items[]` on a single page and any `next_query` echo (defensively).
*/
export async function getAllSites(forceRefresh = false) {
const now = Date.now();
if (!forceRefresh && sitesCache && now - sitesCacheAt < CACHE_TTL_MS) {
return sitesCache;
}
const path = sitesListPath();
logger('paloalto:sites', `Refreshing sites cache from GET ${path}`, 'debug');
try {
const allItems = await fetchAllSitesGet(path);
sitesCache = allItems.map((s) => ({
id: s.id,
name: s.name,
description: s.description || '',
tenant_id: s.tenant_id || null,
raw: s,
}));
sitesCacheAt = now;
if (sitesCache.length === 0) {
logger('paloalto:sites', `Cached 0 sites (empty tenant or response-shape drift — enable LOG_LEVEL=debug to see the raw first-page shape).`, 'warn');
} else {
// Sample the first 3 site names so an operator scanning the
// logs can immediately eyeball whether their target site name
// matches the naming convention Prisma is actually using.
const sample = sitesCache.slice(0, 3).map((s) => s.name).join(', ');
logger(
'paloalto:sites',
`Cached ${sitesCache.length} sites (sample: ${sample}${sitesCache.length > 3 ? ', …' : ''})`,
);
}
return sitesCache;
} catch (err) {
logger('paloalto:sites', `Failed to fetch sites: ${err.message}`, 'error');
// On failure, keep any stale cache (better than nothing) but
// return an empty array if we've never fetched successfully.
return sitesCache || [];
}
}
/**
* Fetch every site via `GET /sdwan/v4.13/api/sites`, following any
* cursor pagination Prisma returns.
*
* The GET endpoint's response shape mirrors POST /query:
* { items: [...], next_query?: {...} }
*
* Some tenants return a single-shot list (all sites in one response,
* no `next_query`). Others paginate — in which case we send the
* `next_query` object back as a `?query=<encoded-json>` query
* param (Prisma's GET-side pagination convention) or via a subsequent
* POST hit. We try the "single response" path first and only
* paginate if `next_query` is populated.
*
* Defenses:
* - Request `?limit=1000` up-front to maximise the first-page size
* (some tenants default to 100).
* - Follow `next_query` when present. Empty / null / undefined /
* empty-object all count as "no more pages" (same rules as the
* POST paginator).
* - MAX_PAGES safety cap against a runaway cursor.
*/
async function fetchAllSitesGet(basePath) {
const PAGE_LIMIT = 1000;
const MAX_PAGES = 50;
const all = [];
let requestParams = { limit: PAGE_LIMIT };
let page = 0;
while (page < MAX_PAGES) {
page += 1;
logger(
'paloalto:sites',
`Requesting page ${page}: GET ${basePath} params=${JSON.stringify(requestParams).slice(0, 200)}`,
'debug',
);
const res = await paloAltoAxios.get(basePath, { params: requestParams });
let items = res.data?.items || res.data?.data;
if (!items && Array.isArray(res.data)) items = res.data;
if (!items) items = [];
if (!Array.isArray(items)) {
const shapeHint = res.data && typeof res.data === 'object'
? `keys=[${Object.keys(res.data).join(', ')}]`
: `type=${typeof res.data}`;
throw new Error(`Unexpected sites response shape on page ${page} (${shapeHint})`);
}
all.push(...items);
logger('paloalto:sites', `Page ${page}: got ${items.length} sites (running total ${all.length})`, 'debug');
// Termination: no cursor → done. GET endpoints without cursor
// return the full list in one response for most tenants.
const nextQuery = res.data?.next_query;
const hasCursor = nextQuery
&& typeof nextQuery === 'object'
&& Object.keys(nextQuery).length > 0;
if (!hasCursor) break;
// Pass Prisma's own cursor state onto the next GET as query
// params. The most portable representation is `?query=<json>`
// — Prisma's GET-side pagination convention — but some
// tenants also honor spreading the cursor fields directly.
// We spread first (more common); if that ever breaks, fall
// back to the JSON-encoded param.
requestParams = { ...nextQuery, limit: PAGE_LIMIT };
}
if (page >= MAX_PAGES) {
logger(
'paloalto:sites',
`Pagination hit MAX_PAGES=${MAX_PAGES} at ${all.length} sites — cursor may be looping. Investigate.`,
'warn',
);
}
return all;
}
/**
* Given a store number (2-4 digits, or a string containing them),
* return the corresponding Prisma site or null if not found.
* Exact match on `CG${pad5}` — the only convention AE uses.
*
* @param {string|number} storeNum
* @returns {Promise<{id:string,name:string,description:string,tenant_id:string|null}|null>}
*/
export async function findSdwanSiteForStore(storeNum) {
let expectedName;
try {
expectedName = siteNameForStore(storeNum);
} catch (err) {
logger('paloalto:sites', `Bad store number "${storeNum}": ${err.message}`, 'warn');
return null;
}
const sites = await getAllSites();
const match = sites.find((s) => s.name === expectedName);
if (!match) {
// Warn (not debug) so an operator can see mismatches in
// default-level logs. Include the nearest names in the tenant so
// a naming-convention drift ("CG782" vs "CG00782") is spotted
// without hunting through the full site list.
const nearby = sites
.filter((s) => (s.name || '').includes(String(storeNum)))
.slice(0, 5)
.map((s) => s.name);
const nearbyHint = nearby.length > 0 ? ` — closest matches: ${nearby.join(', ')}` : '';
logger(
'paloalto:sites',
`No Prisma site matches "${expectedName}" (store ${storeNum}) among ${sites.length} sites${nearbyHint}`,
'warn',
);
return null;
}
logger('paloalto:sites', `Store ${storeNum} → site ${match.name} (${match.id})`);
return match;
}
// ──────────────────────────────────────────────
// Elements (appliances) per site
// ──────────────────────────────────────────────
/**
* Return the list of Prisma appliances (elements) at a site.
*
* Fetches the ENTIRE tenant inventory once (cached 4h) and indexes
* by `site_id` so per-site lookups are O(1) with zero network cost
* on cache hit. This is the correct pattern for Prisma because the
* `?site_id=X` server-side filter on /elements is silently ignored
* (verified live: a filtered request returned 1229 elements for a
* site that owns 1).
*
* GET /sdwan/v3.1/api/elements (SASE unified — tenant-wide)
* GET /v3.1/api/elements (legacy)
*
* @param {string} siteId
* @returns {Promise<Array<{id, name, model, serial_number, connected, site_id}>>}
*/
export async function getElementsForSite(siteId, forceRefresh = false) {
if (!siteId) return [];
const map = await getElementsIndex(forceRefresh);
const rows = map.get(siteId) || [];
logger('paloalto:sites', `Site ${siteId}: ${rows.length} element(s) from tenant-wide cache`, 'debug');
return rows;
}
/**
* Return (fetching if stale) the tenant-wide index of elements
* keyed by site_id. First caller pays the network cost + build; all
* subsequent callers within the 4h TTL get instant Map lookup.
*/
async function getElementsIndex(forceRefresh = false) {
const now = Date.now();
if (!forceRefresh && elementsCache && now - elementsCache.fetchedAt < CACHE_TTL_MS) {
return elementsCache.elementsBySite;
}
const path = elementsPath();
logger('paloalto:sites', `Refreshing tenant-wide elements from ${path}`, 'debug');
try {
const res = await paloAltoAxios.get(path);
const items = res.data?.items || res.data?.data || [];
if (!Array.isArray(items)) {
throw new Error(`Unexpected elements response shape: ${JSON.stringify(res.data)?.slice(0, 200)}`);
}
const elementsBySite = new Map();
for (const e of items) {
if (!e.site_id) continue;
const row = {
id: e.id,
name: e.name || null,
model: e.model_name || e.model || null,
serial_number: e.serial_number || null,
connected: e.connected === true,
site_id: e.site_id,
raw: e,
};
const bucket = elementsBySite.get(e.site_id) || [];
bucket.push(row);
elementsBySite.set(e.site_id, bucket);
}
elementsCache = { elementsBySite, fetchedAt: now };
logger(
'paloalto:sites',
`Cached ${items.length} elements across ${elementsBySite.size} site(s)`,
);
return elementsBySite;
} catch (err) {
logger('paloalto:sites', `Failed to fetch tenant elements: ${err.message}`, 'error');
return elementsCache?.elementsBySite || new Map();
}
}
/**
* Return the list of WAN interfaces (per-site circuits) for a site.
* Cached per site for 4h. WAN interfaces are the "path" identifiers
* that LQM metrics filter by — without this list, per-path latency /
* jitter / loss / MOS queries have no waninterface ids to feed into
* the metric filter and come back empty.
*
* GET /sdwan/v2.10/api/sites/{siteId}/waninterfaces (SASE unified)
* GET /v2.10/api/sites/{siteId}/waninterfaces (legacy)
*
* @param {string} siteId
* @returns {Promise<Array<{id, name, adminUp, wanNetworkId, wanNetworkName, usedFor, bwConfigMode}>>}
*/
export async function getWanInterfacesForSite(siteId, forceRefresh = false) {
if (!siteId) return [];
const now = Date.now();
const cached = waninterfacesCache.get(siteId);
if (!forceRefresh && cached && now - cached.fetchedAt < CACHE_TTL_MS) {
return cached.waninterfaces;
}
const path = waninterfacesPath(siteId);
logger('paloalto:sites', `Refreshing waninterfaces for site ${siteId} from ${path}`, 'debug');
try {
const res = await paloAltoAxios.get(path);
const items = res.data?.items || res.data?.data || [];
if (!Array.isArray(items)) {
throw new Error(`Unexpected waninterfaces response shape: ${JSON.stringify(res.data)?.slice(0, 200)}`);
}
const waninterfaces = items.map((w) => ({
id: w.id,
name: w.name || w.description || null,
// Defensive parsing: on some tenant schema variants the
// waninterface config API doesn't return `admin_up` at all,
// in which case a strict `=== true` check silently reports
// every circuit as DOWN — a misleading false positive
// (verified live: 3 circuits all showed ❌ DOWN when the
// API returned no admin_up field). Treat missing / non-bool
// as null (unknown) so the check + renderer show "?" rather
// than red-crossing a healthy circuit.
adminUp: typeof w.admin_up === 'boolean' ? w.admin_up : null,
wanNetworkId: w.wan_network_id || null,
wanNetworkName: w.wan_network_name || null,
// `used_for`: 'primary' | 'secondary' | ... Also useful as a
// rough "transport type" label until we wire wan_networks
// config in a phase 2.
usedFor: w.used_for || null,
bwConfigMode: w.bw_config_mode || null,
raw: w,
}));
waninterfacesCache.set(siteId, { waninterfaces, fetchedAt: now });
logger('paloalto:sites', `Site ${siteId}: ${waninterfaces.length} waninterface(s)`);
return waninterfaces;
} catch (err) {
logger('paloalto:sites', `Failed to fetch waninterfaces for site ${siteId}: ${err.message}`, 'error');
return cached?.waninterfaces || [];
}
}
// ──────────────────────────────────────────────
// Path helpers — SASE vs legacy have different URL prefixes
// ──────────────────────────────────────────────
function isSase() {
return String(process.env.PRISMA_AUTH_MODE || 'sase').toLowerCase().trim() === 'sase';
}
function sitesListPath() {
return isSase()
? '/sdwan/v4.13/api/sites'
: '/v4.13/api/sites';
}
function elementsPath() {
return isSase()
? '/sdwan/v3.1/api/elements'
: '/v3.1/api/elements';
}
function waninterfacesPath(siteId) {
return isSase()
? `/sdwan/v2.10/api/sites/${siteId}/waninterfaces`
: `/v2.10/api/sites/${siteId}/waninterfaces`;
}
function waninterfaceStatusPath(siteId, wanInterfaceId) {
return isSase()
? `/sdwan/v2.1/api/sites/${siteId}/waninterfaces/${wanInterfaceId}/status`
: `/v2.1/api/sites/${siteId}/waninterfaces/${wanInterfaceId}/status`;
}
/**
* Runtime status for one site WAN interface.
*
* GET /sdwan/v2.1/api/sites/{siteId}/waninterfaces/{wanInterfaceId}/status
*
* Returns a normalized object or null on failure. Never throws.
*
* @param {string} siteId
* @param {string} wanInterfaceId
* @returns {Promise<{operationalUp: boolean|null, adminUp: boolean|null, elementId: string|null, raw: object}|null>}
*/
export async function getWanInterfaceStatus(siteId, wanInterfaceId) {
if (!siteId || !wanInterfaceId) return null;
try {
const res = await paloAltoAxios.get(waninterfaceStatusPath(siteId, wanInterfaceId));
const body = res.data || {};
return normalizeWanInterfaceStatus(body);
} catch (err) {
logger('paloalto:sites',
`waninterface status ${siteId}/${wanInterfaceId} failed: ${err.message}`, 'debug');
return null;
}
}
/**
* Fan-out runtime status for every waninterface at a site.
*
* @param {string} siteId
* @param {Array<{id: string}>|string[]} wanInterfaces
* @param {object} [opts]
* @param {number} [opts.concurrency=4]
* @returns {Promise<Map<string, object>>} interfaceId → status row
*/
export async function getWanInterfaceStatusesForSite(siteId, wanInterfaces, opts = {}) {
const map = new Map();
if (!siteId) return map;
const ids = (Array.isArray(wanInterfaces) ? wanInterfaces : [])
.map((w) => (typeof w === 'string' ? w : w?.id))
.filter(Boolean)
.map(String);
if (ids.length === 0) return map;
const concurrency = Math.max(1, Number(opts.concurrency) || 4);
let i = 0;
async function worker() {
while (i < ids.length) {
const idx = i;
i += 1;
const id = ids[idx];
const status = await getWanInterfaceStatus(siteId, id);
if (status) map.set(id, status);
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, ids.length) }, () => worker()),
);
logger('paloalto:sites',
`Site ${siteId}: runtime status for ${map.size}/${ids.length} waninterface(s)`,
);
return map;
}
/**
* @param {object} body
* @returns {{operationalUp: boolean|null, adminUp: boolean|null, elementId: string|null, raw: object}}
*/
export function normalizeWanInterfaceStatus(body) {
const raw = body && typeof body === 'object' ? body : {};
const operationalUp = pickStatusBool(
raw.operational_up,
raw.operationalUp,
raw.up,
raw.reachable,
statusStringToBool(raw.status),
statusStringToBool(raw.operational_state),
statusStringToBool(raw.state),
statusStringToBool(raw.link_state),
);
const adminUp = pickStatusBool(
raw.admin_up,
raw.adminUp,
statusStringToBool(raw.admin_state),
statusStringToBool(raw.al_admin_state),
);
const elementId = raw.element_id || raw.elementId || null;
return {
operationalUp,
adminUp,
elementId: elementId ? String(elementId) : null,
raw,
};
}
function pickStatusBool(...candidates) {
for (const c of candidates) {
if (typeof c === 'boolean') return c;
}
return null;
}
function statusStringToBool(s) {
if (typeof s !== 'string') return null;
const v = s.toLowerCase().trim();
if (['up', 'active', 'connected', 'online', 'reachable'].includes(v)) return true;
if (['down', 'inactive', 'disconnected', 'offline', 'unreachable'].includes(v)) return false;
return null;
}
/**
* Test-only — clears all caches so a fresh fetch happens next call.
*/
export function _resetSitesCache() {
sitesCache = null;
sitesCacheAt = 0;
elementsCache = null;
waninterfacesCache.clear();
}