@celilo/cli 0.22.0 → 0.24.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 +2 -2
- package/CELILO_SUBSYSTEMS.md +61 -9
- package/drizzle/0024_module_pause.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +7 -7
- package/src/__integration__/container-services-cli.integration.test.ts +8 -2
- package/src/api/remote-client.test.ts +6 -5
- package/src/api/serve.ts +41 -7
- package/src/api-clients/proxmox.ts +34 -0
- package/src/cli/commands/alerts-act.ts +1 -1
- package/src/cli/commands/alerts-sweep.ts +2 -0
- package/src/cli/commands/backup-create.ts +26 -11
- package/src/cli/commands/backup-list.test.ts +83 -0
- package/src/cli/commands/backup-list.ts +67 -3
- package/src/cli/commands/backup-prune.ts +17 -17
- package/src/cli/commands/backup-sweep.ts +20 -8
- package/src/cli/commands/events.ts +34 -3
- package/src/cli/commands/firewall-interface-list.test.ts +85 -0
- package/src/cli/commands/firewall-interface-list.ts +123 -0
- package/src/cli/commands/machine-add.ts +30 -2
- package/src/cli/commands/module-config.test.ts +64 -2
- package/src/cli/commands/module-config.ts +159 -8
- package/src/cli/commands/module-deploy.ts +2 -2
- package/src/cli/commands/module-health.ts +1 -0
- package/src/cli/commands/module-import.ts +3 -3
- package/src/cli/commands/module-list.ts +12 -1
- package/src/cli/commands/module-pause.ts +317 -0
- package/src/cli/commands/module-remove.ts +78 -40
- package/src/cli/commands/module-status.ts +127 -4
- package/src/cli/commands/module-update.test.ts +1 -1
- package/src/cli/commands/monitor.ts +116 -19
- package/src/cli/commands/proxmox-template-selection.ts +1 -1
- package/src/cli/commands/status.ts +25 -3
- package/src/cli/commands/system-migrate.ts +14 -0
- package/src/cli/commands/system-update.ts +4 -1
- package/src/cli/completion.ts +39 -9
- package/src/cli/fuel-gauge.ts +4 -4
- package/src/cli/index.ts +104 -22
- package/src/cli/json-output.test.ts +162 -0
- package/src/cli/prompts.ts +53 -74
- package/src/cli/service-credential.ts +3 -3
- package/src/cli/stdout-is-undecorated.test.ts +94 -0
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/cli/types.ts +7 -2
- package/src/db/schema.ts +73 -15
- package/src/hooks/capability-loader.ts +130 -4
- package/src/hooks/run-named-hook.ts +28 -0
- package/src/hooks/types.ts +2 -1
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +40 -65
- package/src/services/alerting/builtin-monitors.test.ts +18 -10
- package/src/services/alerting/cadence-migration.test.ts +155 -0
- package/src/services/alerting/cadence-migration.ts +90 -0
- package/src/services/alerting/coverage-source.ts +8 -11
- package/src/services/alerting/deploy-hooks.test.ts +16 -7
- package/src/services/alerting/deploy-hooks.ts +11 -5
- package/src/services/alerting/health-cadence.test.ts +58 -0
- package/src/services/alerting/health-cadence.ts +128 -0
- package/src/services/alerting/health-coverage.ts +18 -8
- package/src/services/alerting/monitors.ts +50 -15
- package/src/services/alerting/suppression.test.ts +5 -0
- package/src/services/alerting/suppression.ts +18 -1
- package/src/services/alerting/sweep-runner.test.ts +52 -3
- package/src/services/alerting/sweep-runner.ts +41 -8
- package/src/services/audit/backup-source.ts +24 -1
- package/src/services/audit/backups.test.ts +95 -10
- package/src/services/audit/backups.ts +40 -37
- package/src/services/audit/interface-classification.test.ts +220 -0
- package/src/services/audit/interface-classification.ts +167 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-age-agreement.test.ts +118 -0
- package/src/services/backup-create.ts +36 -30
- package/src/services/backup-metadata.ts +52 -1
- package/src/services/backup-retention.test.ts +123 -0
- package/src/services/backup-retention.ts +66 -5
- package/src/services/backup-schedule.test.ts +166 -0
- package/src/services/backup-schedule.ts +105 -15
- package/src/services/backup-staging.ts +14 -1
- package/src/services/backup-sweep.test.ts +22 -3
- package/src/services/backup-sweep.ts +15 -5
- package/src/services/bus-interview.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/cadence.test.ts +97 -0
- package/src/services/cadence.ts +165 -0
- package/src/services/fleet-checks.ts +48 -0
- package/src/services/machine-detector.ts +23 -1
- package/src/services/module-config.ts +33 -0
- package/src/services/module-deploy.ts +1 -1
- package/src/services/module-pause-observability.test.ts +224 -0
- package/src/services/module-pause-quiescence.test.ts +163 -0
- package/src/services/module-pause.test.ts +573 -0
- package/src/services/module-pause.ts +544 -0
- package/src/services/remove-guard.test.ts +175 -0
- package/src/services/remove-guard.ts +109 -0
- package/src/services/storage-providers/s3.test.ts +96 -13
- package/src/services/storage-providers/s3.ts +48 -15
- package/src/services/terminal-responder.ts +16 -16
- package/src/services/update/dep-graph.test.ts +33 -4
- package/src/services/update/dep-graph.ts +39 -17
- package/src/services/zone-detector.test.ts +34 -3
- package/src/services/zone-detector.ts +32 -49
- package/src/test-utils/cli.ts +15 -14
- package/src/test-utils/integration-guard.ts +26 -0
- package/src/test-utils/setup-test-db.ts +13 -23
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Monitor CRUD.
|
|
3
3
|
*
|
|
4
|
-
* A monitor binds a thing to check to a
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A monitor binds a thing to check to a severity and an escalation policy.
|
|
5
|
+
* Operators address them by their target (`caddy`, `machines_reachable`) rather
|
|
6
|
+
* than by id — per CLAUDE.md, a UUID never reaches the operator.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ `intervalMinutes` and `enabled` are meaningful for `builtin_check` rows
|
|
9
|
+
* ONLY. A built-in check targets a fleet-level audit category with no module
|
|
10
|
+
* and no manifest, so nothing suggests its cadence and the row legitimately IS
|
|
11
|
+
* the config. A `module_hook` row targets a module whose manifest MAY suggest
|
|
12
|
+
* one, so its cadence and whether it is watched at all resolve at read time
|
|
13
|
+
* from `health_check_interval` — see [[services/alerting/health-cadence.ts]].
|
|
14
|
+
* Two columns whose meaning depends on `kind` is a smell, named here rather
|
|
15
|
+
* than discovered in review (design.md D8); the alternatives are a resolved
|
|
16
|
+
* value cached on the row, which rots, or splitting the table, which needs a
|
|
17
|
+
* synthetic monitor identity for `alerts.monitorId` and friends.
|
|
8
18
|
*/
|
|
9
19
|
|
|
10
20
|
import { randomUUID } from 'node:crypto';
|
|
@@ -17,11 +27,12 @@ import {
|
|
|
17
27
|
alerts,
|
|
18
28
|
monitors,
|
|
19
29
|
} from '../../db/schema';
|
|
20
|
-
import {
|
|
30
|
+
import { ALERTING_SWEEP_PATTERN, MONITOR_INTERVAL_FLOOR_MINUTES, parseCadence } from '../cadence';
|
|
21
31
|
|
|
22
32
|
export interface CreateMonitorInput {
|
|
23
33
|
kind: MonitorKind;
|
|
24
34
|
target: string;
|
|
35
|
+
/** `builtin_check` only. Not consulted for `module_hook` — see the file header. */
|
|
25
36
|
intervalMinutes: number;
|
|
26
37
|
severity?: AlertSeverity;
|
|
27
38
|
suppressible?: boolean;
|
|
@@ -69,29 +80,50 @@ export function createMonitor(db: DbClient, input: CreateMonitorInput): Monitor
|
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
/**
|
|
72
|
-
* Ensure a monitor exists for a module that declares a
|
|
83
|
+
* Ensure a `module_hook` monitor row exists for a module that declares a
|
|
84
|
+
* `health_check` hook.
|
|
85
|
+
*
|
|
86
|
+
* The row is no longer where the cadence lives — it carries severity, the
|
|
87
|
+
* escalation policy and `lastRunAt`, and whether it runs at all is resolved
|
|
88
|
+
* from the module's effective cadence at sweep time. So it is created for any
|
|
89
|
+
* module with the hook, not only for one whose manifest happens to suggest an
|
|
90
|
+
* interval: an operator can now set a cadence on a module whose author never
|
|
91
|
+
* named one, and without a row there would be nothing to carry its last run.
|
|
73
92
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
93
|
+
* The stored `intervalMinutes` is the manifest's suggestion where there is one,
|
|
94
|
+
* and is NOT read back for this kind. It is written so that rolling back to a
|
|
95
|
+
* release which does read it behaves as it did before.
|
|
77
96
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
97
|
+
* Idempotent: a second call is a no-op, which is what keeps deploys from
|
|
98
|
+
* duplicating rows. It is no longer what protects an operator's setting — that
|
|
99
|
+
* lives in `module_configs` now and cannot be overwritten from here at all.
|
|
80
100
|
*/
|
|
81
101
|
export function ensureMonitorForModule(
|
|
82
102
|
db: DbClient,
|
|
83
103
|
moduleId: string,
|
|
84
104
|
suggestedInterval: string | undefined,
|
|
85
105
|
): Monitor | null {
|
|
86
|
-
if (!suggestedInterval) return null;
|
|
87
106
|
if (findMonitor(db, 'module_hook', moduleId)) return null;
|
|
88
107
|
|
|
89
|
-
const
|
|
90
|
-
|
|
108
|
+
const suggested = suggestedInterval ? parseCadence(suggestedInterval) : null;
|
|
109
|
+
const intervalMinutes =
|
|
110
|
+
suggested !== null && suggested !== 'manual'
|
|
111
|
+
? suggested.minutes
|
|
112
|
+
: MONITOR_INTERVAL_FLOOR_MINUTES;
|
|
91
113
|
|
|
92
114
|
return createMonitor(db, { kind: 'module_hook', target: moduleId, intervalMinutes });
|
|
93
115
|
}
|
|
94
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Change a `builtin_check` monitor's cadence in place.
|
|
119
|
+
*
|
|
120
|
+
* In place, rather than remove-and-recreate, because the monitor id owns the
|
|
121
|
+
* alert history: recreating it would orphan every live alert it raised.
|
|
122
|
+
*/
|
|
123
|
+
export function updateMonitorInterval(db: DbClient, monitorId: string, minutes: number): void {
|
|
124
|
+
db.update(monitors).set({ intervalMinutes: minutes }).where(eq(monitors.id, monitorId)).run();
|
|
125
|
+
}
|
|
126
|
+
|
|
95
127
|
/**
|
|
96
128
|
* Enable or disable a monitor.
|
|
97
129
|
*
|
|
@@ -139,7 +171,10 @@ export function resolveMonitorAlerts(db: DbClient, monitorId: string, now: Date)
|
|
|
139
171
|
* what switches on the sweep.
|
|
140
172
|
*/
|
|
141
173
|
export const ALERTING_SWEEP_SUBSCRIBER = 'celilo-alerting-sweep';
|
|
142
|
-
|
|
174
|
+
// The tick itself lives in services/cadence.ts, next to the floor derived from
|
|
175
|
+
// it — a sweep whose tick and whose finest servable cadence are stated in two
|
|
176
|
+
// files is the pair that drifts.
|
|
177
|
+
export { ALERTING_SWEEP_PATTERN };
|
|
143
178
|
|
|
144
179
|
export interface SubscriberRegistrar {
|
|
145
180
|
subscribe(options: {
|
|
@@ -35,6 +35,7 @@ const TOPOLOGY: SuppressionTopology = {
|
|
|
35
35
|
const noSuppression = {
|
|
36
36
|
suppressible: true,
|
|
37
37
|
modulesInDeployWindow: new Set<string>(),
|
|
38
|
+
pausedModules: new Set<string>(),
|
|
38
39
|
topology: TOPOLOGY,
|
|
39
40
|
};
|
|
40
41
|
|
|
@@ -185,6 +186,7 @@ describe('findSuppressor — guards', () => {
|
|
|
185
186
|
firingKeys,
|
|
186
187
|
suppressible: false,
|
|
187
188
|
modulesInDeployWindow: new Set(),
|
|
189
|
+
pausedModules: new Set(),
|
|
188
190
|
topology: TOPOLOGY,
|
|
189
191
|
}),
|
|
190
192
|
).toBeNull();
|
|
@@ -197,6 +199,7 @@ describe('findSuppressor — guards', () => {
|
|
|
197
199
|
firingKeys: new Set(),
|
|
198
200
|
suppressible: true,
|
|
199
201
|
modulesInDeployWindow: new Set(['forgejo']),
|
|
202
|
+
pausedModules: new Set(),
|
|
200
203
|
topology: TOPOLOGY,
|
|
201
204
|
}),
|
|
202
205
|
).toEqual({ kind: 'deploy_window', moduleId: 'forgejo' });
|
|
@@ -209,6 +212,7 @@ describe('findSuppressor — guards', () => {
|
|
|
209
212
|
firingKeys: new Set(),
|
|
210
213
|
suppressible: true,
|
|
211
214
|
modulesInDeployWindow: new Set(['forgejo']),
|
|
215
|
+
pausedModules: new Set(),
|
|
212
216
|
topology: TOPOLOGY,
|
|
213
217
|
}),
|
|
214
218
|
).toBeNull();
|
|
@@ -221,6 +225,7 @@ describe('findSuppressor — guards', () => {
|
|
|
221
225
|
firingKeys: new Set([machineAlertKey('iot')]),
|
|
222
226
|
suppressible: true,
|
|
223
227
|
modulesInDeployWindow: new Set(['homebridge']),
|
|
228
|
+
pausedModules: new Set(),
|
|
224
229
|
topology: TOPOLOGY,
|
|
225
230
|
}),
|
|
226
231
|
).toEqual({ kind: 'deploy_window', moduleId: 'homebridge' });
|
|
@@ -106,12 +106,20 @@ export interface SuppressorLookup {
|
|
|
106
106
|
suppressible: boolean;
|
|
107
107
|
/** Modules currently inside a deploy window. */
|
|
108
108
|
modulesInDeployWindow: ReadonlySet<string>;
|
|
109
|
+
/**
|
|
110
|
+
* Modules currently PAUSED. A pause is a deliberate quiescing, so its alerts
|
|
111
|
+
* are explained by the pause itself (openspec/changes/module-pause-lifecycle,
|
|
112
|
+
* task 2.3) — same mechanism as a deploy window, with the pause as the source
|
|
113
|
+
* instead of an ancestor alert.
|
|
114
|
+
*/
|
|
115
|
+
pausedModules: ReadonlySet<string>;
|
|
109
116
|
topology: SuppressionTopology;
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
export type Suppressor =
|
|
113
120
|
| { kind: 'alert'; key: string }
|
|
114
|
-
| { kind: 'deploy_window'; moduleId: string }
|
|
121
|
+
| { kind: 'deploy_window'; moduleId: string }
|
|
122
|
+
| { kind: 'paused'; moduleId: string };
|
|
115
123
|
|
|
116
124
|
/**
|
|
117
125
|
* Find what is suppressing `key`, or null if it should be reported.
|
|
@@ -128,6 +136,15 @@ export function findSuppressor(lookup: SuppressorLookup): Suppressor | null {
|
|
|
128
136
|
|
|
129
137
|
const parsed = parseAlertKey(lookup.key);
|
|
130
138
|
|
|
139
|
+
// A pause is checked before a deploy window because it is the longer-lived
|
|
140
|
+
// and more consequential explanation: a paused module may also be inside a
|
|
141
|
+
// deploy window (unpause redeploys), and "paused" is the fact the operator
|
|
142
|
+
// needs to see. Attributed to the pause rather than suppressed anonymously —
|
|
143
|
+
// silently dropping the alert is what turns a pause into an invisible outage.
|
|
144
|
+
if (parsed?.source === 'module' && lookup.pausedModules.has(parsed.moduleId)) {
|
|
145
|
+
return { kind: 'paused', moduleId: parsed.moduleId };
|
|
146
|
+
}
|
|
147
|
+
|
|
131
148
|
// A deploy is the same mechanism with a window as the source instead of an
|
|
132
149
|
// ancestor alert — which is why deploy auto-silencing is not a second feature.
|
|
133
150
|
if (parsed?.source === 'module' && lookup.modulesInDeployWindow.has(parsed.moduleId)) {
|
|
@@ -4,9 +4,11 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { eq } from 'drizzle-orm';
|
|
6
6
|
import type { DbClient } from '../../db/client';
|
|
7
|
-
import { type Alert, type Monitor, alerts, monitors } from '../../db/schema';
|
|
7
|
+
import { type Alert, type Monitor, alerts, modules, monitors } from '../../db/schema';
|
|
8
8
|
import { setupTestDatabase } from '../../test-utils/setup-test-db';
|
|
9
9
|
import type { HealthCheckResult } from '../health-runner';
|
|
10
|
+
import { deleteModuleConfig, upsertModuleConfig } from '../module-config';
|
|
11
|
+
import { HEALTH_CHECK_INTERVAL_CONFIG_KEY, reconcileModuleWatchState } from './health-cadence';
|
|
10
12
|
import { moduleCheckAlertKey } from './keys';
|
|
11
13
|
import type { MonitorRunDeps } from './run-monitor';
|
|
12
14
|
import { type SuppressionTopology, machineAlertKey } from './suppression';
|
|
@@ -50,6 +52,7 @@ describe('runSweep', () => {
|
|
|
50
52
|
monitorDeps: monitorDeps(result, now),
|
|
51
53
|
loadTopology: () => TOPOLOGY,
|
|
52
54
|
loadDeployWindowModules: () => new Set(),
|
|
55
|
+
loadPausedModules: () => new Set(),
|
|
53
56
|
isSuppressible: () => true,
|
|
54
57
|
// No routes configured: the sweep must still run everything else.
|
|
55
58
|
notifyDepsFor: () => null,
|
|
@@ -66,6 +69,21 @@ describe('runSweep', () => {
|
|
|
66
69
|
const dbPath = join(dir, 'celilo.db');
|
|
67
70
|
process.env.CELILO_DB_PATH = dbPath;
|
|
68
71
|
db = await setupTestDatabase(dbPath);
|
|
72
|
+
// The module row is load-bearing now: a `module_hook` monitor's cadence
|
|
73
|
+
// resolves from the module's manifest and config, not from its own row.
|
|
74
|
+
db.insert(modules)
|
|
75
|
+
.values({
|
|
76
|
+
id: MODULE,
|
|
77
|
+
name: MODULE,
|
|
78
|
+
sourcePath: dir,
|
|
79
|
+
version: '1.0.0',
|
|
80
|
+
state: 'INSTALLED',
|
|
81
|
+
manifestData: {
|
|
82
|
+
id: MODULE,
|
|
83
|
+
hooks: { health_check: { script: './h.ts', interval: '15m' } },
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
.run();
|
|
69
87
|
db.insert(monitors)
|
|
70
88
|
.values({
|
|
71
89
|
id: 'mon-1',
|
|
@@ -116,11 +134,42 @@ describe('runSweep', () => {
|
|
|
116
134
|
expect(report.monitorsRun).toBe(0);
|
|
117
135
|
});
|
|
118
136
|
|
|
119
|
-
|
|
137
|
+
// `enabled` and `intervalMinutes` are builtin_check-only now (design.md D8).
|
|
138
|
+
// A future reader that reintroduces the dependency on the row fails here
|
|
139
|
+
// rather than silently regressing to write-time resolution.
|
|
140
|
+
test("a module monitor's stored enabled flag is not consulted", async () => {
|
|
120
141
|
db.update(monitors).set({ enabled: false }).where(eq(monitors.id, 'mon-1')).run();
|
|
121
142
|
const report = await runSweep(db, currentMonitors(), deps());
|
|
143
|
+
expect(report.monitorsRun).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("a module monitor's stored interval is not consulted", async () => {
|
|
147
|
+
// The row says a full day; the manifest says 15m, which is what wins.
|
|
148
|
+
db.update(monitors).set({ intervalMinutes: 1440 }).where(eq(monitors.id, 'mon-1')).run();
|
|
149
|
+
await runSweep(db, currentMonitors(), deps());
|
|
150
|
+
const report = await runSweep(db, currentMonitors(), deps({}, failing, later(20)));
|
|
151
|
+
expect(report.monitorsRun).toBe(1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('a module whose effective cadence is manual is never run, and its alerts resolve', async () => {
|
|
155
|
+
await runSweep(db, currentMonitors(), deps());
|
|
156
|
+
expect(liveAlerts()).not.toEqual([]);
|
|
157
|
+
|
|
158
|
+
upsertModuleConfig(db, MODULE, HEALTH_CHECK_INTERVAL_CONFIG_KEY, 'manual');
|
|
159
|
+
reconcileModuleWatchState(db, MODULE, later(20));
|
|
160
|
+
|
|
161
|
+
const report = await runSweep(db, currentMonitors(), deps({}, failing, later(20)));
|
|
122
162
|
expect(report.monitorsRun).toBe(0);
|
|
123
|
-
expect(liveAlerts()).
|
|
163
|
+
expect(liveAlerts().every((a) => a.state === 'resolved')).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('unsetting the override resumes watching', async () => {
|
|
167
|
+
upsertModuleConfig(db, MODULE, HEALTH_CHECK_INTERVAL_CONFIG_KEY, 'manual');
|
|
168
|
+
expect((await runSweep(db, currentMonitors(), deps())).monitorsRun).toBe(0);
|
|
169
|
+
|
|
170
|
+
deleteModuleConfig(db, MODULE, HEALTH_CHECK_INTERVAL_CONFIG_KEY);
|
|
171
|
+
const report = await runSweep(db, currentMonitors(), deps({}, failing, later(20)));
|
|
172
|
+
expect(report.monitorsRun).toBe(1);
|
|
124
173
|
});
|
|
125
174
|
|
|
126
175
|
test('recovery resolves the alert on a later sweep', async () => {
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
|
|
22
22
|
import type { DbClient } from '../../db/client';
|
|
23
23
|
import type { Alert, Monitor } from '../../db/schema';
|
|
24
|
+
import { MONITOR_INTERVAL_FLOOR_MINUTES } from '../cadence';
|
|
25
|
+
import { isScheduled, loadModuleHealthCadences } from './health-cadence';
|
|
24
26
|
import type { NotifyDeps, NotifyOutcome } from './notifier';
|
|
25
27
|
import { deliverDeferred, notifyAlert } from './notifier';
|
|
26
28
|
import { type MonitorRunDeps, runOneMonitor } from './run-monitor';
|
|
@@ -43,6 +45,8 @@ export interface SweepDeps {
|
|
|
43
45
|
loadTopology(): SuppressionTopology;
|
|
44
46
|
/** Modules currently inside a deploy window. */
|
|
45
47
|
loadDeployWindowModules(): Set<string>;
|
|
48
|
+
/** Ids of modules currently PAUSED — a pause explains its own module's alerts. */
|
|
49
|
+
loadPausedModules(): Set<string>;
|
|
46
50
|
/** Whether the monitor owning an alert may be suppressed at all. */
|
|
47
51
|
isSuppressible(alert: Alert): boolean;
|
|
48
52
|
/** Compose the per-alert notification context. Null when nothing can page. */
|
|
@@ -122,14 +126,35 @@ export async function runSweep(
|
|
|
122
126
|
};
|
|
123
127
|
|
|
124
128
|
// 1. Run due monitors.
|
|
129
|
+
//
|
|
130
|
+
// A `module_hook` monitor's cadence and whether it is watched at all come
|
|
131
|
+
// from the module's effective health-check cadence, NOT from its row: the row
|
|
132
|
+
// was seeded once at first deploy and never reconsulted, so a corrected
|
|
133
|
+
// manifest could never reach an existing install (design.md D2/D8). A
|
|
134
|
+
// `builtin_check` has no module and no manifest, so its row is the config.
|
|
135
|
+
const cadences = loadModuleHealthCadences(db);
|
|
125
136
|
const due = selectDueMonitors(
|
|
126
|
-
monitors.map((m) =>
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
137
|
+
monitors.map((m) => {
|
|
138
|
+
if (m.kind !== 'module_hook') {
|
|
139
|
+
return {
|
|
140
|
+
id: m.id,
|
|
141
|
+
intervalMinutes: m.intervalMinutes,
|
|
142
|
+
enabled: m.enabled,
|
|
143
|
+
lastRunAt: m.lastRunAt,
|
|
144
|
+
monitor: m,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const cadence = cadences.get(m.target)?.cadence ?? null;
|
|
148
|
+
return {
|
|
149
|
+
id: m.id,
|
|
150
|
+
// Unscheduled monitors are filtered out by `enabled` below, so this
|
|
151
|
+
// value is never used to decide due-ness for them.
|
|
152
|
+
intervalMinutes: isScheduled(cadence) ? cadence.minutes : MONITOR_INTERVAL_FLOOR_MINUTES,
|
|
153
|
+
enabled: isScheduled(cadence),
|
|
154
|
+
lastRunAt: m.lastRunAt,
|
|
155
|
+
monitor: m,
|
|
156
|
+
};
|
|
157
|
+
}),
|
|
133
158
|
deps.now(),
|
|
134
159
|
);
|
|
135
160
|
|
|
@@ -156,6 +181,7 @@ export async function runSweep(
|
|
|
156
181
|
);
|
|
157
182
|
const topology = deps.loadTopology();
|
|
158
183
|
const deployWindows = deps.loadDeployWindowModules();
|
|
184
|
+
const paused = deps.loadPausedModules();
|
|
159
185
|
|
|
160
186
|
for (const alert of live) {
|
|
161
187
|
const suppressor = findSuppressor({
|
|
@@ -163,13 +189,20 @@ export async function runSweep(
|
|
|
163
189
|
firingKeys,
|
|
164
190
|
suppressible: deps.isSuppressible(alert),
|
|
165
191
|
modulesInDeployWindow: deployWindows,
|
|
192
|
+
pausedModules: paused,
|
|
166
193
|
topology,
|
|
167
194
|
});
|
|
168
195
|
const wasSuppressed = alert.state === 'suppressed';
|
|
169
196
|
if (suppressor && !wasSuppressed) {
|
|
170
197
|
markSuppressed(db, alert.id, {
|
|
171
198
|
alertId: suppressor.kind === 'alert' ? suppressor.key : undefined,
|
|
172
|
-
|
|
199
|
+
// A pause is recorded on the same column as a deploy window: both are
|
|
200
|
+
// "a module-scoped condition explains this", and the operator reads the
|
|
201
|
+
// module id either way.
|
|
202
|
+
windowId:
|
|
203
|
+
suppressor.kind === 'deploy_window' || suppressor.kind === 'paused'
|
|
204
|
+
? suppressor.moduleId
|
|
205
|
+
: undefined,
|
|
173
206
|
});
|
|
174
207
|
report.suppressed++;
|
|
175
208
|
} else if (!suppressor && wasSuppressed) {
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
import { eq } from 'drizzle-orm';
|
|
11
11
|
import type { DbClient } from '../../db/client';
|
|
12
|
-
import { backups, modules } from '../../db/schema';
|
|
12
|
+
import { backups, moduleConfigs, modules } from '../../db/schema';
|
|
13
13
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
14
|
+
import { BACKUP_SCHEDULE_CONFIG_KEY } from '../backup-schedule';
|
|
15
|
+
import { parseStoredConfigValue } from '../module-config';
|
|
14
16
|
import type { InstalledModuleBackupInfo } from './backups';
|
|
15
17
|
|
|
16
18
|
const DEPLOYED_STATES = ['INSTALLED', 'VERIFIED'];
|
|
@@ -40,6 +42,7 @@ function latestSuccessfulBackupByModule(db: DbClient): Map<string, number> {
|
|
|
40
42
|
|
|
41
43
|
export function loadBackupAuditInfo(db: DbClient): InstalledModuleBackupInfo[] {
|
|
42
44
|
const latest = latestSuccessfulBackupByModule(db);
|
|
45
|
+
const overrides = backupScheduleOverrides(db);
|
|
43
46
|
return db
|
|
44
47
|
.select()
|
|
45
48
|
.from(modules)
|
|
@@ -49,6 +52,26 @@ export function loadBackupAuditInfo(db: DbClient): InstalledModuleBackupInfo[] {
|
|
|
49
52
|
id: module.id,
|
|
50
53
|
state: module.state,
|
|
51
54
|
manifest: module.manifestData as ModuleManifest,
|
|
55
|
+
scheduleOverride: overrides.get(module.id),
|
|
52
56
|
lastSuccessfulBackupAt: latest.get(module.id) ?? null,
|
|
53
57
|
}));
|
|
54
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Every operator backup-cadence override, by module.
|
|
62
|
+
*
|
|
63
|
+
* Read here rather than resolved here: the audit resolves override-against-
|
|
64
|
+
* manifest through the same accessor the backup sweep uses, so the two cannot
|
|
65
|
+
* disagree about what a module's cadence is.
|
|
66
|
+
*/
|
|
67
|
+
export function backupScheduleOverrides(db: DbClient): Map<string, string> {
|
|
68
|
+
const overrides = new Map<string, string>();
|
|
69
|
+
for (const row of db
|
|
70
|
+
.select()
|
|
71
|
+
.from(moduleConfigs)
|
|
72
|
+
.where(eq(moduleConfigs.key, BACKUP_SCHEDULE_CONFIG_KEY))
|
|
73
|
+
.all()) {
|
|
74
|
+
overrides.set(row.moduleId, String(parseStoredConfigValue(row)));
|
|
75
|
+
}
|
|
76
|
+
return overrides;
|
|
77
|
+
}
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
3
|
-
import {
|
|
3
|
+
import { parseCadence } from '../cadence';
|
|
4
|
+
import { type InstalledModuleBackupInfo, auditBackups, backupStaleThresholdMs } from './backups';
|
|
4
5
|
|
|
5
6
|
const NOW = new Date('2026-04-25T00:00:00Z').getTime();
|
|
6
7
|
const HOUR = 60 * 60 * 1000;
|
|
7
8
|
const ONE_DAY = 24 * HOUR;
|
|
8
9
|
|
|
9
|
-
type Schedule = 'hourly' | 'daily' | 'weekly' | 'monthly' | 'manual';
|
|
10
|
-
|
|
11
10
|
function makeModule(
|
|
12
11
|
id: string,
|
|
13
12
|
opts: {
|
|
14
13
|
hasBackupHook: boolean;
|
|
15
14
|
lastSuccessfulBackupAt: number | null;
|
|
16
|
-
schedule?:
|
|
15
|
+
schedule?: string;
|
|
16
|
+
scheduleOverride?: string;
|
|
17
17
|
},
|
|
18
18
|
): InstalledModuleBackupInfo {
|
|
19
19
|
const manifest = {
|
|
@@ -27,9 +27,42 @@ function makeModule(
|
|
|
27
27
|
// Default to INSTALLED — existing tests assert backup findings
|
|
28
28
|
// fire, which is the deployed-module behavior. Tests for the
|
|
29
29
|
// non-deployed-skip behavior override this explicitly.
|
|
30
|
-
return {
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
state: 'INSTALLED',
|
|
33
|
+
manifest,
|
|
34
|
+
scheduleOverride: opts.scheduleOverride,
|
|
35
|
+
lastSuccessfulBackupAt: opts.lastSuccessfulBackupAt,
|
|
36
|
+
};
|
|
31
37
|
}
|
|
32
38
|
|
|
39
|
+
describe('backupStaleThresholdMs', () => {
|
|
40
|
+
function cadence(value: string) {
|
|
41
|
+
const parsed = parseCadence(value);
|
|
42
|
+
if (parsed === null) throw new Error(`test fixture is not a cadence: ${value}`);
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The four values the change's release note promises. A table could not
|
|
47
|
+
// answer for `6h`, so these come out of `cadence + max(1h, cadence × 0.1)`;
|
|
48
|
+
// `weekly` is the only one that now alerts EARLIER than it used to (8d).
|
|
49
|
+
test('the four documented thresholds', () => {
|
|
50
|
+
expect(backupStaleThresholdMs(cadence('hourly'))).toBe(2 * HOUR);
|
|
51
|
+
expect(backupStaleThresholdMs(cadence('daily'))).toBe(26.4 * HOUR);
|
|
52
|
+
expect(backupStaleThresholdMs(cadence('weekly'))).toBe(7.7 * ONE_DAY);
|
|
53
|
+
expect(backupStaleThresholdMs(cadence('monthly'))).toBe(33 * ONE_DAY);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a custom duration has a defined threshold', () => {
|
|
57
|
+
expect(backupStaleThresholdMs(cadence('6h'))).toBe(7 * HOUR);
|
|
58
|
+
expect(backupStaleThresholdMs(cadence('90m'))).toBe(90 * 60_000 + HOUR);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('manual has none — an opted-out module is never stale', () => {
|
|
62
|
+
expect(backupStaleThresholdMs('manual')).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
33
66
|
describe('auditBackups', () => {
|
|
34
67
|
test('skips modules without an on_backup hook', async () => {
|
|
35
68
|
const result = await auditBackups({
|
|
@@ -39,7 +72,10 @@ describe('auditBackups', () => {
|
|
|
39
72
|
expect(result).toEqual([]);
|
|
40
73
|
});
|
|
41
74
|
|
|
42
|
-
|
|
75
|
+
// A module opted out of scheduled backups used to be reported as missing one
|
|
76
|
+
// forever: the never-backed-up check fired BEFORE the manual check, and the
|
|
77
|
+
// only remediation offered was the very thing the operator declined.
|
|
78
|
+
test('a module opted out is not reported as missing a backup', async () => {
|
|
43
79
|
const result = await auditBackups({
|
|
44
80
|
modules: [
|
|
45
81
|
makeModule('lunacycle', {
|
|
@@ -51,14 +87,63 @@ describe('auditBackups', () => {
|
|
|
51
87
|
now: () => NOW,
|
|
52
88
|
});
|
|
53
89
|
|
|
90
|
+
expect(result).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a module that has NOT opted out is still reported as missing a backup', async () => {
|
|
94
|
+
const result = await auditBackups({
|
|
95
|
+
modules: [
|
|
96
|
+
makeModule('authentik', {
|
|
97
|
+
hasBackupHook: true,
|
|
98
|
+
lastSuccessfulBackupAt: null,
|
|
99
|
+
schedule: 'daily',
|
|
100
|
+
}),
|
|
101
|
+
],
|
|
102
|
+
now: () => NOW,
|
|
103
|
+
});
|
|
104
|
+
|
|
54
105
|
expect(result).toHaveLength(1);
|
|
55
106
|
expect(result[0]).toMatchObject({
|
|
56
107
|
category: 'backups',
|
|
57
108
|
severity: 'drift',
|
|
58
109
|
code: 'backup_missing',
|
|
59
|
-
subject: '
|
|
110
|
+
subject: 'authentik',
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("an operator's manual override silences a module the manifest wanted backed up", async () => {
|
|
115
|
+
const result = await auditBackups({
|
|
116
|
+
modules: [
|
|
117
|
+
makeModule('authentik', {
|
|
118
|
+
hasBackupHook: true,
|
|
119
|
+
lastSuccessfulBackupAt: null,
|
|
120
|
+
schedule: 'daily',
|
|
121
|
+
scheduleOverride: 'manual',
|
|
122
|
+
}),
|
|
123
|
+
],
|
|
124
|
+
now: () => NOW,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(result).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('staleness is judged against the override, not the suggestion', async () => {
|
|
131
|
+
// Manifest says weekly (fresh at 2 days); the operator asked for hourly.
|
|
132
|
+
const result = await auditBackups({
|
|
133
|
+
modules: [
|
|
134
|
+
makeModule('caddy', {
|
|
135
|
+
hasBackupHook: true,
|
|
136
|
+
lastSuccessfulBackupAt: NOW - 2 * ONE_DAY,
|
|
137
|
+
schedule: 'weekly',
|
|
138
|
+
scheduleOverride: 'hourly',
|
|
139
|
+
}),
|
|
140
|
+
],
|
|
141
|
+
now: () => NOW,
|
|
60
142
|
});
|
|
61
|
-
|
|
143
|
+
|
|
144
|
+
expect(result).toHaveLength(1);
|
|
145
|
+
expect(result[0].code).toBe('backup_stale');
|
|
146
|
+
expect(result[0].message).toContain('hourly');
|
|
62
147
|
});
|
|
63
148
|
|
|
64
149
|
test('manual schedule: never flags stale (user-driven cadence)', async () => {
|
|
@@ -93,12 +178,12 @@ describe('auditBackups', () => {
|
|
|
93
178
|
expect(result[0].message).toContain('daily');
|
|
94
179
|
});
|
|
95
180
|
|
|
96
|
-
test('daily schedule:
|
|
181
|
+
test('daily schedule: 27h-old is stale', async () => {
|
|
97
182
|
const result = await auditBackups({
|
|
98
183
|
modules: [
|
|
99
184
|
makeModule('authentik', {
|
|
100
185
|
hasBackupHook: true,
|
|
101
|
-
lastSuccessfulBackupAt: NOW -
|
|
186
|
+
lastSuccessfulBackupAt: NOW - 27 * HOUR,
|
|
102
187
|
schedule: 'daily',
|
|
103
188
|
}),
|
|
104
189
|
],
|