Files
AI_Video_Factory/scripts/alertmanager-smoke.mjs
youbin 36f466ba11
Some checks failed
CI / Migrations, tests, build, and audit (push) Failing after 10m7s
CI / Production gate and container images (push) Successful in 20m35s
Release v1.0.0
2026-07-31 14:21:43 +08:00

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
})
}