@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,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monitor CRUD.
|
|
3
|
+
*
|
|
4
|
+
* A monitor binds a thing to check to a cadence, a severity, and an escalation
|
|
5
|
+
* policy. Operators address them by their target (`caddy`,
|
|
6
|
+
* `machines_reachable`) rather than by id — per CLAUDE.md, a UUID never
|
|
7
|
+
* reaches the operator.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { and, eq, isNotNull } from 'drizzle-orm';
|
|
12
|
+
import type { DbClient } from '../../db/client';
|
|
13
|
+
import {
|
|
14
|
+
type AlertSeverity,
|
|
15
|
+
type Monitor,
|
|
16
|
+
type MonitorKind,
|
|
17
|
+
alerts,
|
|
18
|
+
monitors,
|
|
19
|
+
} from '../../db/schema';
|
|
20
|
+
import { parseIntervalMinutes } from '../../manifest/schema';
|
|
21
|
+
|
|
22
|
+
export interface CreateMonitorInput {
|
|
23
|
+
kind: MonitorKind;
|
|
24
|
+
target: string;
|
|
25
|
+
intervalMinutes: number;
|
|
26
|
+
severity?: AlertSeverity;
|
|
27
|
+
suppressible?: boolean;
|
|
28
|
+
escalationPolicyId?: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function listMonitors(db: DbClient): Monitor[] {
|
|
32
|
+
return db.select().from(monitors).all();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function findMonitor(db: DbClient, kind: MonitorKind, target: string): Monitor | undefined {
|
|
36
|
+
return db
|
|
37
|
+
.select()
|
|
38
|
+
.from(monitors)
|
|
39
|
+
.where(and(eq(monitors.kind, kind), eq(monitors.target, target)))
|
|
40
|
+
.get();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Find a monitor by target alone.
|
|
45
|
+
*
|
|
46
|
+
* Targets are unique in practice — a module id and an audit category cannot
|
|
47
|
+
* collide, since audit categories are a fixed snake_case set and module ids are
|
|
48
|
+
* kebab-case. Resolving by target alone is what lets the operator type
|
|
49
|
+
* `celilo monitor run caddy` without also naming the kind.
|
|
50
|
+
*/
|
|
51
|
+
export function findMonitorByTarget(db: DbClient, target: string): Monitor | undefined {
|
|
52
|
+
return db.select().from(monitors).where(eq(monitors.target, target)).get();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createMonitor(db: DbClient, input: CreateMonitorInput): Monitor {
|
|
56
|
+
const id = randomUUID();
|
|
57
|
+
db.insert(monitors)
|
|
58
|
+
.values({
|
|
59
|
+
id,
|
|
60
|
+
kind: input.kind,
|
|
61
|
+
target: input.target,
|
|
62
|
+
intervalMinutes: input.intervalMinutes,
|
|
63
|
+
severity: input.severity ?? 'critical',
|
|
64
|
+
suppressible: input.suppressible ?? true,
|
|
65
|
+
escalationPolicyId: input.escalationPolicyId ?? null,
|
|
66
|
+
})
|
|
67
|
+
.run();
|
|
68
|
+
return db.select().from(monitors).where(eq(monitors.id, id)).get() as Monitor;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Ensure a monitor exists for a module that declares a suggested interval.
|
|
73
|
+
*
|
|
74
|
+
* Called on deploy. Never overwrites an existing monitor: the manifest's
|
|
75
|
+
* interval is the author's SUGGESTION and the operator's setting must survive
|
|
76
|
+
* module upgrades, so a second call is a no-op rather than a reset.
|
|
77
|
+
*
|
|
78
|
+
* Returns the monitor when one was created, null when one already existed or
|
|
79
|
+
* the manifest declares no interval.
|
|
80
|
+
*/
|
|
81
|
+
export function ensureMonitorForModule(
|
|
82
|
+
db: DbClient,
|
|
83
|
+
moduleId: string,
|
|
84
|
+
suggestedInterval: string | undefined,
|
|
85
|
+
): Monitor | null {
|
|
86
|
+
if (!suggestedInterval) return null;
|
|
87
|
+
if (findMonitor(db, 'module_hook', moduleId)) return null;
|
|
88
|
+
|
|
89
|
+
const intervalMinutes = parseIntervalMinutes(suggestedInterval);
|
|
90
|
+
if (intervalMinutes === null) return null;
|
|
91
|
+
|
|
92
|
+
return createMonitor(db, { kind: 'module_hook', target: moduleId, intervalMinutes });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Enable or disable a monitor.
|
|
97
|
+
*
|
|
98
|
+
* Disabling resolves its alerts rather than leaving them hanging: a disabled
|
|
99
|
+
* monitor will never report on them again, so anything still live would be
|
|
100
|
+
* frozen forever with no way to clear.
|
|
101
|
+
*/
|
|
102
|
+
export function setMonitorEnabled(
|
|
103
|
+
db: DbClient,
|
|
104
|
+
monitorId: string,
|
|
105
|
+
enabled: boolean,
|
|
106
|
+
now: Date,
|
|
107
|
+
): void {
|
|
108
|
+
db.update(monitors).set({ enabled }).where(eq(monitors.id, monitorId)).run();
|
|
109
|
+
if (!enabled) resolveMonitorAlerts(db, monitorId, now);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Resolve every live alert owned by a monitor. */
|
|
113
|
+
export function resolveMonitorAlerts(db: DbClient, monitorId: string, now: Date): number {
|
|
114
|
+
const live = db
|
|
115
|
+
.select({ id: alerts.id })
|
|
116
|
+
.from(alerts)
|
|
117
|
+
.where(and(eq(alerts.monitorId, monitorId), isNotNull(alerts.activeKey)))
|
|
118
|
+
.all();
|
|
119
|
+
|
|
120
|
+
for (const row of live) {
|
|
121
|
+
db.update(alerts)
|
|
122
|
+
.set({ state: 'resolved', activeKey: null, resolvedAt: now })
|
|
123
|
+
.where(eq(alerts.id, row.id))
|
|
124
|
+
.run();
|
|
125
|
+
}
|
|
126
|
+
return live.length;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The bus subscriber that makes alerting run by itself.
|
|
131
|
+
*
|
|
132
|
+
* Registered as an ordinary subscriber with a shell handler, exactly like a
|
|
133
|
+
* module subscription — the dispatcher spawns `celilo alerts sweep` on every
|
|
134
|
+
* five-minute tick. No new scheduling mechanism: `timer.tick.5m` already
|
|
135
|
+
* exists, already survives restarts, and already has retry and dedup.
|
|
136
|
+
*
|
|
137
|
+
* Idempotent: `bus.subscribe` upserts by name, so calling this on every
|
|
138
|
+
* monitor creation is safe and means switching on the first monitor is also
|
|
139
|
+
* what switches on the sweep.
|
|
140
|
+
*/
|
|
141
|
+
export const ALERTING_SWEEP_SUBSCRIBER = 'celilo-alerting-sweep';
|
|
142
|
+
export const ALERTING_SWEEP_PATTERN = 'timer.tick.5m';
|
|
143
|
+
|
|
144
|
+
export interface SubscriberRegistrar {
|
|
145
|
+
subscribe(options: {
|
|
146
|
+
name: string;
|
|
147
|
+
pattern: string;
|
|
148
|
+
handler: string;
|
|
149
|
+
registeredBy?: string;
|
|
150
|
+
}): unknown;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const ALERTING_POLL_SUBSCRIBER = 'celilo-alerting-inbound';
|
|
154
|
+
|
|
155
|
+
export function ensureSweepSubscriber(bus: SubscriberRegistrar): void {
|
|
156
|
+
bus.subscribe({
|
|
157
|
+
name: ALERTING_SWEEP_SUBSCRIBER,
|
|
158
|
+
pattern: ALERTING_SWEEP_PATTERN,
|
|
159
|
+
handler: 'celilo alerts sweep',
|
|
160
|
+
registeredBy: 'celilo-alerting',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Read inbound replies on the same five-minute tick.
|
|
166
|
+
*
|
|
167
|
+
* Five minutes is coarse for an ack — the design wants seconds — but the bus
|
|
168
|
+
* timer menu is deliberately fixed, and a coarse ack that works beats a
|
|
169
|
+
* bespoke scheduler that has to be supervised. An operator wanting faster
|
|
170
|
+
* turnaround runs `celilo alerts poll`, and a tighter loop can be added later
|
|
171
|
+
* without changing anything else.
|
|
172
|
+
*/
|
|
173
|
+
export function ensureInboundSubscriber(bus: SubscriberRegistrar): void {
|
|
174
|
+
bus.subscribe({
|
|
175
|
+
name: ALERTING_POLL_SUBSCRIBER,
|
|
176
|
+
pattern: ALERTING_SWEEP_PATTERN,
|
|
177
|
+
handler: 'celilo alerts poll',
|
|
178
|
+
registeredBy: 'celilo-alerting',
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function deleteMonitor(db: DbClient, monitorId: string, now: Date): void {
|
|
183
|
+
resolveMonitorAlerts(db, monitorId, now);
|
|
184
|
+
db.delete(monitors).where(eq(monitors.id, monitorId)).run();
|
|
185
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
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 { defineEvents, openBus } from '@celilo/event-bus';
|
|
6
|
+
import type { DbClient } from '../../db/client';
|
|
7
|
+
import { type Route, modules } from '../../db/schema';
|
|
8
|
+
import { setupTestDatabase } from '../../test-utils/setup-test-db';
|
|
9
|
+
import {
|
|
10
|
+
type NotificationResponderHandle,
|
|
11
|
+
startNotificationResponder,
|
|
12
|
+
} from './notification-responder';
|
|
13
|
+
import { createPerson, createRoute } from './people';
|
|
14
|
+
import { consumeDelivery, findLiveDelivery } from './tokens';
|
|
15
|
+
|
|
16
|
+
const NO_SCHEMAS = defineEvents({});
|
|
17
|
+
const NOW = new Date('2026-07-28T12:00:00Z');
|
|
18
|
+
|
|
19
|
+
describe('notification responder', () => {
|
|
20
|
+
let dir: string;
|
|
21
|
+
let db: DbClient;
|
|
22
|
+
let busPath: string;
|
|
23
|
+
let route: Route;
|
|
24
|
+
let sent: { address: string; body: string }[];
|
|
25
|
+
let declined: { type: string; reason: string }[];
|
|
26
|
+
let sendFailures: string[];
|
|
27
|
+
let sendFails: boolean;
|
|
28
|
+
let handle: NotificationResponderHandle | undefined;
|
|
29
|
+
|
|
30
|
+
function start(hasTty = false, routes?: Route[]) {
|
|
31
|
+
handle = startNotificationResponder({
|
|
32
|
+
db,
|
|
33
|
+
busDbPath: busPath,
|
|
34
|
+
routes: routes ?? [route],
|
|
35
|
+
transportFor: () => ({
|
|
36
|
+
send: async (r) => {
|
|
37
|
+
if (sendFails) throw new Error('unlinked account cannot send');
|
|
38
|
+
sent.push({ address: r.address, body: r.body });
|
|
39
|
+
return { messageId: 'm1' };
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
hasTty: () => hasTty,
|
|
43
|
+
// Real time, not the fixed NOW: the bus stamps `emitted_at` with the
|
|
44
|
+
// wall clock, so a fake clock in the past makes every question look
|
|
45
|
+
// like it has not been emitted yet.
|
|
46
|
+
now: () => new Date(),
|
|
47
|
+
ttlMs: 86_400_000,
|
|
48
|
+
// Most tests are about the delivery decision, not the grace — a
|
|
49
|
+
// question emitted microseconds ago is otherwise always too fresh.
|
|
50
|
+
// The grace itself gets its own test below.
|
|
51
|
+
askAfterMs: 0,
|
|
52
|
+
onDeclined: (type, reason) => declined.push({ type, reason }),
|
|
53
|
+
onSendFailed: (type, error) => sendFailures.push(`${type}: ${error}`),
|
|
54
|
+
});
|
|
55
|
+
return handle;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeEach(async () => {
|
|
59
|
+
dir = mkdtempSync(join(tmpdir(), 'nresp-'));
|
|
60
|
+
const dbPath = join(dir, 'celilo.db');
|
|
61
|
+
busPath = join(dir, 'events.db');
|
|
62
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
63
|
+
db = await setupTestDatabase(dbPath);
|
|
64
|
+
sent = [];
|
|
65
|
+
declined = [];
|
|
66
|
+
sendFailures = [];
|
|
67
|
+
sendFails = false;
|
|
68
|
+
|
|
69
|
+
db.insert(modules)
|
|
70
|
+
.values({
|
|
71
|
+
id: 'signal',
|
|
72
|
+
name: 'Signal',
|
|
73
|
+
version: '0.1.0',
|
|
74
|
+
manifestData: {},
|
|
75
|
+
sourcePath: '/tmp/signal',
|
|
76
|
+
})
|
|
77
|
+
.run();
|
|
78
|
+
const peter = createPerson(db, { name: 'peter', timezone: 'UTC' });
|
|
79
|
+
route = createRoute(db, {
|
|
80
|
+
personId: peter.id,
|
|
81
|
+
transportModuleId: 'signal',
|
|
82
|
+
address: '+15550001',
|
|
83
|
+
canAck: true,
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
handle?.stop();
|
|
89
|
+
handle = undefined;
|
|
90
|
+
db.$client.close();
|
|
91
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
92
|
+
try {
|
|
93
|
+
rmSync(dir, { recursive: true, force: true });
|
|
94
|
+
} catch {
|
|
95
|
+
/* ignore */
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const emit = (type: string, payload: Record<string, unknown>) => {
|
|
100
|
+
const bus = openBus({ dbPath: busPath, events: NO_SCHEMAS });
|
|
101
|
+
try {
|
|
102
|
+
return bus.emitRaw(type, payload, { emittedBy: 'test' }).id;
|
|
103
|
+
} finally {
|
|
104
|
+
bus.close();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const repliesFor = (eventId: number) => {
|
|
109
|
+
const bus = openBus({ dbPath: busPath, events: NO_SCHEMAS });
|
|
110
|
+
try {
|
|
111
|
+
return bus.db.query('SELECT type, payload FROM events WHERE reply_for = ?').all(eventId) as {
|
|
112
|
+
type: string;
|
|
113
|
+
payload: string;
|
|
114
|
+
}[];
|
|
115
|
+
} finally {
|
|
116
|
+
bus.close();
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
test('a non-secret question is delivered to a route', async () => {
|
|
121
|
+
const responder = start();
|
|
122
|
+
emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' });
|
|
123
|
+
await responder.poll();
|
|
124
|
+
|
|
125
|
+
expect(sent).toHaveLength(1);
|
|
126
|
+
expect(sent[0].address).toBe('+15550001');
|
|
127
|
+
expect(sent[0].body).toContain('caddy needs: hostname');
|
|
128
|
+
expect(sent[0].body).toMatch(/reply [0-9A-Z]{6} <value>/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// THE security property. A secret must never reach a chat, even headlessly.
|
|
132
|
+
test('a secret question is never delivered', async () => {
|
|
133
|
+
const responder = start();
|
|
134
|
+
emit('secret.required.forgejo.admin_token', { scope: 'forgejo', key: 'admin_token' });
|
|
135
|
+
await responder.poll();
|
|
136
|
+
|
|
137
|
+
expect(sent).toEqual([]);
|
|
138
|
+
expect(declined[0].type).toBe('secret.required.forgejo.admin_token');
|
|
139
|
+
expect(declined[0].reason).toContain('terminal');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('nothing is delivered when a terminal is attached', async () => {
|
|
143
|
+
const responder = start(true);
|
|
144
|
+
emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' });
|
|
145
|
+
await responder.poll();
|
|
146
|
+
|
|
147
|
+
expect(sent).toEqual([]);
|
|
148
|
+
expect(declined[0].reason).toContain('terminal responder');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('nothing is delivered when no route can receive replies', async () => {
|
|
152
|
+
const responder = start(false, []);
|
|
153
|
+
emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' });
|
|
154
|
+
await responder.poll();
|
|
155
|
+
|
|
156
|
+
expect(sent).toEqual([]);
|
|
157
|
+
expect(declined[0].reason).toContain('never be answered');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Resolve a token the way the inbound poller does — through the DELIVERY
|
|
162
|
+
* row, not through anything the responder remembers. That is what lets a
|
|
163
|
+
* reply arriving minutes later, in a different process, still land.
|
|
164
|
+
*/
|
|
165
|
+
function eventIdForToken(token: string): string | null {
|
|
166
|
+
const delivery = findLiveDelivery(db, token, NOW);
|
|
167
|
+
return delivery?.kind === 'interview' ? delivery.targetId : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const tokenIn = (body: string) => /reply ([0-9A-Z]{6})/.exec(body)?.[1] as string;
|
|
171
|
+
|
|
172
|
+
// The unification: a reply from a phone unblocks a waiting deploy.
|
|
173
|
+
test('an inbound reply publishes the answer against the waiting question', async () => {
|
|
174
|
+
const responder = start();
|
|
175
|
+
const eventId = emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' });
|
|
176
|
+
await responder.poll();
|
|
177
|
+
|
|
178
|
+
const resolved = eventIdForToken(tokenIn(sent[0].body)) as string;
|
|
179
|
+
expect(resolved).toBe(String(eventId));
|
|
180
|
+
responder.answer(resolved, 'www.example.com');
|
|
181
|
+
|
|
182
|
+
const replies = repliesFor(eventId);
|
|
183
|
+
expect(replies).toHaveLength(1);
|
|
184
|
+
expect(replies[0].type).toBe('config.required.caddy.hostname.reply');
|
|
185
|
+
expect(JSON.parse(replies[0].payload).value).toBe('www.example.com');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A responder that is actually attached — a terminal, or the wire bridge in
|
|
190
|
+
* api-serve — answers within seconds. Paging a phone about a question
|
|
191
|
+
* somebody is already looking at is noise, and both responders would reply
|
|
192
|
+
* to the same question.
|
|
193
|
+
*/
|
|
194
|
+
test('a fresh question is left for whatever responder is attached', async () => {
|
|
195
|
+
const responder = startNotificationResponder({
|
|
196
|
+
db,
|
|
197
|
+
busDbPath: busPath,
|
|
198
|
+
routes: [route],
|
|
199
|
+
transportFor: () => ({
|
|
200
|
+
send: async (r) => {
|
|
201
|
+
sent.push({ address: r.address, body: r.body });
|
|
202
|
+
return { messageId: 'm1' };
|
|
203
|
+
},
|
|
204
|
+
}),
|
|
205
|
+
hasTty: () => false,
|
|
206
|
+
now: () => new Date(),
|
|
207
|
+
ttlMs: 86_400_000,
|
|
208
|
+
askAfterMs: 90_000,
|
|
209
|
+
});
|
|
210
|
+
handle = responder;
|
|
211
|
+
|
|
212
|
+
emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' });
|
|
213
|
+
expect((await responder.poll()).asked).toBe(0);
|
|
214
|
+
expect(sent).toEqual([]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The CLI runs one process per invocation and polls every few seconds, so
|
|
219
|
+
* "asked already" cannot live in memory — it has to be the delivery row.
|
|
220
|
+
*/
|
|
221
|
+
test('a question is not re-asked by a fresh responder process', async () => {
|
|
222
|
+
const first = start();
|
|
223
|
+
emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' });
|
|
224
|
+
expect((await first.poll()).asked).toBe(1);
|
|
225
|
+
first.stop();
|
|
226
|
+
|
|
227
|
+
const second = start();
|
|
228
|
+
expect((await second.poll()).asked).toBe(0);
|
|
229
|
+
expect(sent).toHaveLength(1);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Found by the e2e: the delivery row is minted BEFORE the send (the token has
|
|
233
|
+
// to be in the body), so a failed send would otherwise leave a record saying
|
|
234
|
+
// the question was asked — suppressing every retry while the deploy waits
|
|
235
|
+
// forever on a question nobody received.
|
|
236
|
+
test('a failed send is reported and does NOT count as asked', async () => {
|
|
237
|
+
sendFails = true;
|
|
238
|
+
const responder = start();
|
|
239
|
+
emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' });
|
|
240
|
+
|
|
241
|
+
const first = await responder.poll();
|
|
242
|
+
expect(first.asked).toBe(0);
|
|
243
|
+
expect(first.failed).toBe(1);
|
|
244
|
+
expect(sendFailures[0]).toContain('config.required.caddy.hostname');
|
|
245
|
+
|
|
246
|
+
// The question is still outstanding, so a later poll retries it.
|
|
247
|
+
sendFails = false;
|
|
248
|
+
expect((await responder.poll()).asked).toBe(1);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('a token nobody issued resolves to nothing', async () => {
|
|
252
|
+
const responder = start();
|
|
253
|
+
const eventId = emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' });
|
|
254
|
+
await responder.poll();
|
|
255
|
+
|
|
256
|
+
expect(eventIdForToken('ZZZZZZ')).toBeNull();
|
|
257
|
+
expect(repliesFor(eventId)).toEqual([]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Two questions outstanding must not cross-answer.
|
|
261
|
+
test('each question is answered by its own token', async () => {
|
|
262
|
+
const responder = start();
|
|
263
|
+
const first = emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' });
|
|
264
|
+
await responder.poll();
|
|
265
|
+
const second = emit('config.required.forgejo.domain', { scope: 'forgejo', key: 'domain' });
|
|
266
|
+
await responder.poll();
|
|
267
|
+
|
|
268
|
+
expect(sent).toHaveLength(2);
|
|
269
|
+
responder.answer(eventIdForToken(tokenIn(sent[1].body)) as string, 'git.example.com');
|
|
270
|
+
|
|
271
|
+
expect(repliesFor(first)).toEqual([]);
|
|
272
|
+
expect(JSON.parse(repliesFor(second)[0].payload).value).toBe('git.example.com');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Single-use lives in the token layer, which is where the alert path
|
|
276
|
+
// enforces it too — one mechanism, not two.
|
|
277
|
+
test('a consumed token cannot be replayed', async () => {
|
|
278
|
+
const responder = start();
|
|
279
|
+
emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' });
|
|
280
|
+
await responder.poll();
|
|
281
|
+
|
|
282
|
+
const token = tokenIn(sent[0].body);
|
|
283
|
+
const delivery = findLiveDelivery(db, token, NOW);
|
|
284
|
+
expect(delivery).toBeDefined();
|
|
285
|
+
consumeDelivery(db, (delivery as { id: string }).id, NOW);
|
|
286
|
+
|
|
287
|
+
expect(eventIdForToken(token)).toBeNull();
|
|
288
|
+
expect(responder).toBeDefined();
|
|
289
|
+
});
|
|
290
|
+
});
|