#!/usr/bin/env node import { randomUUID } from 'node:crypto' import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' const scriptPath = fileURLToPath(import.meta.url) const acknowledgement = 'I_ACCEPT_TEST_ALERT_NOTIFICATIONS' const defaultReceiver = 'frameflow-operator-webhook' export class AlertmanagerSmokeError extends Error { constructor(message, options = {}) { super(message, options) this.name = 'AlertmanagerSmokeError' } } function required(value, name) { if (typeof value !== 'string' || value.trim() === '') throw new AlertmanagerSmokeError(`${name} is required`) return value.trim() } function integerValue(value, name, minimum, maximum) { const parsed = Number(value) if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) { throw new AlertmanagerSmokeError(`${name} must be an integer from ${minimum} to ${maximum}`) } return parsed } export function resolveAlertmanagerUrl(value) { let url try { url = new URL(required(value, 'FRAMEFLOW_ALERTMANAGER_URL')) } catch (error) { if (error instanceof AlertmanagerSmokeError) throw error throw new AlertmanagerSmokeError('FRAMEFLOW_ALERTMANAGER_URL must be a valid URL') } if (url.username || url.password || url.search || url.hash) { throw new AlertmanagerSmokeError('Alertmanager URL cannot contain credentials, a query, or a fragment') } if (!['http:', 'https:'].includes(url.protocol)) { throw new AlertmanagerSmokeError('Alertmanager URL must use HTTP or HTTPS') } const localHostnames = new Set(['localhost', '127.0.0.1', '::1', '[::1]']) if (url.protocol === 'http:' && !localHostnames.has(url.hostname)) { throw new AlertmanagerSmokeError('HTTP is allowed only for a loopback Alertmanager URL') } if (url.pathname !== '/' && url.pathname !== '') { throw new AlertmanagerSmokeError('Alertmanager URL must not contain a path') } return url.origin } export function usage() { return `Usage: npm run smoke:alertmanager -- [options] Sends a real FrameFlowDeliveryTest alert and resolution through Alertmanager, then verifies successful webhook notification counters for both events. Required environment: FRAMEFLOW_ALERT_TEST_ACK=${acknowledgement} Optional environment: FRAMEFLOW_ALERTMANAGER_URL=http://127.0.0.1:9093 FRAMEFLOW_ALERT_TEST_RECEIVER=${defaultReceiver} FRAMEFLOW_ALERT_TEST_TIMEOUT_SECONDS=60 Options: --url Alertmanager origin; HTTP is loopback-only --receiver Receiver label used by notification metrics --timeout-seconds <15-300> --json --help This command causes real firing and resolved webhook notifications. A passing report proves that the configured webhook returned success to Alertmanager; it does not prove downstream processing after that acknowledgement. ` } export function parseAlertmanagerSmokeOptions(argv = process.argv.slice(2), environment = process.env) { const values = {} const flags = new Set() const valueOptions = new Set(['--url', '--receiver', '--timeout-seconds']) const flagOptions = new Set(['--json', '--help']) for (let index = 0; index < argv.length; index += 1) { const argument = argv[index] if (valueOptions.has(argument)) { const value = argv[index + 1] if (!value || value.startsWith('--')) throw new AlertmanagerSmokeError(`${argument} requires a value`) values[argument] = value index += 1 } else if (flagOptions.has(argument)) { flags.add(argument) } else { throw new AlertmanagerSmokeError(`Unknown option: ${argument}`) } } if (flags.has('--help')) return { help: true } if (environment.FRAMEFLOW_ALERT_TEST_ACK !== acknowledgement) { throw new AlertmanagerSmokeError(`Alert smoke requires FRAMEFLOW_ALERT_TEST_ACK=${acknowledgement}`) } const receiver = required(values['--receiver'] ?? environment.FRAMEFLOW_ALERT_TEST_RECEIVER ?? defaultReceiver, 'receiver') if (!/^[a-zA-Z0-9_.-]{1,100}$/.test(receiver)) { throw new AlertmanagerSmokeError('receiver must contain only letters, digits, dot, underscore, or hyphen') } const timeoutSeconds = integerValue(values['--timeout-seconds'] ?? environment.FRAMEFLOW_ALERT_TEST_TIMEOUT_SECONDS ?? '60', 'timeout-seconds', 15, 300) return { alertmanagerUrl: resolveAlertmanagerUrl(values['--url'] ?? environment.FRAMEFLOW_ALERTMANAGER_URL ?? 'http://127.0.0.1:9093'), receiver, timeoutMs: timeoutSeconds * 1000, requestTimeoutMs: Math.min(10_000, timeoutSeconds * 1000), pollIntervalMs: 500, json: flags.has('--json'), } } function unescapePrometheusLabel(value) { return value.replace(/\\([\\"n])/g, (_match, escaped) => escaped === 'n' ? '\n' : escaped) } function parsePrometheusLabels(value) { const labels = {} const matcher = /([a-zA-Z_][a-zA-Z0-9_]*)="((?:\\.|[^"\\])*)"/g for (const match of value.matchAll(matcher)) labels[match[1]] = unescapePrometheusLabel(match[2]) return labels } export function notificationCounters(metricsText, receiver) { const counters = { total: 0, failed: 0 } for (const line of String(metricsText).split('\n')) { const match = line.match(/^(alertmanager_notifications(?:_failed)?_total)\{([^}]*)\}\s+([^\s]+)$/) if (!match) continue const labels = parsePrometheusLabels(match[2]) if (labels.integration !== 'webhook' || labels.receiver_name !== receiver) continue const value = Number(match[3]) if (!Number.isFinite(value)) continue if (match[1] === 'alertmanager_notifications_failed_total') counters.failed += value else counters.total += value } return counters } function safeResponseText(value) { return String(value ?? '').replace(/[\r\n\t]+/g, ' ').slice(0, 500) } async function fetchWithTimeout(fetchImpl, url, options, timeoutMs) { const controller = new AbortController() const timer = setTimeout(() => controller.abort(new Error(`request timed out after ${timeoutMs}ms`)), timeoutMs) try { return await fetchImpl(url, { ...options, signal: controller.signal }) } finally { clearTimeout(timer) } } async function expectOk(fetchImpl, url, options, timeoutMs) { const response = await fetchWithTimeout(fetchImpl, url, options, timeoutMs) if (!response.ok) { throw new AlertmanagerSmokeError(`${options.method ?? 'GET'} ${new URL(url).pathname} failed (${response.status}): ${safeResponseText(await response.text())}`) } return response } async function postAlert(fetchImpl, options, alert) { await expectOk(fetchImpl, `${options.alertmanagerUrl}/api/v2/alerts`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify([alert]), }, options.requestTimeoutMs) } async function listTestAlerts(fetchImpl, options, testId) { const matcher = encodeURIComponent(`test_id="${testId}"`) const response = await expectOk(fetchImpl, `${options.alertmanagerUrl}/api/v2/alerts?active=true&silenced=true&inhibited=true&unprocessed=true&filter=${matcher}`, {}, options.requestTimeoutMs) const body = await response.json() if (!Array.isArray(body)) throw new AlertmanagerSmokeError('Alertmanager returned an invalid alert list') return body.filter((alert) => alert?.labels?.test_id === testId) } async function readNotificationCounters(fetchImpl, options) { const response = await expectOk(fetchImpl, `${options.alertmanagerUrl}/metrics`, {}, options.requestTimeoutMs) return notificationCounters(await response.text(), options.receiver) } async function waitUntil(check, description, options, dependencies) { const deadline = dependencies.now() + options.timeoutMs let lastError = null do { try { const result = await check() if (result?.fatal) throw result.error if (result?.done) return result.value } catch (error) { lastError = error } if (dependencies.now() >= deadline) break await dependencies.sleep(Math.min(options.pollIntervalMs, Math.max(1, deadline - dependencies.now()))) } while (dependencies.now() <= deadline) const suffix = lastError instanceof Error ? `: ${lastError.message}` : '' throw new AlertmanagerSmokeError(`Timed out waiting for ${description}${suffix}`) } async function waitForAlertState(fetchImpl, options, testId, expectedActive, dependencies) { return waitUntil(async () => { const alerts = await listTestAlerts(fetchImpl, options, testId) const active = alerts.some((alert) => Date.parse(alert.endsAt) > dependencies.now()) return active === expectedActive ? { done: true, value: alerts } : { done: false } }, expectedActive ? 'the test alert to become active' : 'the test alert to resolve', options, dependencies) } async function waitForSuccessfulNotification(fetchImpl, options, baseline, phase, dependencies) { return waitUntil(async () => { const counters = await readNotificationCounters(fetchImpl, options) if (counters.failed > baseline.failed) { return { fatal: true, error: new AlertmanagerSmokeError(`${phase} webhook notification failed according to Alertmanager metrics`), } } return counters.total > baseline.total ? { done: true, value: counters } : { done: false } }, `a successful ${phase} webhook notification`, options, dependencies) } export async function runAlertmanagerSmoke(options, dependencies = {}) { const fetchImpl = dependencies.fetchImpl ?? fetch const now = dependencies.now ?? Date.now const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds))) const createId = dependencies.createId ?? randomUUID const runtime = { now, sleep } const testId = createId() const startsAt = new Date(now()).toISOString() const firingAlert = { labels: { alertname: 'FrameFlowDeliveryTest', severity: 'info', service: 'frameflow', test_id: testId, }, annotations: { summary: 'FrameFlow controlled Alertmanager delivery test', description: `Controlled firing notification for deployment acceptance test ${testId}.`, }, startsAt, endsAt: new Date(now() + 15 * 60_000).toISOString(), generatorURL: `${options.alertmanagerUrl}/#/alerts`, } const baseline = await readNotificationCounters(fetchImpl, options) let firingPosted = false let resolutionPosted = false try { await postAlert(fetchImpl, options, firingAlert) firingPosted = true await waitForAlertState(fetchImpl, options, testId, true, runtime) const afterFiring = await waitForSuccessfulNotification(fetchImpl, options, baseline, 'firing', runtime) const resolvedAt = new Date(now()).toISOString() await postAlert(fetchImpl, options, { ...firingAlert, endsAt: resolvedAt }) resolutionPosted = true await waitForAlertState(fetchImpl, options, testId, false, runtime) const afterResolved = await waitForSuccessfulNotification(fetchImpl, options, afterFiring, 'resolved', runtime) return { status: 'passed', testId, receiver: options.receiver, alertmanagerUrl: options.alertmanagerUrl, startsAt, resolvedAt, firingWebhookAccepted: true, resolvedWebhookAccepted: true, notificationDelta: afterResolved.total - baseline.total, failedNotificationDelta: afterResolved.failed - baseline.failed, downstreamProcessingProven: false, } } catch (error) { if (firingPosted && !resolutionPosted) { try { await postAlert(fetchImpl, options, { ...firingAlert, endsAt: new Date(now()).toISOString() }) } catch (cleanupError) { throw new AlertmanagerSmokeError(`${error instanceof Error ? error.message : String(error)}; cleanup resolution also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`, { cause: error }) } } throw error } } async function main() { const options = parseAlertmanagerSmokeOptions() if (options.help) { process.stdout.write(usage()) return } const report = await runAlertmanagerSmoke(options) if (options.json) { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`) return } process.stdout.write(`Alertmanager webhook smoke passed.\nTest ID: ${report.testId}\nReceiver: ${report.receiver}\nFiring and resolved webhooks returned success; confirm downstream processing with the test ID.\n`) } if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { main().catch((error) => { process.stderr.write(`Alertmanager smoke failed: ${error instanceof Error ? error.message : String(error)}\n`) process.exitCode = 1 }) }