311 lines
12 KiB
JavaScript
Executable File
311 lines
12 KiB
JavaScript
Executable File
#!/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 <origin> Alertmanager origin; HTTP is loopback-only
|
|
--receiver <name> 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
|
|
})
|
|
}
|