@celilo/cli 1.5.0 → 1.7.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 -1
- package/CELILO_SUBSYSTEMS.md +18 -2
- package/MODULE_PRIMITIVES.md +25 -7
- package/drizzle/0026_module_integrity_version.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/lookup.ts +39 -29
- package/src/capabilities/secret-ref.test.ts +24 -0
- package/src/capabilities/secret-validation.ts +50 -0
- package/src/capabilities/validation.test.ts +187 -2
- package/src/capabilities/validation.ts +53 -1
- package/src/cli/commands/alerts-sweep.ts +18 -0
- package/src/cli/commands/module-audit.ts +5 -2
- package/src/cli/commands/module-remove.ts +34 -2
- package/src/cli/commands/module-update.test.ts +238 -3
- package/src/cli/commands/module-update.ts +206 -12
- package/src/cli/commands/module-verify.ts +77 -13
- package/src/cli/commands/service-set-credentials.test.ts +108 -0
- package/src/cli/commands/service-set-credentials.ts +115 -0
- package/src/cli/commands/system-audit.ts +17 -0
- package/src/cli/commands/system-doctor.ts +78 -2
- package/src/cli/commands/system-migrate.ts +6 -4
- package/src/cli/commands/system-update.ts +33 -3
- package/src/cli/completion.ts +16 -1
- package/src/cli/index.ts +11 -2
- package/src/cli/tui/audit-state.ts +11 -3
- package/src/cli/tui/audit-tui.tsx +10 -4
- package/src/cli/tui/icons.ts +9 -2
- package/src/cli/tui/modals/analyzing.tsx +3 -0
- package/src/db/client.ts +10 -8
- package/src/db/migrate.test.ts +147 -0
- package/src/db/migrate.ts +69 -1
- package/src/db/schema.ts +5 -0
- package/src/hooks/capability-loader.test.ts +55 -0
- package/src/hooks/capability-loader.ts +16 -1
- package/src/manifest/json-schema-roundtrip.test.ts +12 -4
- package/src/manifest/schema.ts +23 -0
- package/src/module/import.ts +56 -40
- package/src/module/packaging/audit.ts +103 -28
- package/src/module/packaging/build.ts +12 -53
- package/src/module/packaging/classify-module-path.test.ts +104 -0
- package/src/module/packaging/extract.ts +31 -3
- package/src/module/packaging/generated-plane.test.ts +79 -0
- package/src/module/packaging/generated-plane.ts +134 -0
- package/src/module/packaging/host-plane.test.ts +132 -0
- package/src/module/packaging/host-plane.ts +135 -0
- package/src/module/packaging/package-rules.ts +62 -0
- package/src/policy/module-business-baseline.ts +0 -11
- package/src/services/alerting/monitors.ts +54 -2
- package/src/services/alerting/sweep-runner.ts +38 -1
- package/src/services/audit/cli-version.test.ts +6 -2
- package/src/services/audit/cli-version.ts +20 -6
- package/src/services/audit/detect-without-converge.test.ts +91 -0
- package/src/services/audit/detect-without-converge.ts +81 -0
- package/src/services/audit/disk-space.test.ts +5 -2
- package/src/services/audit/disk-space.ts +5 -3
- package/src/services/audit/health.test.ts +39 -0
- package/src/services/audit/index.test.ts +7 -1
- package/src/services/audit/index.ts +12 -0
- package/src/services/audit/module-integrity.test.ts +146 -0
- package/src/services/audit/module-integrity.ts +113 -0
- package/src/services/audit/module-versions.ts +4 -1
- package/src/services/audit/schema.test.ts +7 -2
- package/src/services/audit/schema.ts +19 -1
- package/src/services/audit/terraform-plan.ts +17 -2
- package/src/services/audit/types.test.ts +29 -0
- package/src/services/audit/types.ts +30 -4
- package/src/services/consumer-cleanup.ts +5 -3
- package/src/services/container-service.test.ts +34 -0
- package/src/services/container-service.ts +44 -0
- package/src/services/deployed-systems.test.ts +101 -0
- package/src/services/deployed-systems.ts +43 -11
- package/src/services/dns-provider-backfill.ts +30 -0
- package/src/services/fleet-checks.test.ts +26 -0
- package/src/services/fleet-checks.ts +11 -1
- package/src/services/module-deploy.ts +109 -41
- package/src/services/provider-arrival.test.ts +241 -0
- package/src/services/provider-arrival.ts +213 -0
- package/src/services/restore-from-file.ts +4 -0
- package/src/services/update/orchestrator.test.ts +2 -0
- package/src/templates/generator.test.ts +35 -0
- package/src/templates/generator.ts +29 -1
- package/src/variables/context.test.ts +63 -0
- package/src/variables/context.ts +10 -2
- package/src/variables/declarative-derivation.test.ts +47 -8
- package/src/variables/declarative-derivation.ts +6 -4
- package/src/services/public-web-republish.test.ts +0 -189
- package/src/services/public-web-republish.ts +0 -84
|
@@ -42,3 +42,65 @@ export function includeNodeModulesPath(relPath: string): boolean {
|
|
|
42
42
|
if (nmIdx + 2 >= segments.length) return true; // node_modules/@celilo dir itself
|
|
43
43
|
return segments[nmIdx + 2] === 'capabilities';
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What a module-relative path IS, for integrity purposes.
|
|
48
|
+
*
|
|
49
|
+
* - `package` the module's own content. Belongs in `checksums.json` and must
|
|
50
|
+
* match it. A mismatch is a real finding.
|
|
51
|
+
* - `derived` celilo writes or rewrites the on-disk copy, so its content is
|
|
52
|
+
* not a stable integrity claim. Never a finding.
|
|
53
|
+
* - `unknown` neither. Never packaged, never installed, and the only kind of
|
|
54
|
+
* `[EXTRA]` worth printing.
|
|
55
|
+
*/
|
|
56
|
+
export type ModulePathClass = 'package' | 'derived' | 'unknown';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The one answer to "what belongs to a module", replacing the four divergent
|
|
60
|
+
* copies that used to decide it independently (`build.ts#shouldExclude`,
|
|
61
|
+
* `audit.ts#FRAMEWORK_OWNED_PATHS`, `extract.ts#scanDirectory`,
|
|
62
|
+
* `import.ts#copyModuleFiles`). Every disagreement between them became a
|
|
63
|
+
* `module verify` violation — 72 of them across two healthy modules.
|
|
64
|
+
*
|
|
65
|
+
* `scripts/node_modules/**` is the interesting case: it is `derived`, because
|
|
66
|
+
* `module import` runs `bun install` over it and the bytes on disk stop
|
|
67
|
+
* matching the package's. It is nonetheless SHIPPED, because a target may have
|
|
68
|
+
* no reachable registry (ISS-0046) — that carve-out lives in `build.ts`, which
|
|
69
|
+
* composes this function with `includeNodeModulesPath` rather than restating
|
|
70
|
+
* either rule.
|
|
71
|
+
*/
|
|
72
|
+
export function classifyModulePath(relPath: string): ModulePathClass {
|
|
73
|
+
const segments = relPath.split('/');
|
|
74
|
+
const name = segments[segments.length - 1] ?? '';
|
|
75
|
+
|
|
76
|
+
// The module's own e2e/ tree is tests plus their deps, including a
|
|
77
|
+
// node_modules of its own. Excluded whole, before anything below.
|
|
78
|
+
if (segments[0] === 'e2e') return 'unknown';
|
|
79
|
+
|
|
80
|
+
// Anything under a `node_modules` segment is decided by the canonical rule
|
|
81
|
+
// and by nothing else. Ordering matters: a vendored dependency ships files
|
|
82
|
+
// named `*.test.ts` (`@celilo/capabilities/src/remote.test.ts` is on the
|
|
83
|
+
// fleet right now) and those are the DEPENDENCY's, not the module's.
|
|
84
|
+
if (segments.includes('node_modules')) {
|
|
85
|
+
return includeNodeModulesPath(relPath) ? 'derived' : 'unknown';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Source-tree noise. A module's git repo has it, a module's install never
|
|
89
|
+
// should, so on an installed tree it is a real finding.
|
|
90
|
+
if (segments.some((s) => s === '.git' || s === '.next' || s === '.cache')) return 'unknown';
|
|
91
|
+
if (name === '.DS_Store') return 'unknown';
|
|
92
|
+
// scripts/tsconfig.json exists so tsc can check hooks in CI. Nothing on a
|
|
93
|
+
// target ever runs tsc.
|
|
94
|
+
if (name === 'tsconfig.json') return 'unknown';
|
|
95
|
+
if (name.endsWith('.netapp') || name.endsWith('.test.ts')) return 'unknown';
|
|
96
|
+
|
|
97
|
+
// Celilo's own output under the module's install root.
|
|
98
|
+
if (segments[0] === 'generated' || segments[0] === 'screenshots') return 'derived';
|
|
99
|
+
// A checksum manifest cannot list itself, nor the signature over it.
|
|
100
|
+
if (relPath === 'checksums.json' || relPath === 'signature.sig') return 'derived';
|
|
101
|
+
// Regenerated by `module import` from the manifest (HOOK_API_V2 Phase 2).
|
|
102
|
+
if (relPath === 'celilo/types.d.ts') return 'derived';
|
|
103
|
+
if (relPath === 'cookies.json') return 'derived';
|
|
104
|
+
|
|
105
|
+
return 'package';
|
|
106
|
+
}
|
|
@@ -248,12 +248,6 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
|
|
|
248
248
|
count: 2,
|
|
249
249
|
why: "S16 — two more copies of S15's decision; fixing S15 removes all three (#938)",
|
|
250
250
|
},
|
|
251
|
-
{
|
|
252
|
-
file: 'apps/celilo/src/services/public-web-republish.ts',
|
|
253
|
-
capability: 'public_web',
|
|
254
|
-
count: 1,
|
|
255
|
-
why: "S2 — encodes caddy's redeploy behaviour; generalise to a provider-declared re-assert signal (#945)",
|
|
256
|
-
},
|
|
257
251
|
{
|
|
258
252
|
file: 'apps/celilo/src/services/zone-policy.ts',
|
|
259
253
|
capability: 'public_web',
|
|
@@ -391,11 +385,6 @@ export const SERVICE_FILENAME_BASELINE: readonly ServiceFilenameRow[] = [
|
|
|
391
385
|
capability: 'firewall',
|
|
392
386
|
why: 'S8 — core reaching into one provider implementation (#941)',
|
|
393
387
|
},
|
|
394
|
-
{
|
|
395
|
-
file: 'apps/celilo/src/services/public-web-republish.ts',
|
|
396
|
-
capability: 'public_web',
|
|
397
|
-
why: "S2 — named for one provider's redeploy behaviour (#945)",
|
|
398
|
-
},
|
|
399
388
|
];
|
|
400
389
|
|
|
401
390
|
export const PROVIDER_LITERAL_BASELINE: readonly ProviderLiteralRow[] = [
|
|
@@ -214,7 +214,59 @@ export function ensureInboundSubscriber(bus: SubscriberRegistrar): void {
|
|
|
214
214
|
});
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
|
|
218
|
-
|
|
217
|
+
/** A live alert about to be deleted along with the monitor that owns it. */
|
|
218
|
+
export interface DroppedAlert {
|
|
219
|
+
key: string;
|
|
220
|
+
message: string;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Delete a monitor, and report the live alerts that go with it.
|
|
225
|
+
*
|
|
226
|
+
* The alerts are DELETED, not resolved. `alerts.monitorId` is
|
|
227
|
+
* `on delete cascade`, so the row goes the moment the monitor does. This used
|
|
228
|
+
* to call `resolveMonitorAlerts` first; that write was unreachable — the very
|
|
229
|
+
* next statement dropped the same rows, and nothing read them in between, so
|
|
230
|
+
* the resolved state existed for the duration of one statement. Deleted rather
|
|
231
|
+
* than restored, because resolving properly would need the alert rows to
|
|
232
|
+
* outlive their monitor, which the FK forbids and which no reader wants:
|
|
233
|
+
* `resolvedAt` is only ever read as a liveness predicate (`ack.ts`), never
|
|
234
|
+
* reported. Do not put the call back without changing the FK first.
|
|
235
|
+
*
|
|
236
|
+
* What IS worth keeping is what was lost, which is why the live alerts are
|
|
237
|
+
* returned. A monitor dropped while holding a firing alert takes a real
|
|
238
|
+
* failure and the coverage of it away together, and a caller that only names
|
|
239
|
+
* the monitor cannot tell an operator what stopped being watched.
|
|
240
|
+
*/
|
|
241
|
+
export function deleteMonitor(db: DbClient, monitorId: string): DroppedAlert[] {
|
|
242
|
+
const dropped = db
|
|
243
|
+
.select({ key: alerts.key, message: alerts.message })
|
|
244
|
+
.from(alerts)
|
|
245
|
+
.where(and(eq(alerts.monitorId, monitorId), isNotNull(alerts.activeKey)))
|
|
246
|
+
.all();
|
|
247
|
+
|
|
219
248
|
db.delete(monitors).where(eq(monitors.id, monitorId)).run();
|
|
249
|
+
return dropped;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Drop the `module_hook` monitor belonging to a module that is going away.
|
|
254
|
+
*
|
|
255
|
+
* Called from the removal path rather than expressed as a foreign key, because
|
|
256
|
+
* `monitors.target` cannot carry one: it holds a module id for `module_hook`
|
|
257
|
+
* and an audit check name for `builtin_check`, and SQLite has no conditional
|
|
258
|
+
* reference. Splitting the table to get the constraint would need a synthetic
|
|
259
|
+
* monitor identity for `alerts.monitorId` — the trade the file header already
|
|
260
|
+
* weighs and declines.
|
|
261
|
+
*
|
|
262
|
+
* Deleting cascades the monitor's alerts away (`alerts.monitorId` is
|
|
263
|
+
* `on delete cascade`), which is what releases anything they were suppressing.
|
|
264
|
+
* They are deleted, not resolved — see `deleteMonitor`. Returns what was live
|
|
265
|
+
* so the caller can say what stopped being watched, or null if the module had
|
|
266
|
+
* no monitor.
|
|
267
|
+
*/
|
|
268
|
+
export function deleteMonitorForModule(db: DbClient, moduleId: string): DroppedAlert[] | null {
|
|
269
|
+
const monitor = findMonitor(db, 'module_hook', moduleId);
|
|
270
|
+
if (!monitor) return null;
|
|
271
|
+
return deleteMonitor(db, monitor.id);
|
|
220
272
|
}
|
|
@@ -23,6 +23,7 @@ import type { DbClient } from '../../db/client';
|
|
|
23
23
|
import type { Alert, Monitor } from '../../db/schema';
|
|
24
24
|
import { MONITOR_INTERVAL_FLOOR_MINUTES } from '../cadence';
|
|
25
25
|
import { isScheduled, loadModuleHealthCadences } from './health-cadence';
|
|
26
|
+
import { type DroppedAlert, deleteMonitor } from './monitors';
|
|
26
27
|
import type { NotifyDeps, NotifyOutcome } from './notifier';
|
|
27
28
|
import { deliverDeferred, notifyAlert } from './notifier';
|
|
28
29
|
import { type MonitorRunDeps, runOneMonitor } from './run-monitor';
|
|
@@ -102,6 +103,19 @@ export interface SweepReport {
|
|
|
102
103
|
* former, because nothing ever reached the daemon.
|
|
103
104
|
*/
|
|
104
105
|
failures: string[];
|
|
106
|
+
/**
|
|
107
|
+
* Modules whose `module_hook` monitor was dropped because the module itself
|
|
108
|
+
* is gone, and the live alerts each one took with it.
|
|
109
|
+
*
|
|
110
|
+
* The alerts are DELETED by the monitor's cascade, not resolved, so a firing
|
|
111
|
+
* check and the coverage of it disappear in the same instant with nothing
|
|
112
|
+
* else recording either. A bare module name is not enough to act on: the row
|
|
113
|
+
* that mattered on the fleet was holding `Cannot reach router: Router login
|
|
114
|
+
* failed`, and `1 stranded-dropped (greenwave)` would not have told anyone
|
|
115
|
+
* that a router had stopped being checked. Same reason `noPolicy`, `skipped`
|
|
116
|
+
* and `failures` all name their subject rather than counting it.
|
|
117
|
+
*/
|
|
118
|
+
strandedDropped: { moduleId: string; alerts: DroppedAlert[] }[];
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
/**
|
|
@@ -129,6 +143,7 @@ export async function runSweep(
|
|
|
129
143
|
noPolicy: [],
|
|
130
144
|
skipped: [],
|
|
131
145
|
failures: [],
|
|
146
|
+
strandedDropped: [],
|
|
132
147
|
};
|
|
133
148
|
|
|
134
149
|
// 1. Run due monitors.
|
|
@@ -139,8 +154,30 @@ export async function runSweep(
|
|
|
139
154
|
// manifest could never reach an existing install (design.md D2/D8). A
|
|
140
155
|
// `builtin_check` has no module and no manifest, so its row is the config.
|
|
141
156
|
const cadences = loadModuleHealthCadences(db);
|
|
157
|
+
|
|
158
|
+
// Reconcile away monitors whose module is gone, before anything tries to run
|
|
159
|
+
// them. `loadModuleHealthCadences` holds every module, so a miss here means
|
|
160
|
+
// no module row — and a `module_hook` monitor without one is unschedulable
|
|
161
|
+
// forever: its cadence resolves to null, `isScheduled` says false, and the
|
|
162
|
+
// sweep never selects it again. Anything it left firing would sit there with
|
|
163
|
+
// no path back to `resolved`, suppressing every alert it is an ancestor of
|
|
164
|
+
// (celilo#1029).
|
|
165
|
+
//
|
|
166
|
+
// The removal path deletes the monitor itself, so this only fires for rows a
|
|
167
|
+
// celilo without that fix left behind, or a removal that died between the two
|
|
168
|
+
// deletes. Same stance `setMonitorEnabled` already takes: a monitor that will
|
|
169
|
+
// never report again must not hold live alerts.
|
|
170
|
+
const stranded = monitors.filter((m) => m.kind === 'module_hook' && !cadences.has(m.target));
|
|
171
|
+
for (const monitor of stranded) {
|
|
172
|
+
report.strandedDropped.push({
|
|
173
|
+
moduleId: monitor.target,
|
|
174
|
+
alerts: deleteMonitor(db, monitor.id),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
const active = stranded.length > 0 ? monitors.filter((m) => !stranded.includes(m)) : monitors;
|
|
178
|
+
|
|
142
179
|
const due = selectDueMonitors(
|
|
143
|
-
|
|
180
|
+
active.map((m) => {
|
|
144
181
|
if (m.kind !== 'module_hook') {
|
|
145
182
|
return {
|
|
146
183
|
id: m.id,
|
|
@@ -50,11 +50,15 @@ describe('auditCliVersion', () => {
|
|
|
50
50
|
expect(result[0].message).toContain('0.1.7');
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
test('
|
|
53
|
+
test('a registry we could not reach is unmeasured, not up-to-date', async () => {
|
|
54
|
+
// Was `expect(result).toEqual([])`. "I could not ask npm" and "you are on
|
|
55
|
+
// the latest" are different statements and rendered identically.
|
|
54
56
|
const result = await auditCliVersion({
|
|
55
57
|
installedVersion: '0.1.5',
|
|
56
58
|
fetcher: async () => null,
|
|
57
59
|
});
|
|
58
|
-
expect(result).
|
|
60
|
+
expect(result).toHaveLength(1);
|
|
61
|
+
expect(result[0]?.severity).toBe('unmeasured');
|
|
62
|
+
expect(result[0]?.code).toBe('cli_version_unmeasured');
|
|
59
63
|
});
|
|
60
64
|
});
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* CLI version drift check.
|
|
3
3
|
*
|
|
4
4
|
* Compares the running `@celilo/cli` version against the latest version
|
|
5
|
-
* on npm. A newer published version produces a single `drift` finding;
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* on npm. A newer published version produces a single `drift` finding; a
|
|
6
|
+
* network failure (offline, npm down) produces an `unmeasured` one. It used to
|
|
7
|
+
* produce nothing, which rendered as READY — "I could not ask npm" and "you are
|
|
8
|
+
* on the latest" are different statements and the operator should be able to
|
|
9
|
+
* tell them apart (D7). It still does not BLOCK: schema drift and capability
|
|
10
|
+
* ABI drift are the blocking gates; CLI version is informational.
|
|
10
11
|
*
|
|
11
12
|
* `latestVersionFetcher` is injectable so tests don't hit npm.
|
|
12
13
|
*/
|
|
@@ -67,7 +68,20 @@ export interface CliVersionAuditDeps {
|
|
|
67
68
|
export async function auditCliVersion(deps: CliVersionAuditDeps): Promise<DriftFinding[]> {
|
|
68
69
|
const fetcher = deps.fetcher ?? fetchLatestCliVersion;
|
|
69
70
|
const latest = await fetcher();
|
|
70
|
-
if (!latest)
|
|
71
|
+
if (!latest) {
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
category: 'cli_version',
|
|
75
|
+
severity: 'unmeasured',
|
|
76
|
+
code: 'cli_version_unmeasured',
|
|
77
|
+
message: `@celilo/cli ${deps.installedVersion}: could not reach the npm registry, so version drift is unknown`,
|
|
78
|
+
remediation:
|
|
79
|
+
'Check outbound network to registry.npmjs.org, then re-audit. This finding records that the comparison did not happen, not that the CLI is current.',
|
|
80
|
+
actionable: false,
|
|
81
|
+
subject: 'system',
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
}
|
|
71
85
|
|
|
72
86
|
if (compareSemver(deps.installedVersion, latest) >= 0) {
|
|
73
87
|
return []; // up to date or ahead (dev build)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ModuleManifest } from '../../manifest/schema';
|
|
3
|
+
import { auditDetectWithoutConverge } from './detect-without-converge';
|
|
4
|
+
|
|
5
|
+
function mod(
|
|
6
|
+
id: string,
|
|
7
|
+
manifest: Partial<ModuleManifest>,
|
|
8
|
+
state = 'INSTALLED',
|
|
9
|
+
): { id: string; state: string; manifest: ModuleManifest } {
|
|
10
|
+
return { id, state, manifest: manifest as ModuleManifest };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const HOOK = { script: 'scripts/x.ts' };
|
|
14
|
+
|
|
15
|
+
describe('auditDetectWithoutConverge', () => {
|
|
16
|
+
test('a convergence hook no subscription fires is drift', () => {
|
|
17
|
+
// celilo#934's shape: the module has the correcting code and nothing runs
|
|
18
|
+
// it, so drift is computed every health check and corrected never.
|
|
19
|
+
const findings = auditDetectWithoutConverge({
|
|
20
|
+
modules: [mod('wireguard', { hooks: { reconcile_peers: HOOK } })],
|
|
21
|
+
});
|
|
22
|
+
expect(findings).toHaveLength(1);
|
|
23
|
+
expect(findings[0]?.code).toBe('converge_hook_never_fires');
|
|
24
|
+
expect(findings[0]?.message).toContain('reconcile_peers');
|
|
25
|
+
expect(findings[0]?.subject).toBe('wireguard');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('the same module WIRED UP is not reported', () => {
|
|
29
|
+
// This is `wireguard` after celilo#968.
|
|
30
|
+
const findings = auditDetectWithoutConverge({
|
|
31
|
+
modules: [
|
|
32
|
+
mod('wireguard', {
|
|
33
|
+
hooks: { reconcile_peers: HOOK },
|
|
34
|
+
subscriptions: [
|
|
35
|
+
{ name: 'wireguard-peer-converge', pattern: 'timer.tick.15m', hook: 'reconcile_peers' },
|
|
36
|
+
],
|
|
37
|
+
} as Partial<ModuleManifest>),
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
expect(findings).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a subscription firing a DIFFERENT hook does not count', () => {
|
|
44
|
+
const findings = auditDetectWithoutConverge({
|
|
45
|
+
modules: [
|
|
46
|
+
mod('m', {
|
|
47
|
+
hooks: { reconcile_peers: HOOK, reconcile_routes: HOOK },
|
|
48
|
+
subscriptions: [{ name: 's', pattern: 'timer.tick.15m', hook: 'reconcile_routes' }],
|
|
49
|
+
} as Partial<ModuleManifest>),
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
expect(findings).toHaveLength(1);
|
|
53
|
+
expect(findings[0]?.message).toContain('reconcile_peers');
|
|
54
|
+
expect(findings[0]?.message).not.toContain('reconcile_routes');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('a module with no convergence hook at all is not reported', () => {
|
|
58
|
+
// A module that detects drift and declares no correcting hook is a missing
|
|
59
|
+
// feature, not dead wiring. Guessing at it from `health_check` alone would
|
|
60
|
+
// flag most of the fleet.
|
|
61
|
+
expect(
|
|
62
|
+
auditDetectWithoutConverge({
|
|
63
|
+
modules: [mod('caddy', { hooks: { health_check: { script: 'x.ts' } } })],
|
|
64
|
+
}),
|
|
65
|
+
).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('`list_peers` is detection, not convergence, and is not required to fire', () => {
|
|
69
|
+
expect(
|
|
70
|
+
auditDetectWithoutConverge({ modules: [mod('wireguard', { hooks: { list_peers: HOOK } })] }),
|
|
71
|
+
).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('an undeployed module is not reported', () => {
|
|
75
|
+
expect(
|
|
76
|
+
auditDetectWithoutConverge({
|
|
77
|
+
modules: [mod('m', { hooks: { reconcile_peers: HOOK } }, 'IMPORTED')],
|
|
78
|
+
}),
|
|
79
|
+
).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the remediation is prose, and is NOT marked actionable', () => {
|
|
83
|
+
// The fix is an edit to a module's manifest. Marking it actionable would
|
|
84
|
+
// put a Remediate button on a modal that cannot do anything.
|
|
85
|
+
const findings = auditDetectWithoutConverge({
|
|
86
|
+
modules: [mod('wireguard', { hooks: { reconcile_peers: HOOK } })],
|
|
87
|
+
});
|
|
88
|
+
expect(findings[0]?.actionable).toBe(false);
|
|
89
|
+
expect(findings[0]?.remediation).toContain('manifest.yml');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A module that carries convergence code nothing ever runs
|
|
3
|
+
* (openspec/changes/module-integrity-rigor, D10; celilo#934).
|
|
4
|
+
*
|
|
5
|
+
* No convergence framework. celilo already has one — `subscriptions:` plus
|
|
6
|
+
* `timer.tick.<n>`, driven by the event bus, used by `technitium`,
|
|
7
|
+
* `wireguard-manager`, `namecheap` and `celilo-mgmt`. celilo#934 asked whether
|
|
8
|
+
* drift detection and correction deserve a shared shape; they have one. The
|
|
9
|
+
* gap was that nothing noticed when a module had the shape and not the wiring,
|
|
10
|
+
* so `wireguard` computed peer drift and corrected none of it.
|
|
11
|
+
*
|
|
12
|
+
* **What this rule actually checks, and how it narrows D10.** The design says
|
|
13
|
+
* "a module whose health check computes drift and whose manifest declares no
|
|
14
|
+
* reconcile subscription". The first half is not decidable from a manifest —
|
|
15
|
+
* celilo cannot read a hook script and tell whether it computes drift — so
|
|
16
|
+
* this checks the decidable, strictly stronger half: a module that DECLARES a
|
|
17
|
+
* convergence hook which no subscription fires. That names dead machinery with
|
|
18
|
+
* no interpretation and no false positives. A module that detects drift and
|
|
19
|
+
* declares no correcting hook at all is a missing feature, and a rule that
|
|
20
|
+
* guessed at it from `health_check` alone would flag most of the fleet.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ModuleManifest } from '../../manifest/schema';
|
|
24
|
+
import type { DriftFinding } from './types';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hooks whose whole purpose is to CORRECT something. If one of these exists
|
|
28
|
+
* and nothing calls it, the module has the fix and never applies it.
|
|
29
|
+
*
|
|
30
|
+
* `list_peers` is deliberately absent: it reports what a tunnel carries and
|
|
31
|
+
* corrects nothing, so it is a detection hook and firing it on a timer would
|
|
32
|
+
* achieve nothing.
|
|
33
|
+
*/
|
|
34
|
+
const CONVERGENCE_HOOKS = [
|
|
35
|
+
'reconcile_routes',
|
|
36
|
+
'reconcile_clients',
|
|
37
|
+
'reconcile_peers',
|
|
38
|
+
'refresh_registrations',
|
|
39
|
+
'reassert_dhcp_dns',
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
export interface DetectWithoutConvergeAuditDeps {
|
|
43
|
+
modules: { id: string; state: string; manifest: ModuleManifest }[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEPLOYED_STATES = new Set(['INSTALLED', 'VERIFIED']);
|
|
47
|
+
|
|
48
|
+
export function auditDetectWithoutConverge(deps: DetectWithoutConvergeAuditDeps): DriftFinding[] {
|
|
49
|
+
const findings: DriftFinding[] = [];
|
|
50
|
+
|
|
51
|
+
for (const module of deps.modules) {
|
|
52
|
+
if (!DEPLOYED_STATES.has(module.state)) continue;
|
|
53
|
+
|
|
54
|
+
const hooks = module.manifest.hooks ?? {};
|
|
55
|
+
const declared = CONVERGENCE_HOOKS.filter((name) => hooks[name] !== undefined);
|
|
56
|
+
if (declared.length === 0) continue;
|
|
57
|
+
|
|
58
|
+
const fired = new Set(
|
|
59
|
+
(module.manifest.subscriptions ?? []).map((s) => s.hook).filter((h): h is string => !!h),
|
|
60
|
+
);
|
|
61
|
+
const unfired = declared.filter((name) => !fired.has(name));
|
|
62
|
+
if (unfired.length === 0) continue;
|
|
63
|
+
|
|
64
|
+
findings.push({
|
|
65
|
+
category: 'detect_without_converge',
|
|
66
|
+
severity: 'drift',
|
|
67
|
+
code: 'converge_hook_never_fires',
|
|
68
|
+
message: `${module.id}: declares ${unfired.join(', ')} but no subscription ever fires ${unfired.length === 1 ? 'it' : 'them'} — drift is detected and never corrected`,
|
|
69
|
+
details:
|
|
70
|
+
'Add a `subscriptions:` entry on a `timer.tick.<n>` event naming the hook. `technitium`, `wireguard-manager`, `namecheap` and `celilo-mgmt` all do this; the machinery exists and this module is not wired into it.',
|
|
71
|
+
remediation: `Edit ${module.id}'s manifest.yml to add a subscriptions entry for ${unfired.join(', ')}, then 'celilo module update'.`,
|
|
72
|
+
// Prose, not a command: the fix is an edit to a module's manifest, and
|
|
73
|
+
// `actionable: true` on something no `celilo …` invocation performs would
|
|
74
|
+
// put a Remediate button on a modal that cannot do anything.
|
|
75
|
+
actionable: false,
|
|
76
|
+
subject: module.id,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return findings;
|
|
81
|
+
}
|
|
@@ -78,13 +78,16 @@ describe('auditDiskSpace', () => {
|
|
|
78
78
|
|
|
79
79
|
// Unmeasurable must not read as healthy — but it must not page either, since
|
|
80
80
|
// machines_reachable is already alerting for the same dead host.
|
|
81
|
-
test('an unmeasurable host is recorded as
|
|
81
|
+
test('an unmeasurable host is recorded as unmeasured, not as healthy', () => {
|
|
82
82
|
const findings = auditDiskSpace({
|
|
83
83
|
results: [usage({ hostname: 'iot', usedPercent: null, message: 'ssh: connect timed out' })],
|
|
84
84
|
});
|
|
85
85
|
|
|
86
86
|
expect(findings).toHaveLength(1);
|
|
87
|
-
|
|
87
|
+
// Was `todo`, because that was the only non-paging severity available.
|
|
88
|
+
// `unmeasured` is what it always meant, and it stops the verdict returning
|
|
89
|
+
// READY.
|
|
90
|
+
expect(findings[0]?.severity).toBe('unmeasured');
|
|
88
91
|
expect(findings[0]?.code).toBe('disk_unmeasured');
|
|
89
92
|
expect(findings[0]?.details).toContain('timed out');
|
|
90
93
|
});
|
|
@@ -66,12 +66,14 @@ export function auditDiskSpace(deps: DiskSpaceAuditDeps): DriftFinding[] {
|
|
|
66
66
|
for (const result of deps.results) {
|
|
67
67
|
// Unmeasurable is NOT healthy — but it does not page either. The host is
|
|
68
68
|
// already unreachable, `machines_reachable` is already alerting on it, and
|
|
69
|
-
// a second page for one dead host is noise. `todo`
|
|
70
|
-
//
|
|
69
|
+
// a second page for one dead host is noise. This used to be filed as `todo`
|
|
70
|
+
// because that was the only non-paging severity available; `unmeasured`
|
|
71
|
+
// (D7) is what it always meant, and unlike `todo` it stops the verdict
|
|
72
|
+
// returning READY.
|
|
71
73
|
if (result.usedPercent === null) {
|
|
72
74
|
findings.push({
|
|
73
75
|
category: 'disk_space',
|
|
74
|
-
severity: '
|
|
76
|
+
severity: 'unmeasured',
|
|
75
77
|
code: 'disk_unmeasured',
|
|
76
78
|
message: `${result.hostname}: disk usage could not be measured`,
|
|
77
79
|
details: result.message,
|
|
@@ -81,4 +81,43 @@ describe('auditHealth', () => {
|
|
|
81
81
|
expect(result).toHaveLength(2);
|
|
82
82
|
expect(result.map((f) => f.subject).sort()).toEqual(['a', 'b']);
|
|
83
83
|
});
|
|
84
|
+
|
|
85
|
+
test('firewall persistence drift reaches the audit as a drift finding', () => {
|
|
86
|
+
// The delivery path for celilo#670's fix: the iptables module reports drift
|
|
87
|
+
// as a `warn` health item, the runner turns any warn into `degraded`, and
|
|
88
|
+
// this turns `degraded` into an audit finding. No core code knows what an
|
|
89
|
+
// iptables rule is — which is the point (#941).
|
|
90
|
+
const unpersisted = '-A POSTROUTING -s 10.9.9.0/24 -o eth1 -j MASQUERADE';
|
|
91
|
+
const results: HealthCheckResult[] = [
|
|
92
|
+
{
|
|
93
|
+
moduleId: 'iptables',
|
|
94
|
+
status: 'degraded',
|
|
95
|
+
checks: [
|
|
96
|
+
{ name: 'ssh_access', status: 'pass', message: 'SSH connected to fw01' },
|
|
97
|
+
{
|
|
98
|
+
name: 'persistence_drift',
|
|
99
|
+
status: 'warn',
|
|
100
|
+
message: '1 live rule(s) absent from /etc/iptables/rules.v4 (lost on reboot)',
|
|
101
|
+
details: `live only: nat ${unpersisted}`,
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
return auditHealth({ results }).then((findings) => {
|
|
108
|
+
expect(findings).toHaveLength(1);
|
|
109
|
+
expect(findings[0]).toMatchObject({
|
|
110
|
+
category: 'health',
|
|
111
|
+
severity: 'drift',
|
|
112
|
+
subject: 'iptables',
|
|
113
|
+
actionable: true,
|
|
114
|
+
});
|
|
115
|
+
expect(findings[0].details).toContain('persistence_drift');
|
|
116
|
+
expect(findings[0].details).toContain('lost on reboot');
|
|
117
|
+
// The audit is a summary: it carries the COUNT, and points at the command
|
|
118
|
+
// that names the rules. `module health` prints check.details verbatim
|
|
119
|
+
// (cli/commands/module-health.ts:49), so every drifted rule is reachable.
|
|
120
|
+
expect(findings[0].remediation).toBe('celilo module health iptables --debug');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
84
123
|
});
|
|
@@ -11,7 +11,11 @@ const emptyDeps = {
|
|
|
11
11
|
fetcher: async () => '0.1.5',
|
|
12
12
|
},
|
|
13
13
|
schema: {
|
|
14
|
-
journal
|
|
14
|
+
// A READABLE journal with nothing pending. It used to be `() => null`,
|
|
15
|
+
// which now (correctly) reports `unmeasured` — an unreadable journal is not
|
|
16
|
+
// the same as a schema with no pending migrations, and this fixture means
|
|
17
|
+
// the latter.
|
|
18
|
+
journal: () => ({ version: '6', dialect: 'sqlite', entries: [] }),
|
|
15
19
|
applied: () => [],
|
|
16
20
|
db: fakeDb,
|
|
17
21
|
},
|
|
@@ -26,6 +30,8 @@ const emptyDeps = {
|
|
|
26
30
|
fetcher: async () => ({ latest: null }),
|
|
27
31
|
},
|
|
28
32
|
moduleConfigs: { modules: [] },
|
|
33
|
+
moduleIntegrity: { results: [] },
|
|
34
|
+
detectWithoutConverge: { modules: [] },
|
|
29
35
|
health: { results: [] },
|
|
30
36
|
backups: { modules: [] },
|
|
31
37
|
abandonedOperations: { records: [] },
|
|
@@ -18,9 +18,14 @@ import { type BackupsAuditDeps, auditBackups } from './backups';
|
|
|
18
18
|
import { type BrowserPinAuditDeps, auditBrowserPin } from './browser-pin';
|
|
19
19
|
import { type CapabilityAbiAuditDeps, auditCapabilityAbi } from './capability-abi';
|
|
20
20
|
import { type CliVersionAuditDeps, auditCliVersion } from './cli-version';
|
|
21
|
+
import {
|
|
22
|
+
type DetectWithoutConvergeAuditDeps,
|
|
23
|
+
auditDetectWithoutConverge,
|
|
24
|
+
} from './detect-without-converge';
|
|
21
25
|
import { type HealthAuditDeps, auditHealth } from './health';
|
|
22
26
|
import { type MachinesReachableAuditDeps, auditMachinesReachable } from './machines-reachable';
|
|
23
27
|
import { type ModuleConfigsAuditDeps, auditModuleConfigs } from './module-configs';
|
|
28
|
+
import { type ModuleIntegrityAuditDeps, auditModuleIntegrity } from './module-integrity';
|
|
24
29
|
import { type ModuleVersionsAuditDeps, auditModuleVersions } from './module-versions';
|
|
25
30
|
import { type PublicDnsAuditDeps, auditPublicDns } from './public-dns';
|
|
26
31
|
import { type SchemaAuditDeps, auditSchema } from './schema';
|
|
@@ -53,6 +58,8 @@ export interface AuditDeps {
|
|
|
53
58
|
terraformPlan: TerraformPlanAuditDeps;
|
|
54
59
|
moduleVersions: ModuleVersionsAuditDeps;
|
|
55
60
|
moduleConfigs: ModuleConfigsAuditDeps;
|
|
61
|
+
moduleIntegrity: ModuleIntegrityAuditDeps;
|
|
62
|
+
detectWithoutConverge: DetectWithoutConvergeAuditDeps;
|
|
56
63
|
health: HealthAuditDeps;
|
|
57
64
|
backups: BackupsAuditDeps;
|
|
58
65
|
abandonedOperations: AbandonedOperationsAuditDeps;
|
|
@@ -107,6 +114,11 @@ export async function runAudit(
|
|
|
107
114
|
wrap('terraform_plan', auditTerraformPlan(deps.terraformPlan)),
|
|
108
115
|
wrap('module_versions', auditModuleVersions(deps.moduleVersions)),
|
|
109
116
|
wrap('module_configs', auditModuleConfigs(deps.moduleConfigs)),
|
|
117
|
+
wrap('module_integrity', Promise.resolve(auditModuleIntegrity(deps.moduleIntegrity))),
|
|
118
|
+
wrap(
|
|
119
|
+
'detect_without_converge',
|
|
120
|
+
Promise.resolve(auditDetectWithoutConverge(deps.detectWithoutConverge)),
|
|
121
|
+
),
|
|
110
122
|
wrap('health', auditHealth(deps.health)),
|
|
111
123
|
wrap('backups', auditBackups(deps.backups)),
|
|
112
124
|
wrap(
|