@celilo/cli 0.13.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +3 -0
- package/CELILO_SUBSYSTEMS.md +71 -2
- package/docs/ALERTING.md +298 -0
- package/docs/INDEX.md +103 -0
- package/drizzle/0016_trusted_sources.sql +10 -0
- package/drizzle/0017_alerting.sql +127 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +3 -2
- package/schemas/system_config.json +9 -0
- package/src/ansible/inventory.ts +2 -2
- package/src/cli/commands/alerts-act.ts +107 -0
- package/src/cli/commands/alerts-list.ts +62 -0
- package/src/cli/commands/alerts-poll.ts +129 -0
- package/src/cli/commands/alerts-sweep.ts +156 -0
- package/src/cli/commands/module-list.ts +50 -3
- package/src/cli/commands/monitor.ts +178 -0
- package/src/cli/commands/notify-config.ts +453 -0
- package/src/cli/commands/system-audit.ts +2 -0
- package/src/cli/commands/system-update.ts +1 -0
- package/src/cli/completion.ts +26 -0
- package/src/cli/generate-zsh-completion.ts +2 -0
- package/src/cli/index.ts +58 -0
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/db/schema.ts +358 -0
- package/src/hooks/capability-loader.ts +158 -46
- package/src/hooks/capability-map-coverage.test.ts +101 -0
- package/src/manifest/schema.ts +60 -1
- package/src/services/alerting/ack.test.ts +212 -0
- package/src/services/alerting/ack.ts +119 -0
- package/src/services/alerting/builtin-monitors.test.ts +132 -0
- package/src/services/alerting/builtin-monitors.ts +84 -0
- package/src/services/alerting/builtin-source.ts +82 -0
- package/src/services/alerting/coverage-source.ts +38 -0
- package/src/services/alerting/deferral.test.ts +161 -0
- package/src/services/alerting/delivery-loop.test.ts +396 -0
- package/src/services/alerting/deploy-hooks.test.ts +125 -0
- package/src/services/alerting/deploy-hooks.ts +111 -0
- package/src/services/alerting/escalation.test.ts +207 -0
- package/src/services/alerting/escalation.ts +151 -0
- package/src/services/alerting/format.test.ts +193 -0
- package/src/services/alerting/format.ts +150 -0
- package/src/services/alerting/health-coverage.ts +81 -0
- package/src/services/alerting/inbound-poller.test.ts +298 -0
- package/src/services/alerting/inbound-poller.ts +236 -0
- package/src/services/alerting/inbound.test.ts +201 -0
- package/src/services/alerting/inbound.ts +112 -0
- package/src/services/alerting/interview-responder.test.ts +169 -0
- package/src/services/alerting/interview-responder.ts +158 -0
- package/src/services/alerting/keys.test.ts +155 -0
- package/src/services/alerting/keys.ts +190 -0
- package/src/services/alerting/monitors.ts +185 -0
- package/src/services/alerting/notification-responder.test.ts +290 -0
- package/src/services/alerting/notification-responder.ts +260 -0
- package/src/services/alerting/notifier.ts +219 -0
- package/src/services/alerting/people.ts +178 -0
- package/src/services/alerting/quiet-hours.test.ts +140 -0
- package/src/services/alerting/quiet-hours.ts +99 -0
- package/src/services/alerting/reconcile.test.ts +190 -0
- package/src/services/alerting/reconcile.ts +166 -0
- package/src/services/alerting/run-monitor.test.ts +185 -0
- package/src/services/alerting/run-monitor.ts +177 -0
- package/src/services/alerting/store.test.ts +222 -0
- package/src/services/alerting/store.ts +289 -0
- package/src/services/alerting/suppression.test.ts +228 -0
- package/src/services/alerting/suppression.ts +142 -0
- package/src/services/alerting/sweep-runner.test.ts +229 -0
- package/src/services/alerting/sweep-runner.ts +204 -0
- package/src/services/alerting/sweep.test.ts +61 -0
- package/src/services/alerting/sweep.ts +41 -0
- package/src/services/alerting/tokens.test.ts +152 -0
- package/src/services/alerting/tokens.ts +119 -0
- package/src/services/alerting/transport-loader.ts +48 -0
- package/src/services/aspect-runner.ts +2 -2
- package/src/services/audit/index.test.ts +1 -0
- package/src/services/audit/index.ts +3 -0
- package/src/services/audit/trusted-sources.test.ts +137 -0
- package/src/services/audit/trusted-sources.ts +124 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/firewall-reach.ts +83 -0
- package/src/services/health-runner.test.ts +50 -0
- package/src/services/health-runner.ts +116 -82
- package/src/services/module-deploy.ts +32 -3
- package/src/services/ssh-key-manager.test.ts +14 -0
- package/src/services/ssh-key-manager.ts +12 -0
- package/src/services/system-config-validator.test.ts +31 -1
- package/src/services/trusted-sources.test.ts +221 -0
- package/src/services/trusted-sources.ts +159 -0
- package/src/services/update/orchestrator.test.ts +1 -0
- package/src/templates/generator.ts +6 -29
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering alerts for the operator.
|
|
3
|
+
*
|
|
4
|
+
* A flat table rather than a TUI: alert triage is a list-scan, not an
|
|
5
|
+
* exploration, and a table is greppable, pipeable, and renders unchanged over
|
|
6
|
+
* the Remote API. See design D14.
|
|
7
|
+
*
|
|
8
|
+
* Pure — takes rows, returns strings — so the wording and the column that says
|
|
9
|
+
* "explained by something upstream" can be asserted without a database.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Alert, AlertState } from '../../db/schema';
|
|
13
|
+
|
|
14
|
+
export interface AlertRow {
|
|
15
|
+
key: string;
|
|
16
|
+
/** Display label, not the stored state — `firing` renders as `FIRING`. */
|
|
17
|
+
state: string;
|
|
18
|
+
severity: string;
|
|
19
|
+
age: string;
|
|
20
|
+
detail: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const STATE_LABEL: Record<AlertState, string> = {
|
|
24
|
+
pending: 'pending',
|
|
25
|
+
firing: 'FIRING',
|
|
26
|
+
acked: 'acked',
|
|
27
|
+
suppressed: 'suppressed',
|
|
28
|
+
resolved: 'resolved',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compact humanised age. Deliberately coarse — an operator scanning a list
|
|
33
|
+
* needs "is this new or has it been broken all day", not seconds.
|
|
34
|
+
*/
|
|
35
|
+
export function humaniseAge(from: Date, now: Date): string {
|
|
36
|
+
const seconds = Math.max(0, Math.floor((now.getTime() - from.getTime()) / 1000));
|
|
37
|
+
if (seconds < 60) return `${seconds}s`;
|
|
38
|
+
const minutes = Math.floor(seconds / 60);
|
|
39
|
+
if (minutes < 60) return `${minutes}m`;
|
|
40
|
+
const hours = Math.floor(minutes / 60);
|
|
41
|
+
if (hours < 24) return `${hours}h`;
|
|
42
|
+
return `${Math.floor(hours / 24)}d`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Time REMAINING, rounded up.
|
|
47
|
+
*
|
|
48
|
+
* `humaniseAge` floors, which is right for elapsed time and wrong for a
|
|
49
|
+
* countdown: with 1h59m left, "1h" reads as though a 2h silence were nearly
|
|
50
|
+
* over. Rounding up never overstates how soon an alert comes back.
|
|
51
|
+
*/
|
|
52
|
+
export function humaniseRemaining(until: Date, now: Date): string {
|
|
53
|
+
const seconds = Math.max(0, Math.ceil((until.getTime() - now.getTime()) / 1000));
|
|
54
|
+
if (seconds < 60) return `${seconds}s`;
|
|
55
|
+
const minutes = Math.ceil(seconds / 60);
|
|
56
|
+
if (minutes < 60) return `${minutes}m`;
|
|
57
|
+
const hours = Math.ceil(minutes / 60);
|
|
58
|
+
if (hours < 24) return `${hours}h`;
|
|
59
|
+
return `${Math.ceil(hours / 24)}d`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The most useful thing to say about an alert beyond its state.
|
|
64
|
+
*
|
|
65
|
+
* Ordering is by what an operator would act on: an acknowledgement tells them
|
|
66
|
+
* someone already has it, a suppression tells them not to chase this one, and
|
|
67
|
+
* a silence tells them it is deliberate. Only when none apply does the alert's
|
|
68
|
+
* own message earn the column.
|
|
69
|
+
*/
|
|
70
|
+
export function alertDetail(alert: Alert, now: Date): string {
|
|
71
|
+
if (alert.state === 'acked') return 'acked';
|
|
72
|
+
if (alert.state === 'suppressed') {
|
|
73
|
+
return alert.suppressedByWindowId ? 'deploy in progress' : 'explained by an upstream alert';
|
|
74
|
+
}
|
|
75
|
+
if (alert.silencedUntil && alert.silencedUntil > now) {
|
|
76
|
+
return `silenced for ${humaniseRemaining(alert.silencedUntil, now)}`;
|
|
77
|
+
}
|
|
78
|
+
if (alert.awaitingConfirmation) return 'awaiting confirmation';
|
|
79
|
+
if (alert.state === 'pending' && alert.graceUntil > now) return 'within grace window';
|
|
80
|
+
return alert.message;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function toAlertRow(alert: Alert, now: Date): AlertRow {
|
|
84
|
+
return {
|
|
85
|
+
key: alert.key,
|
|
86
|
+
state: STATE_LABEL[alert.state],
|
|
87
|
+
severity: alert.severity,
|
|
88
|
+
age: humaniseAge(alert.firstFiredAt, now),
|
|
89
|
+
detail: alertDetail(alert, now),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Render rows as a fixed-width table.
|
|
95
|
+
*
|
|
96
|
+
* Sorted most-actionable first: firing alerts an operator must deal with, then
|
|
97
|
+
* pending, then everything already accounted for. Within a group, oldest first
|
|
98
|
+
* — a problem that has been burning for a day outranks one from a minute ago.
|
|
99
|
+
*/
|
|
100
|
+
const STATE_ORDER: Record<string, number> = {
|
|
101
|
+
FIRING: 0,
|
|
102
|
+
pending: 1,
|
|
103
|
+
acked: 2,
|
|
104
|
+
suppressed: 3,
|
|
105
|
+
resolved: 4,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export function sortAlertRows(rows: AlertRow[]): AlertRow[] {
|
|
109
|
+
return [...rows].sort((a, b) => {
|
|
110
|
+
const byState = (STATE_ORDER[a.state] ?? 9) - (STATE_ORDER[b.state] ?? 9);
|
|
111
|
+
return byState !== 0 ? byState : a.key.localeCompare(b.key);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function renderAlertTable(rows: AlertRow[]): string {
|
|
116
|
+
if (rows.length === 0) return 'No alerts.';
|
|
117
|
+
|
|
118
|
+
const sorted = sortAlertRows(rows);
|
|
119
|
+
const headers = ['KEY', 'STATE', 'SEVERITY', 'AGE', 'DETAIL'];
|
|
120
|
+
const columns: (keyof AlertRow)[] = ['key', 'state', 'severity', 'age', 'detail'];
|
|
121
|
+
const widths = columns.map((column, i) =>
|
|
122
|
+
Math.max(headers[i].length, ...sorted.map((row) => row[column].length)),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const line = (cells: string[]) =>
|
|
126
|
+
cells
|
|
127
|
+
.map((cell, i) => (i === cells.length - 1 ? cell : cell.padEnd(widths[i])))
|
|
128
|
+
.join(' ')
|
|
129
|
+
.trimEnd();
|
|
130
|
+
|
|
131
|
+
return [line(headers), ...sorted.map((row) => line(columns.map((c) => row[c])))].join('\n');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The observed-health cell for `celilo module list`.
|
|
136
|
+
*
|
|
137
|
+
* "Not observed" is a finding, not a blank: a module nothing is watching is
|
|
138
|
+
* unverified infrastructure, and rendering it as healthy is the lie this column
|
|
139
|
+
* exists to stop telling. See design D15.
|
|
140
|
+
*/
|
|
141
|
+
export function moduleHealthCell(input: {
|
|
142
|
+
monitored: boolean;
|
|
143
|
+
firingCount: number;
|
|
144
|
+
suppressed: boolean;
|
|
145
|
+
}): string {
|
|
146
|
+
if (!input.monitored) return 'not observed';
|
|
147
|
+
if (input.suppressed) return 'suppressed';
|
|
148
|
+
if (input.firingCount > 0) return `${input.firingCount} firing`;
|
|
149
|
+
return 'ok';
|
|
150
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health coverage — which deployed modules nothing is watching.
|
|
3
|
+
*
|
|
4
|
+
* A module that has never been successfully checked is not a neutral absence
|
|
5
|
+
* of information. It is unverified infrastructure, and it fails in exactly the
|
|
6
|
+
* way a broken module does, only later and with less warning. Once checks are
|
|
7
|
+
* scheduled this becomes a durable condition rather than a transient one: a
|
|
8
|
+
* monitored module resolves to healthy or failing within one interval, so
|
|
9
|
+
* anything still unobserved is structurally blind.
|
|
10
|
+
*
|
|
11
|
+
* Two ways that happens, and both are worth saying out loud:
|
|
12
|
+
* - the module ships no `health_check` hook, so it can never be verified;
|
|
13
|
+
* - it has one, but no enabled monitor schedules it.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately cheap: this reads the module roster against the monitor set and
|
|
16
|
+
* contacts nothing. It is safe to run on every sweep.
|
|
17
|
+
*
|
|
18
|
+
* See openspec/changes/add-alerting/design.md D15.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ModuleState } from '../../db/schema';
|
|
22
|
+
import { type FailingKey, builtinAlertKey } from './keys';
|
|
23
|
+
|
|
24
|
+
export const HEALTH_COVERAGE_CHECK = 'health_coverage';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* States in which a module is live enough to be worth watching. A module still
|
|
28
|
+
* being imported or configured has nothing deployed to observe yet, so an
|
|
29
|
+
* absent monitor there is expected rather than a gap.
|
|
30
|
+
*/
|
|
31
|
+
const DEPLOYED_STATES: ReadonlySet<ModuleState> = new Set<ModuleState>(['INSTALLED', 'VERIFIED']);
|
|
32
|
+
|
|
33
|
+
export interface ModuleCoverageInput {
|
|
34
|
+
id: string;
|
|
35
|
+
state: ModuleState;
|
|
36
|
+
/** Whether the manifest declares a `health_check` hook at all. */
|
|
37
|
+
hasHealthCheckHook: boolean;
|
|
38
|
+
/** Whether an ENABLED monitor schedules that hook. */
|
|
39
|
+
hasEnabledMonitor: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Findings are `warning` severity: a coverage gap is real, but it is not an
|
|
44
|
+
* outage and must not wake anyone. It shows up in `celilo alerts` and in the
|
|
45
|
+
* `celilo module list` health column, where it reads as "not observed" rather
|
|
46
|
+
* than being silently rendered as healthy.
|
|
47
|
+
*/
|
|
48
|
+
export function healthCoverageFailingKeys(modules: ModuleCoverageInput[]): FailingKey[] {
|
|
49
|
+
const failing: FailingKey[] = [];
|
|
50
|
+
for (const module of modules) {
|
|
51
|
+
if (!DEPLOYED_STATES.has(module.state)) continue;
|
|
52
|
+
|
|
53
|
+
const key = builtinAlertKey(HEALTH_COVERAGE_CHECK, 'module', module.id);
|
|
54
|
+
|
|
55
|
+
if (!module.hasHealthCheckHook) {
|
|
56
|
+
failing.push({
|
|
57
|
+
key,
|
|
58
|
+
severity: 'warning',
|
|
59
|
+
message: `${module.id} declares no health_check hook — it can never be verified`,
|
|
60
|
+
details:
|
|
61
|
+
'celilo has no way to confirm this module works. Add a health_check\n' +
|
|
62
|
+
'hook to its manifest, or accept that failures here surface only when\n' +
|
|
63
|
+
'something downstream breaks.',
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!module.hasEnabledMonitor) {
|
|
69
|
+
failing.push({
|
|
70
|
+
key,
|
|
71
|
+
severity: 'warning',
|
|
72
|
+
message: `${module.id} has a health_check hook but nothing schedules it`,
|
|
73
|
+
details:
|
|
74
|
+
'The module can be verified on demand but is not being watched.\n' +
|
|
75
|
+
'Create a monitor for it, or set hooks.health_check.interval in the\n' +
|
|
76
|
+
'module manifest so one is created on deploy.',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return failing;
|
|
81
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type { InboundMessage } from '@celilo/capabilities';
|
|
6
|
+
import { eq } from 'drizzle-orm';
|
|
7
|
+
import type { DbClient } from '../../db/client';
|
|
8
|
+
import { type Route, alerts, modules, monitors, notificationDeliveries } from '../../db/schema';
|
|
9
|
+
import { setupTestDatabase } from '../../test-utils/setup-test-db';
|
|
10
|
+
import { type InboundPollDeps, pollInbound, transportsWithRoutes } from './inbound-poller';
|
|
11
|
+
import { moduleCheckAlertKey } from './keys';
|
|
12
|
+
import { createPerson, createRoute } from './people';
|
|
13
|
+
import { mintDelivery } from './tokens';
|
|
14
|
+
|
|
15
|
+
const NOW = new Date('2026-07-28T03:00:00Z');
|
|
16
|
+
const KEY = moduleCheckAlertKey('caddy', 'disk-space');
|
|
17
|
+
const PETER = '+15550001';
|
|
18
|
+
const WIFE = '+15550002';
|
|
19
|
+
const STRANGER = '+15559999';
|
|
20
|
+
|
|
21
|
+
describe('pollInbound', () => {
|
|
22
|
+
let dir: string;
|
|
23
|
+
let db: DbClient;
|
|
24
|
+
let peterRoute: Route;
|
|
25
|
+
let wifeRoute: Route;
|
|
26
|
+
const cursors = new Map<string, string | null>();
|
|
27
|
+
|
|
28
|
+
function deps(messages: InboundMessage[], over: Partial<InboundPollDeps> = {}): InboundPollDeps {
|
|
29
|
+
return {
|
|
30
|
+
receiveFrom: async () => ({ messages, cursor: 'cursor-1' }),
|
|
31
|
+
readCursor: (t) => cursors.get(t) ?? null,
|
|
32
|
+
writeCursor: (t, c) => {
|
|
33
|
+
cursors.set(t, c);
|
|
34
|
+
},
|
|
35
|
+
now: () => NOW,
|
|
36
|
+
...over,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const inbound = (from: string, body: string): InboundMessage => ({
|
|
41
|
+
senderAddress: from,
|
|
42
|
+
body,
|
|
43
|
+
receivedAt: NOW.getTime(),
|
|
44
|
+
messageId: `${from}:${NOW.getTime()}`,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
dir = mkdtempSync(join(tmpdir(), 'inbound-poll-'));
|
|
49
|
+
const dbPath = join(dir, 'celilo.db');
|
|
50
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
51
|
+
db = await setupTestDatabase(dbPath);
|
|
52
|
+
cursors.clear();
|
|
53
|
+
|
|
54
|
+
db.insert(modules)
|
|
55
|
+
.values({
|
|
56
|
+
id: 'signal',
|
|
57
|
+
name: 'Signal',
|
|
58
|
+
version: '0.1.0',
|
|
59
|
+
manifestData: {},
|
|
60
|
+
sourcePath: '/tmp/signal',
|
|
61
|
+
})
|
|
62
|
+
.run();
|
|
63
|
+
db.insert(monitors)
|
|
64
|
+
.values({
|
|
65
|
+
id: 'mon-1',
|
|
66
|
+
kind: 'module_hook',
|
|
67
|
+
target: 'caddy',
|
|
68
|
+
intervalMinutes: 15,
|
|
69
|
+
severity: 'critical',
|
|
70
|
+
})
|
|
71
|
+
.run();
|
|
72
|
+
db.insert(alerts)
|
|
73
|
+
.values({
|
|
74
|
+
id: 'alert-1',
|
|
75
|
+
key: KEY,
|
|
76
|
+
activeKey: KEY,
|
|
77
|
+
monitorId: 'mon-1',
|
|
78
|
+
state: 'firing',
|
|
79
|
+
severity: 'critical',
|
|
80
|
+
graceUntil: NOW,
|
|
81
|
+
message: '/var 94% used',
|
|
82
|
+
})
|
|
83
|
+
.run();
|
|
84
|
+
|
|
85
|
+
const peter = createPerson(db, { name: 'peter', timezone: 'UTC' });
|
|
86
|
+
const wife = createPerson(db, { name: 'wife', timezone: 'UTC' });
|
|
87
|
+
peterRoute = createRoute(db, {
|
|
88
|
+
personId: peter.id,
|
|
89
|
+
transportModuleId: 'signal',
|
|
90
|
+
address: PETER,
|
|
91
|
+
canAck: true,
|
|
92
|
+
});
|
|
93
|
+
wifeRoute = createRoute(db, {
|
|
94
|
+
personId: wife.id,
|
|
95
|
+
transportModuleId: 'signal',
|
|
96
|
+
address: WIFE,
|
|
97
|
+
canAck: true,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
db.$client.close();
|
|
103
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
104
|
+
try {
|
|
105
|
+
rmSync(dir, { recursive: true, force: true });
|
|
106
|
+
} catch {
|
|
107
|
+
/* ignore */
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const page = (routeId: string) =>
|
|
112
|
+
mintDelivery(db, {
|
|
113
|
+
kind: 'alert',
|
|
114
|
+
targetId: 'alert-1',
|
|
115
|
+
routeId,
|
|
116
|
+
now: NOW,
|
|
117
|
+
ttlMs: 86_400_000,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const alertState = () => db.select().from(alerts).where(eq(alerts.id, 'alert-1')).get()?.state;
|
|
121
|
+
|
|
122
|
+
test('only transports with routes are polled', () => {
|
|
123
|
+
expect(transportsWithRoutes(db)).toEqual(['signal']);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('a valid token from the right sender acknowledges the alert', async () => {
|
|
127
|
+
const delivery = page(peterRoute.id);
|
|
128
|
+
const report = await pollInbound(db, deps([inbound(PETER, delivery.token)]));
|
|
129
|
+
|
|
130
|
+
expect(report.acked).toBe(1);
|
|
131
|
+
expect(alertState()).toBe('acked');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// 7.5 — the secondary acks, and the primary is told so they can stand down.
|
|
135
|
+
test('an ack is broadcast to everyone else who was paged', async () => {
|
|
136
|
+
const sent: { address: string; body: string }[] = [];
|
|
137
|
+
page(peterRoute.id);
|
|
138
|
+
const wifeDelivery = page(wifeRoute.id);
|
|
139
|
+
|
|
140
|
+
const report = await pollInbound(
|
|
141
|
+
db,
|
|
142
|
+
deps([inbound(WIFE, wifeDelivery.token)], {
|
|
143
|
+
transportFor: () => ({
|
|
144
|
+
send: async (request: { address: string; body: string }) => {
|
|
145
|
+
sent.push(request);
|
|
146
|
+
return { messageId: 'm-1' };
|
|
147
|
+
},
|
|
148
|
+
}),
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(report.acked).toBe(1);
|
|
153
|
+
expect(report.broadcast).toBe(1);
|
|
154
|
+
expect(sent).toHaveLength(1);
|
|
155
|
+
expect(sent[0].address).toBe(PETER);
|
|
156
|
+
expect(sent[0].body).toContain('wife');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('the acknowledger is not told about their own ack', async () => {
|
|
160
|
+
const sent: string[] = [];
|
|
161
|
+
const delivery = page(peterRoute.id);
|
|
162
|
+
|
|
163
|
+
await pollInbound(
|
|
164
|
+
db,
|
|
165
|
+
deps([inbound(PETER, delivery.token)], {
|
|
166
|
+
transportFor: () => ({
|
|
167
|
+
send: async (request: { address: string; body: string }) => {
|
|
168
|
+
sent.push(request.address);
|
|
169
|
+
return { messageId: 'm-1' };
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
expect(sent).toEqual([]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// An installation with no send path still acks — it just cannot tell anyone.
|
|
179
|
+
test('a missing transport does not stop the ack', async () => {
|
|
180
|
+
page(peterRoute.id);
|
|
181
|
+
const wifeDelivery = page(wifeRoute.id);
|
|
182
|
+
const report = await pollInbound(db, deps([inbound(WIFE, wifeDelivery.token)]));
|
|
183
|
+
|
|
184
|
+
expect(report.acked).toBe(1);
|
|
185
|
+
expect(report.broadcast).toBe(0);
|
|
186
|
+
expect(alertState()).toBe('acked');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('a token from the wrong sender is rejected and changes nothing', async () => {
|
|
190
|
+
const delivery = page(peterRoute.id);
|
|
191
|
+
const report = await pollInbound(db, deps([inbound(WIFE, delivery.token)]));
|
|
192
|
+
|
|
193
|
+
expect(report.rejected).toBe(1);
|
|
194
|
+
expect(report.acked).toBe(0);
|
|
195
|
+
expect(alertState()).toBe('firing');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Silence, not a reply — answering confirms the number is live.
|
|
199
|
+
test('an unknown sender is ignored', async () => {
|
|
200
|
+
const delivery = page(peterRoute.id);
|
|
201
|
+
const report = await pollInbound(db, deps([inbound(STRANGER, delivery.token)]));
|
|
202
|
+
|
|
203
|
+
expect(report.ignored).toBe(1);
|
|
204
|
+
expect(alertState()).toBe('firing');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('the token is consumed so it cannot be replayed', async () => {
|
|
208
|
+
const delivery = page(peterRoute.id);
|
|
209
|
+
await pollInbound(db, deps([inbound(PETER, delivery.token)]));
|
|
210
|
+
|
|
211
|
+
const row = db
|
|
212
|
+
.select()
|
|
213
|
+
.from(notificationDeliveries)
|
|
214
|
+
.where(eq(notificationDeliveries.id, delivery.id))
|
|
215
|
+
.get();
|
|
216
|
+
expect(row?.consumedAt).not.toBeNull();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// At-least-once is the contract: the same batch replayed after a crash must
|
|
220
|
+
// not do anything twice that matters.
|
|
221
|
+
test('replaying the same message is harmless', async () => {
|
|
222
|
+
const delivery = page(peterRoute.id);
|
|
223
|
+
const message = inbound(PETER, delivery.token);
|
|
224
|
+
|
|
225
|
+
const first = await pollInbound(db, deps([message]));
|
|
226
|
+
const second = await pollInbound(db, deps([message]));
|
|
227
|
+
|
|
228
|
+
expect(first.acked).toBe(1);
|
|
229
|
+
// The token is spent, so the replay is refused rather than acking again.
|
|
230
|
+
expect(second.acked).toBe(0);
|
|
231
|
+
expect(second.rejected).toBe(1);
|
|
232
|
+
expect(alertState()).toBe('acked');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('a bare ack works when exactly one delivery is outstanding', async () => {
|
|
236
|
+
page(peterRoute.id);
|
|
237
|
+
const report = await pollInbound(db, deps([inbound(PETER, 'ack')]));
|
|
238
|
+
expect(report.acked).toBe(1);
|
|
239
|
+
expect(alertState()).toBe('acked');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('a bare ack is refused when several are outstanding', async () => {
|
|
243
|
+
page(peterRoute.id);
|
|
244
|
+
db.insert(alerts)
|
|
245
|
+
.values({
|
|
246
|
+
id: 'alert-2',
|
|
247
|
+
key: 'module:caddy/check:cert',
|
|
248
|
+
activeKey: 'module:caddy/check:cert',
|
|
249
|
+
monitorId: 'mon-1',
|
|
250
|
+
state: 'firing',
|
|
251
|
+
severity: 'critical',
|
|
252
|
+
graceUntil: NOW,
|
|
253
|
+
message: 'expiring',
|
|
254
|
+
})
|
|
255
|
+
.run();
|
|
256
|
+
mintDelivery(db, {
|
|
257
|
+
kind: 'alert',
|
|
258
|
+
targetId: 'alert-2',
|
|
259
|
+
routeId: peterRoute.id,
|
|
260
|
+
now: NOW,
|
|
261
|
+
ttlMs: 86_400_000,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const report = await pollInbound(db, deps([inbound(PETER, 'ack')]));
|
|
265
|
+
expect(report.rejected).toBe(1);
|
|
266
|
+
expect(alertState()).toBe('firing');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// One dead transport must not stop the others being read.
|
|
270
|
+
test('an unreachable transport is skipped without throwing', async () => {
|
|
271
|
+
const report = await pollInbound(db, deps([], { receiveFrom: async () => null }));
|
|
272
|
+
expect(report.transportsPolled).toBe(1);
|
|
273
|
+
expect(report.messagesRead).toBe(0);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('the cursor is persisted after a successful poll', async () => {
|
|
277
|
+
await pollInbound(db, deps([]));
|
|
278
|
+
expect(cursors.get('signal')).toBe('cursor-1');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('the cursor is NOT advanced when the transport could not be reached', async () => {
|
|
282
|
+
await pollInbound(db, deps([], { receiveFrom: async () => null }));
|
|
283
|
+
expect(cursors.get('signal')).toBeUndefined();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('gibberish is rejected without touching the alert', async () => {
|
|
287
|
+
page(peterRoute.id);
|
|
288
|
+
const report = await pollInbound(db, deps([inbound(PETER, 'what is going on')]));
|
|
289
|
+
expect(report.rejected).toBe(1);
|
|
290
|
+
expect(alertState()).toBe('firing');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('a route pointing at a transport nobody uses is not polled', () => {
|
|
294
|
+
db.delete(alerts).run();
|
|
295
|
+
expect(transportsWithRoutes(db)).toEqual(['signal']);
|
|
296
|
+
expect(wifeRoute.transportModuleId).toBe('signal');
|
|
297
|
+
});
|
|
298
|
+
});
|