feat: add service-scoped stack alert rules (#1681)

* feat: add service-scoped stack alert rules

Stack alerts can target one Compose service or all services. Breach timers
are per container and cooldowns are per service so a healthy sibling no
longer clears another container's timer or silences a different service.

* fix: gate remote scoped alert creates without losing the body

Remote hops skip JSON parsing so the proxy stream stays pipeable, which
left service_name invisible to the capability gate. Buffer POST /alerts
bodies for inspection, fail closed when the remote lacks the capability,
and rewrite the buffered bytes on forward. Restore alert-panel alt text
to match the unchanged screenshot.

* fix: bound remote alert body buffer and reject encoded JSON

Cap proxied POST /alerts buffering at the local 100KB JSON limit with
structured 413 cleanup, reject non-identity Content-Encoding with 415 so
compressed scoped bodies cannot bypass the mixed-version gate, and cover
oversized, chunked, and gzip regressions.

* fix: harden service-scoped alert delete, cooldown, and proxy gates

Reject non-digit alert ids, dual-write last_fired_at for rollback safety,
gate cooldown on persisted notification history, fail-fast oversized proxy
bodies with 413, and clarify Not in compose UI semantics.

* test: expect dispatchAlert persisted result in crash-safety cases

Update notification-routing assertions for the new { persisted } return
shape so CI matches the cooldown-gating contract.
This commit is contained in:
Anso
2026-07-23 17:57:04 -04:00
committed by GitHub
parent dd54a2e483
commit 85842cc547
32 changed files with 1736 additions and 135 deletions
@@ -59,6 +59,7 @@ export const CAPABILITIES = [
'stack-down-remove-volumes',
'guided-external-network-preflight',
'service-scoped-update',
'service-scoped-stack-alert',
] as const;
/**
@@ -88,6 +89,10 @@ export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes'
/** Capability for the nested per-service update/restore routes and the `effective-services` model they read. */
export const SERVICE_SCOPED_UPDATE_CAPABILITY = 'service-scoped-update' as const satisfies Capability;
/** Capability for nullable `service_name` on stack alert rules and per-service cooldown evaluation. */
export const SERVICE_SCOPED_STACK_ALERT_CAPABILITY =
'service-scoped-stack-alert' as const satisfies Capability;
/** Returns true when the string is a usable semver version. */
export function isValidVersion(v: string | null | undefined): v is string {
return !!v && v !== 'unknown' && v !== '0.0.0-dev' && !!semver.valid(v);
+72 -9
View File
@@ -101,6 +101,7 @@ function stringifyServicesJson(services: StackServiceStatus[], generation: numbe
export interface StackAlert {
id?: number;
stack_name: string;
service_name: string | null;
metric: string;
operator: string;
threshold: number;
@@ -1062,6 +1063,7 @@ export class DatabaseService {
this.migrateStackDossierHashes();
this.migrateGitSourceMultiFile();
this.migrateNodeUpdateSkips();
this.migrateStackAlertServiceScope();
// Reset the cache once at end of constructor in case any migration
// populated it via getGlobalSettings() and a subsequent migration
@@ -1098,6 +1100,7 @@ export class DatabaseService {
CREATE TABLE IF NOT EXISTS stack_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stack_name TEXT NOT NULL,
service_name TEXT,
metric TEXT NOT NULL,
operator TEXT NOT NULL,
threshold REAL NOT NULL,
@@ -1106,6 +1109,17 @@ export class DatabaseService {
last_fired_at INTEGER DEFAULT 0
);
-- FK cascade is declarative only: PRAGMA foreign_keys is never enabled
-- on this connection, so parent deletes must remove children explicitly
-- (see deleteStackAlert).
CREATE TABLE IF NOT EXISTS stack_alert_service_cooldowns (
alert_id INTEGER NOT NULL,
service_name TEXT NOT NULL,
last_fired_at INTEGER NOT NULL,
PRIMARY KEY (alert_id, service_name),
FOREIGN KEY (alert_id) REFERENCES stack_alerts(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS notification_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL DEFAULT 0,
@@ -2280,6 +2294,25 @@ export class DatabaseService {
}
}
private migrateStackAlertServiceScope(): void {
this.tryAddColumn('stack_alerts', 'service_name', 'TEXT');
try {
// FK is not enforced (foreign_keys pragma off); deleteStackAlert removes children.
this.db.prepare(`
CREATE TABLE IF NOT EXISTS stack_alert_service_cooldowns (
alert_id INTEGER NOT NULL,
service_name TEXT NOT NULL,
last_fired_at INTEGER NOT NULL,
PRIMARY KEY (alert_id, service_name),
FOREIGN KEY (alert_id) REFERENCES stack_alerts(id) ON DELETE CASCADE
)
`).run();
} catch (e) {
console.error('[DatabaseService] stack_alert_service_cooldowns migration failed:', (e as Error).message);
throw e;
}
}
private migrateScanPolicyFleetColumns(): void {
this.tryAddColumn('scan_policies', 'node_identity', "TEXT NOT NULL DEFAULT ''");
this.tryAddColumn('scan_policies', 'replicated_from_control', 'INTEGER NOT NULL DEFAULT 0');
@@ -3075,22 +3108,19 @@ export class DatabaseService {
// --- Stack Alerts ---
public getStackAlerts(stackName?: string): StackAlert[] {
let stmt;
if (stackName) {
stmt = this.db.prepare('SELECT * FROM stack_alerts WHERE stack_name = ?');
return stmt.all(stackName) as StackAlert[];
} else {
stmt = this.db.prepare('SELECT * FROM stack_alerts');
return stmt.all() as StackAlert[];
return this.db.prepare('SELECT * FROM stack_alerts WHERE stack_name = ?').all(stackName) as StackAlert[];
}
return this.db.prepare('SELECT * FROM stack_alerts').all() as StackAlert[];
}
public addStackAlert(alert: StackAlert): StackAlert {
const stmt = this.db.prepare(
'INSERT INTO stack_alerts (stack_name, metric, operator, threshold, duration_mins, cooldown_mins, last_fired_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO stack_alerts (stack_name, service_name, metric, operator, threshold, duration_mins, cooldown_mins, last_fired_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
const result = stmt.run(
alert.stack_name,
alert.service_name ?? null,
alert.metric,
alert.operator,
alert.threshold,
@@ -3101,9 +3131,16 @@ export class DatabaseService {
return this.db.prepare('SELECT * FROM stack_alerts WHERE id = ?').get(result.lastInsertRowid) as StackAlert;
}
/**
* Delete an alert and its per-service cooldown rows.
* SQLite foreign_keys is not enabled here, so child rows are removed
* explicitly rather than relying on ON DELETE CASCADE.
*/
public deleteStackAlert(id: number): void {
const stmt = this.db.prepare('DELETE FROM stack_alerts WHERE id = ?');
stmt.run(id);
this.transaction(() => {
this.deleteStackAlertServiceCooldowns(id);
this.db.prepare('DELETE FROM stack_alerts WHERE id = ?').run(id);
});
}
public updateStackAlertLastFired(id: number, timestamp: number): void {
@@ -3111,6 +3148,32 @@ export class DatabaseService {
stmt.run(timestamp, id);
}
public getStackAlertServiceCooldown(alertId: number, serviceName: string): number | null {
const row = this.db.prepare(
'SELECT last_fired_at FROM stack_alert_service_cooldowns WHERE alert_id = ? AND service_name = ?'
).get(alertId, serviceName) as { last_fired_at: number } | undefined;
return row?.last_fired_at ?? null;
}
public hasAnyStackAlertServiceCooldown(alertId: number): boolean {
const row = this.db.prepare(
'SELECT 1 AS present FROM stack_alert_service_cooldowns WHERE alert_id = ? LIMIT 1'
).get(alertId) as { present: number } | undefined;
return !!row;
}
public upsertStackAlertServiceCooldown(alertId: number, serviceName: string, timestamp: number): void {
this.db.prepare(`
INSERT INTO stack_alert_service_cooldowns (alert_id, service_name, last_fired_at)
VALUES (?, ?, ?)
ON CONFLICT(alert_id, service_name) DO UPDATE SET last_fired_at = excluded.last_fired_at
`).run(alertId, serviceName, timestamp);
}
public deleteStackAlertServiceCooldowns(alertId: number): void {
this.db.prepare('DELETE FROM stack_alert_service_cooldowns WHERE alert_id = ?').run(alertId);
}
// --- Auto-Heal Policies ---
public getAutoHealPolicies(stackName?: string, nodeId?: number): AutoHealPolicy[] {
+3 -3
View File
@@ -735,15 +735,15 @@ export class DockerEventService {
}
private async emitError(category: NotificationCategory, message: string, stackName?: string, containerName?: string, systemOnly = false): Promise<void> {
return this.notifier.dispatchAlert('error', category, message, this.buildAlertOptions(stackName, containerName, systemOnly));
await this.notifier.dispatchAlert('error', category, message, this.buildAlertOptions(stackName, containerName, systemOnly));
}
private async emitWarning(category: NotificationCategory, message: string, stackName?: string, containerName?: string): Promise<void> {
return this.notifier.dispatchAlert('warning', category, message, this.buildAlertOptions(stackName, containerName));
await this.notifier.dispatchAlert('warning', category, message, this.buildAlertOptions(stackName, containerName));
}
private async emitInfo(category: NotificationCategory, message: string, stackName?: string, containerName?: string): Promise<void> {
return this.notifier.dispatchAlert('info', category, message, this.buildAlertOptions(stackName, containerName));
await this.notifier.dispatchAlert('info', category, message, this.buildAlertOptions(stackName, containerName));
}
// ========================================================================
+120 -45
View File
@@ -32,6 +32,39 @@ const getOperatorPhrase = (operator: string): string => {
return `triggered the operator ${operator}`;
};
/** Sentinel for containers without a Compose service label. Not a valid API target. */
const UNLABELED_SERVICE_KEY = '_unlabeled';
function displayServiceName(serviceName: string): string {
return serviceName === UNLABELED_SERVICE_KEY ? 'unknown service' : serviceName;
}
function parseAlertBreachKey(key: string): { ruleId: number; containerId: string } | null {
const sep = key.indexOf(':');
if (sep < 0) return null;
const ruleId = Number(key.slice(0, sep));
if (!Number.isFinite(ruleId)) return null;
return { ruleId, containerId: key.slice(sep + 1) };
}
/** Per-service cooldown timestamp, with pre-migration fallback to rule.last_fired_at. */
function resolveStackAlertLastFired(rule: StackAlert, serviceName: string, db: DatabaseService): number {
const ruleId = rule.id!;
let lastFired = db.getStackAlertServiceCooldown(ruleId, serviceName);
if (lastFired == null && !db.hasAnyStackAlertServiceCooldown(ruleId) && (rule.last_fired_at || 0) > 0) {
lastFired = rule.last_fired_at || 0;
}
return lastFired || 0;
}
function normalizeContainerName(container: { Id: string; Names?: string[] }): string {
const raw = container.Names?.[0];
if (raw && raw.length > 0) {
return raw.startsWith('/') ? raw.slice(1) : raw;
}
return container.Id.slice(0, 12);
}
/** Shape of the JSON returned by Docker container stats (stream: false). */
interface DockerContainerStats {
cpu_stats?: {
@@ -153,21 +186,18 @@ export class MonitorService {
private janitorConsecutiveTimeouts = 0;
private janitorBreakerUntil = 0;
// Track the duration a specific stack alert rule has been in breach state
// key: rule_id, value: AlertState
private activeBreaches = new Map<number, AlertState>();
// Track the duration a specific stack alert rule+container has been in breach
// key: `${ruleId}:${containerId}`, value: AlertState
private activeBreaches = new Map<string, AlertState>();
// Track previous network counters per container for rate calculation.
// key: container_id, value: { rx bytes, tx bytes, sample timestamp }
private previousNetworkStats = new Map<string, { rx: number; tx: number; ts: number }>();
// Per-cycle dispatch dedup. Stack rules are shared across every container
// in the stack, so parallel processContainer calls can race past the
// cooldown check (DB write happens after the awaited dispatch) and fire
// the same alert N times on first breach. The synchronous check-and-add
// below is atomic in JS between awaits, so only the first worker wins.
// Per-cycle dispatch dedup keyed by rule+service so replicas of the same
// Compose service do not fire N times, while different services can.
// Reset at the start of each evaluateStackAlerts call.
private firedThisCycle = new Set<number>();
private firedThisCycle = new Set<string>();
// Crash and healthcheck detection live in DockerEventService (event-driven,
// causal classification). MonitorService no longer polls for container
@@ -604,6 +634,10 @@ export class MonitorService {
else alertsByStack.set(a.stack_name, [a]);
}
const activeRuleIds = new Set(alerts.map(a => a.id!));
const allCurrentIds = new Set<string>();
let enumerationFailed = false;
for (const node of nodes) {
if (!node.id) continue;
// Remote nodes are self-monitoring - skip direct Docker access
@@ -623,22 +657,39 @@ export class MonitorService {
(container) => this.processContainer(node, container, alertsByStack, docker, db),
);
for (const c of containers) allCurrentIds.add(c.Id);
// Clean up stale network stats for containers on this node that no longer run
if (this.previousNetworkStats.size > containers.length * 2) {
const currentIds = new Set(containers.map((c: { Id: string }) => c.Id));
const nodeIds = new Set(containers.map((c: { Id: string }) => c.Id));
for (const key of this.previousNetworkStats.keys()) {
if (!currentIds.has(key)) this.previousNetworkStats.delete(key);
if (!nodeIds.has(key)) this.previousNetworkStats.delete(key);
}
}
} catch (err) {
// Enumeration failed: preserve existing breach timers.
enumerationFailed = true;
console.error(`Error fetching containers for node ${node.name}`, err);
}
}
// Clean up stale breach trackers for rules that have been deleted
const activeRuleIds = new Set(alerts.map(a => a.id!));
for (const key of this.activeBreaches.keys()) {
if (!activeRuleIds.has(key)) {
// After successful enumeration(s), drop breach timers for containers
// that are no longer running. Skip when any local enumeration failed
// so a transient Docker error cannot reset duration timers.
if (!enumerationFailed) {
for (const key of [...this.activeBreaches.keys()]) {
const parsed = parseAlertBreachKey(key);
if (parsed && activeRuleIds.has(parsed.ruleId) && !allCurrentIds.has(parsed.containerId)) {
this.activeBreaches.delete(key);
}
}
}
// Clean up in-memory breach trackers for rules that have been deleted.
// Persisted cooldowns are removed only via transactional deleteStackAlert.
for (const key of [...this.activeBreaches.keys()]) {
const parsed = parseAlertBreachKey(key);
if (parsed && !activeRuleIds.has(parsed.ruleId)) {
this.activeBreaches.delete(key);
}
}
@@ -666,12 +717,14 @@ export class MonitorService {
*/
private async processContainer(
node: Node,
container: { Id: string; Labels?: Record<string, string> },
container: { Id: string; Names?: string[]; Labels?: Record<string, string> },
alertsByStack: Map<string, StackAlert[]>,
docker: DockerController,
db: DatabaseService,
): Promise<void> {
const stackName = container.Labels?.['com.docker.compose.project'] || 'system';
const serviceName = container.Labels?.['com.docker.compose.service'] || UNLABELED_SERVICE_KEY;
const containerName = normalizeContainerName(container);
try {
const rawStats = await withTimeout(
@@ -727,7 +780,11 @@ export class MonitorService {
});
for (const rule of stackAlerts) {
if (rule.service_name && rule.service_name !== serviceName) continue;
const ruleId = rule.id!;
const breachKey = `${ruleId}:${container.Id}`;
const cooldownKey = `${ruleId}:${serviceName}`;
const currentValue = metrics[rule.metric as keyof typeof metrics];
if (currentValue === undefined) continue;
@@ -735,57 +792,75 @@ export class MonitorService {
const isBreaching = this.evaluateCondition(currentValue, rule.operator, rule.threshold);
if (isBreaching) {
if (!this.activeBreaches.has(ruleId)) {
this.activeBreaches.set(ruleId, { breachStartedAt: Date.now() });
if (isDebugEnabled()) console.log(`[Monitor:diag] Breach entered: rule ${ruleId} (${rule.metric} ${rule.operator} ${rule.threshold}) on stack "${rule.stack_name}"`);
if (!this.activeBreaches.has(breachKey)) {
this.activeBreaches.set(breachKey, { breachStartedAt: Date.now() });
if (isDebugEnabled()) console.log(`[Monitor:diag] Breach entered: rule ${ruleId} (${rule.metric} ${rule.operator} ${rule.threshold}) on stack "${rule.stack_name}" service "${serviceName}" container "${containerName}"`);
}
const breachState = this.activeBreaches.get(ruleId)!;
const breachState = this.activeBreaches.get(breachKey)!;
const durationMs = Date.now() - breachState.breachStartedAt;
const requiredDurationMs = rule.duration_mins * 60 * 1000;
if (durationMs >= requiredDurationMs) {
const timeSinceLastFired = Date.now() - (rule.last_fired_at || 0);
const timeSinceLastFired = Date.now() - resolveStackAlertLastFired(rule, serviceName, db);
const requiredCooldownMs = rule.cooldown_mins * 60 * 1000;
if (timeSinceLastFired >= requiredCooldownMs) {
// Claim this rule for the cycle before awaiting
// dispatch. The check-and-add is synchronous, so
// sibling workers evaluating the same shared rule
// see the claim and skip — preventing N-fire when
// multiple containers in one stack all breach.
if (this.firedThisCycle.has(ruleId)) {
if (isDebugEnabled()) console.log(`[Monitor:diag] Skipping duplicate dispatch for rule ${ruleId} (already fired this cycle by sibling container)`);
} else {
this.firedThisCycle.add(ruleId);
// Claim this rule+service for the cycle before awaiting
// dispatch so replicas of the same service skip.
if (this.firedThisCycle.has(cooldownKey)) {
if (isDebugEnabled()) console.log(`[Monitor:diag] Skipping duplicate dispatch for rule ${ruleId} service "${serviceName}" (already fired this cycle by sibling replica)`);
continue;
}
const { name: metricName, unit } = getMetricDetails(rule.metric);
const operatorPhrase = getOperatorPhrase(rule.operator);
this.firedThisCycle.add(cooldownKey);
const safeCurrent = typeof currentValue === 'number' ? Number(currentValue.toFixed(2)) : currentValue;
const safeThreshold = typeof rule.threshold === 'number' ? Number(rule.threshold.toFixed(2)) : rule.threshold;
const { name: metricName, unit } = getMetricDetails(rule.metric);
const operatorPhrase = getOperatorPhrase(rule.operator);
// Node-neutral body: the hub bell badge attributes remotes.
const message = `The **${metricName}** for **${rule.stack_name}** ${operatorPhrase} **${safeThreshold}${unit}** (Currently: ${safeCurrent}${unit}).`;
const safeCurrent = typeof currentValue === 'number' ? Number(currentValue.toFixed(2)) : currentValue;
const safeThreshold = typeof rule.threshold === 'number' ? Number(rule.threshold.toFixed(2)) : rule.threshold;
const serviceLabel = displayServiceName(serviceName);
console.log(`[MonitorService] Alert fired: rule ${ruleId} on stack "${rule.stack_name}": ${metricName} ${operatorPhrase} ${safeThreshold}${unit}`);
await NotificationService.getInstance().dispatchAlert(
// Node-neutral body: the hub bell badge attributes remotes.
const message = `The **${metricName}** for **${serviceLabel}** in **${rule.stack_name}** (container **${containerName}**) ${operatorPhrase} **${safeThreshold}${unit}** (Currently: ${safeCurrent}${unit}).`;
try {
const { persisted } = await NotificationService.getInstance().dispatchAlert(
'warning',
'monitor_alert',
message,
{ stackName: rule.stack_name, actor: 'system:monitor' },
{ stackName: rule.stack_name, containerName, actor: 'system:monitor' },
);
if (!persisted) {
// History was not written; do not advance cooldown or we silence retries.
this.firedThisCycle.delete(cooldownKey);
console.error(
`[MonitorService] Alert history not persisted for rule ${ruleId} service "${serviceName}"; cooldown not advanced`,
);
continue;
}
const firedAt = Date.now();
// Dual-write: per-service row for current code, parent last_fired_at
// so a downgrade that only reads the parent column still has a floor.
db.upsertStackAlertServiceCooldown(ruleId, serviceName, firedAt);
db.updateStackAlertLastFired(ruleId, firedAt);
console.log(`[MonitorService] Alert fired: rule ${ruleId} on stack "${rule.stack_name}" service "${serviceName}": ${metricName} ${operatorPhrase} ${safeThreshold}${unit}`);
} catch (fireErr) {
this.firedThisCycle.delete(cooldownKey);
console.error(
`[MonitorService] Failed to fire alert rule ${ruleId} service "${serviceName}" container "${containerName}":`,
fireErr,
);
db.updateStackAlertLastFired(ruleId, Date.now());
}
} else if (isDebugEnabled()) {
console.log(`[Monitor:diag] Cooldown active for rule ${ruleId}: ${Math.round((requiredCooldownMs - timeSinceLastFired) / 1000)}s remaining`);
console.log(`[Monitor:diag] Cooldown active for rule ${ruleId} service "${serviceName}": ${Math.round((requiredCooldownMs - timeSinceLastFired) / 1000)}s remaining`);
}
}
} else {
if (this.activeBreaches.has(ruleId)) {
if (isDebugEnabled()) console.log(`[Monitor:diag] Breach cleared: rule ${ruleId} on stack "${rule.stack_name}"`);
this.activeBreaches.delete(ruleId);
if (this.activeBreaches.has(breachKey)) {
if (isDebugEnabled()) console.log(`[Monitor:diag] Breach cleared: rule ${ruleId} on stack "${rule.stack_name}" container "${containerName}"`);
this.activeBreaches.delete(breachKey);
}
}
}
+11 -5
View File
@@ -181,7 +181,7 @@ export class NotificationService {
category: NotificationCategory,
message: string,
options?: { stackName?: string; containerName?: string; actor?: string },
) {
): Promise<{ persisted: boolean }> {
const t0 = Date.now();
const { stackName, containerName, actor } = options ?? {};
@@ -191,6 +191,8 @@ export class NotificationService {
// WebSocket broadcast can all throw on an unhealthy DB, which would
// otherwise surface as an unhandledRejection and take the process down.
// The whole body is wrapped so the worst case is a dropped notification.
// Callers that gate cooldowns on history use `persisted` (true only after the row write).
let wroteHistory = false;
try {
// Internal writes use the middleware default so they share a row key
// with user-initiated requests; otherwise the UI and monitors split
@@ -216,11 +218,12 @@ export class NotificationService {
container_name: containerName,
actor_username: actor ?? null,
});
wroteHistory = true;
StackActivityMetricsService.getInstance().record(localNodeId, 'write', Date.now() - t0, true);
} catch (err) {
StackActivityMetricsService.getInstance().record(localNodeId, 'write', Date.now() - t0, false);
console.error('[Notify] Failed to persist notification:', err);
return;
return { persisted: false };
}
// Separate [StackActivity:diag] namespace from the [Notify:diag] lines
// below so a single grep can pull every per-stack timeline write across
@@ -273,7 +276,7 @@ export class NotificationService {
}
if (suppressExternal) {
return;
return { persisted: wroteHistory };
}
// Resolve retry extras once for this dispatch (shared by all destinations).
@@ -298,14 +301,14 @@ export class NotificationService {
)
);
this.recordDispatchErrors(notification.id!, errors);
return;
return { persisted: wroteHistory };
}
// 4. Fall back to this instance's agents (keyed by this instance's default node id).
const agents = this.dbService.getEnabledAgents(localNodeId);
if (agents.length === 0) {
if (isDebugEnabled()) console.log('[Notify:diag] No routes or agents matched; skipping external dispatch');
return;
return { persisted: wroteHistory };
}
if (isDebugEnabled()) console.log(`[Notify:diag] Falling back to ${agents.length} global agent(s)`);
@@ -322,8 +325,11 @@ export class NotificationService {
)
);
this.recordDispatchErrors(notification.id!, errors);
return { persisted: wroteHistory };
} catch (err) {
console.error('[Notify] dispatchAlert failed:', err);
// History may already be written; callers must not treat that as a miss.
return { persisted: wroteHistory };
}
}