@celilo/cli 1.4.0 → 1.6.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_SUBSYSTEMS.md +18 -4
- package/MODULE_PRIMITIVES.md +19 -6
- package/drizzle/0026_module_integrity_version.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/src/cli/commands/module-audit.ts +5 -2
- package/src/cli/commands/module-update.test.ts +90 -2
- package/src/cli/commands/module-update.ts +112 -6
- package/src/cli/commands/module-verify.ts +77 -13
- package/src/cli/commands/system-audit.ts +17 -0
- package/src/cli/commands/system-doctor.ts +78 -2
- package/src/cli/commands/system-update.ts +33 -3
- package/src/cli/index.ts +2 -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/schema.ts +5 -0
- package/src/manifest/json-schema-roundtrip.test.ts +12 -4
- package/src/manifest/schema.ts +23 -0
- package/src/module/import.ts +36 -35
- package/src/module/packaging/audit.ts +103 -28
- package/src/module/packaging/build.ts +41 -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-script-scan.test.ts +164 -0
- package/src/policy/module-script-scan.ts +143 -0
- package/src/policy/no-hand-built-ssh.test.ts +22 -62
- 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/module-deploy.ts +21 -0
- package/src/services/restore-from-file.ts +4 -0
- package/src/services/update/orchestrator.test.ts +2 -0
- package/src/templates/copy-role-files.test.ts +69 -0
- package/src/templates/generator.ts +23 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recurrence gate for openspec/changes/unified-management-no-ssh/proposal.md:
|
|
3
|
+
* **modules never hand-build SSH, and the one sanctioned raw-exec path is
|
|
4
|
+
* always justified in writing.**
|
|
5
|
+
*
|
|
6
|
+
* ONE definition of the rules, used by both enforcement points:
|
|
7
|
+
*
|
|
8
|
+
* - `apps/celilo/src/policy/no-hand-built-ssh.test.ts` — every in-repo module,
|
|
9
|
+
* on every `bun test`.
|
|
10
|
+
* - `apps/celilo/src/module/packaging/build.ts` — every `.netapp` at the
|
|
11
|
+
* moment it is packaged, including modules that never pass through this
|
|
12
|
+
* repo's CI (`bun run publish` is a documented escape hatch and runs no
|
|
13
|
+
* tests).
|
|
14
|
+
*
|
|
15
|
+
* Deliberately not two copies. The types this repo keeps re-learning that
|
|
16
|
+
* lesson on — `HookName` (celilo#821), `HookContext` — were duplicated
|
|
17
|
+
* declarations that drifted silently because nothing fails when two copies
|
|
18
|
+
* disagree. A scan rule is worse: the duplicate that drifts is the one that
|
|
19
|
+
* stops catching things, and a gate that stops catching things looks exactly
|
|
20
|
+
* like a gate with nothing to catch.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
24
|
+
import { join, relative } from 'node:path';
|
|
25
|
+
|
|
26
|
+
export interface ScanViolation {
|
|
27
|
+
/** Path as the operator should see it — relative to the scanned root. */
|
|
28
|
+
file: string;
|
|
29
|
+
/** 1-based line number of the offending line. */
|
|
30
|
+
line: number;
|
|
31
|
+
/** Short rule name, e.g. `raw ssh invocation`. */
|
|
32
|
+
rule: string;
|
|
33
|
+
/** What to do instead. */
|
|
34
|
+
hint: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How far above a `runAppCommand*` call the justification may sit.
|
|
39
|
+
*
|
|
40
|
+
* Calibrated against the real call sites rather than guessed: the convention is
|
|
41
|
+
* a comment block directly above the call, but a call wrapped in `waitFor(() =>
|
|
42
|
+
* …)` puts the justification above the ENCLOSING statement, a few lines up. A
|
|
43
|
+
* window covers both without needing to parse TypeScript. Eight lines is the
|
|
44
|
+
* widest real gap plus headroom; wide enough to miss nothing legitimate, narrow
|
|
45
|
+
* enough that an unrelated hatch elsewhere in the function cannot launder a
|
|
46
|
+
* fresh call.
|
|
47
|
+
*/
|
|
48
|
+
const ESCAPE_HATCH_LOOKBACK_LINES = 8;
|
|
49
|
+
|
|
50
|
+
const PATTERN_RULES: Array<{ rule: string; re: RegExp; hint: string }> = [
|
|
51
|
+
{
|
|
52
|
+
rule: 'raw ssh invocation (StrictHostKeyChecking)',
|
|
53
|
+
re: /StrictHostKeyChecking/,
|
|
54
|
+
hint: 'Use a remote-ops primitive (remoteExec/probe/serviceCtl/…). See MODULE_PRIMITIVES.md.',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
rule: 'raw ssh invocation (ssh … root@)',
|
|
58
|
+
re: /\bssh\s+(?:-\S+\s+|\S*root@)/,
|
|
59
|
+
hint: 'Use a remote-ops primitive, not a hand-built ssh string. See MODULE_PRIMITIVES.md.',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
rule: "'ssh2' import",
|
|
63
|
+
re: /(?:from|require\()\s*['"]ssh2['"]/,
|
|
64
|
+
hint: 'Modules never open their own SSH connection — use the primitives. See MODULE_PRIMITIVES.md.',
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/** A `runAppCommand(` / `runAppCommandWithSecret(` CALL — not the import. */
|
|
69
|
+
const RAW_EXEC_CALL = /\brunAppCommand(?:WithSecret)?\s*\(/;
|
|
70
|
+
|
|
71
|
+
const ESCAPE_HATCH_MARKER = /escape-hatch:/;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Scan one file's source. Pure — takes the text, returns the violations, so it
|
|
75
|
+
* is testable without a filesystem and reusable over a staged package.
|
|
76
|
+
*/
|
|
77
|
+
export function scanModuleScriptSource(file: string, source: string): ScanViolation[] {
|
|
78
|
+
const violations: ScanViolation[] = [];
|
|
79
|
+
const lines = source.split('\n');
|
|
80
|
+
|
|
81
|
+
lines.forEach((text, i) => {
|
|
82
|
+
for (const { rule, re, hint } of PATTERN_RULES) {
|
|
83
|
+
if (re.test(text)) violations.push({ file, line: i + 1, rule, hint });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!RAW_EXEC_CALL.test(text)) return;
|
|
87
|
+
const from = Math.max(0, i - ESCAPE_HATCH_LOOKBACK_LINES);
|
|
88
|
+
const justified = lines.slice(from, i).some((l) => ESCAPE_HATCH_MARKER.test(l));
|
|
89
|
+
if (!justified) {
|
|
90
|
+
violations.push({
|
|
91
|
+
file,
|
|
92
|
+
line: i + 1,
|
|
93
|
+
rule: 'unjustified raw-exec escape hatch',
|
|
94
|
+
hint:
|
|
95
|
+
'runAppCommand* is the ONLY sanctioned raw-exec path and every call site must say why ' +
|
|
96
|
+
'no capability, HTTP or converge path exists. Add an `// escape-hatch: …` comment ' +
|
|
97
|
+
'immediately above the call. See MODULE_PRIMITIVES.md.',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return violations;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Every production `.ts` under a module's `scripts/` — excluding tests and
|
|
107
|
+
* `node_modules`.
|
|
108
|
+
*
|
|
109
|
+
* The exclusion is load-bearing, not tidiness: the shipped closure bundles
|
|
110
|
+
* `@celilo/capabilities`, whose `remote.ts` builds the `ssh … root@` string
|
|
111
|
+
* that every one of these rules exists to keep OUT of module code. Scanning it
|
|
112
|
+
* would fail every module in the fleet on the implementation of the primitives
|
|
113
|
+
* they were told to use.
|
|
114
|
+
*/
|
|
115
|
+
export function moduleScriptFiles(scriptsDir: string): string[] {
|
|
116
|
+
if (!existsSync(scriptsDir) || !statSync(scriptsDir).isDirectory()) return [];
|
|
117
|
+
const out: string[] = [];
|
|
118
|
+
const walk = (dir: string) => {
|
|
119
|
+
for (const entry of readdirSync(dir)) {
|
|
120
|
+
if (entry === 'node_modules') continue;
|
|
121
|
+
const p = join(dir, entry);
|
|
122
|
+
if (statSync(p).isDirectory()) walk(p);
|
|
123
|
+
else if (p.endsWith('.ts') && !p.endsWith('.test.ts')) out.push(p);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
walk(scriptsDir);
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Scan a module directory (the one holding `manifest.yml`). Returns every
|
|
132
|
+
* violation in its `scripts/`, with paths relative to `moduleDir`.
|
|
133
|
+
*/
|
|
134
|
+
export function scanModuleDirectory(moduleDir: string): ScanViolation[] {
|
|
135
|
+
return moduleScriptFiles(join(moduleDir, 'scripts')).flatMap((f) =>
|
|
136
|
+
scanModuleScriptSource(relative(moduleDir, f), readFileSync(f, 'utf-8')),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Render violations for a test failure message or a refused publish. */
|
|
141
|
+
export function formatViolations(violations: ScanViolation[]): string {
|
|
142
|
+
return violations.map((v) => ` ${v.file}:${v.line}\n → ${v.rule}. ${v.hint}`).join('\n');
|
|
143
|
+
}
|
|
@@ -1,24 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Recurrence gate for openspec/changes/unified-management-no-ssh/proposal.md: **modules never hand-build SSH
|
|
2
|
+
* Recurrence gate for openspec/changes/unified-management-no-ssh/proposal.md: **modules never hand-build SSH,
|
|
3
|
+
* and every raw-exec escape hatch is justified in writing.**
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* script and fails if a banned pattern reappears, so the SSH-elimination work
|
|
9
|
-
* can't silently erode.
|
|
5
|
+
* The rules themselves live in `./module-script-scan`, because this is not the
|
|
6
|
+
* only place they run — `.netapp` packaging applies the same scan at publish
|
|
7
|
+
* time, which is the enforcement point that also covers a module built outside
|
|
8
|
+
* this repo's CI. One definition, two callers.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* hand-built SSH however it's invoked (child_process, execSync, or the Runner),
|
|
14
|
-
* and `ssh2` catches the library route. We do NOT ban `child_process` outright:
|
|
15
|
-
* many modules' on_install legitimately shell LOCAL `celilo`/system commands
|
|
16
|
-
* (`celilo system apply-config`), which isn't remote SSH.
|
|
10
|
+
* This file is the in-repo half: every production module script, on every
|
|
11
|
+
* `bun test`.
|
|
17
12
|
*/
|
|
18
13
|
|
|
19
14
|
import { describe, expect, test } from 'bun:test';
|
|
20
|
-
import { existsSync,
|
|
15
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
21
16
|
import { join, resolve } from 'node:path';
|
|
17
|
+
import { formatViolations, moduleScriptFiles, scanModuleDirectory } from './module-script-scan';
|
|
22
18
|
|
|
23
19
|
/** Walk up from this test to the repo root (the dir holding both modules/ and apps/). */
|
|
24
20
|
function repoRoot(): string {
|
|
@@ -30,61 +26,25 @@ function repoRoot(): string {
|
|
|
30
26
|
throw new Error('could not locate repo root (no ancestor with modules/ + apps/)');
|
|
31
27
|
}
|
|
32
28
|
|
|
33
|
-
|
|
34
|
-
function moduleScripts(): string[] {
|
|
29
|
+
function moduleDirs(): string[] {
|
|
35
30
|
const modulesRoot = join(repoRoot(), 'modules');
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (entry === 'node_modules') continue;
|
|
40
|
-
const p = join(dir, entry);
|
|
41
|
-
if (statSync(p).isDirectory()) walk(p);
|
|
42
|
-
else if (p.endsWith('.ts') && !p.endsWith('.test.ts')) out.push(p);
|
|
43
|
-
}
|
|
44
|
-
};
|
|
45
|
-
for (const mod of readdirSync(modulesRoot)) {
|
|
46
|
-
const scripts = join(modulesRoot, mod, 'scripts');
|
|
47
|
-
if (existsSync(scripts) && statSync(scripts).isDirectory()) walk(scripts);
|
|
48
|
-
}
|
|
49
|
-
return out;
|
|
31
|
+
return readdirSync(modulesRoot)
|
|
32
|
+
.map((m) => join(modulesRoot, m))
|
|
33
|
+
.filter((d) => statSync(d).isDirectory() && existsSync(join(d, 'scripts')));
|
|
50
34
|
}
|
|
51
35
|
|
|
52
|
-
const BANNED = [
|
|
53
|
-
{
|
|
54
|
-
name: 'raw ssh invocation (StrictHostKeyChecking)',
|
|
55
|
-
re: /StrictHostKeyChecking/,
|
|
56
|
-
hint: 'Use a remote-ops primitive (remoteExec/probe/serviceCtl/…). See MODULE_PRIMITIVES.md.',
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
name: 'raw ssh invocation (ssh … root@)',
|
|
60
|
-
re: /\bssh\s+(?:-\S+\s+|\S*root@)/,
|
|
61
|
-
hint: 'Use a remote-ops primitive, not a hand-built ssh string. See MODULE_PRIMITIVES.md.',
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
name: "'ssh2' import",
|
|
65
|
-
re: /(?:from|require\()\s*['"]ssh2['"]/,
|
|
66
|
-
hint: 'Modules never open their own SSH connection — use the primitives. See MODULE_PRIMITIVES.md.',
|
|
67
|
-
},
|
|
68
|
-
];
|
|
69
|
-
|
|
70
36
|
describe('recurrence gate: modules never hand-build SSH', () => {
|
|
71
|
-
const
|
|
37
|
+
const dirs = moduleDirs();
|
|
72
38
|
|
|
73
39
|
test('scans a non-trivial set of module scripts (sanity — the scan actually ran)', () => {
|
|
74
|
-
|
|
40
|
+
const scanned = dirs.flatMap((d) => moduleScriptFiles(join(d, 'scripts')));
|
|
41
|
+
expect(scanned.length).toBeGreaterThan(10);
|
|
75
42
|
});
|
|
76
43
|
|
|
77
|
-
test('no production module script hand-builds SSH
|
|
78
|
-
const violations
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (b.re.test(src)) violations.push(`${file}\n → ${b.name}. ${b.hint}`);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
expect(
|
|
86
|
-
violations,
|
|
87
|
-
`Hand-built SSH found in module scripts:\n ${violations.join('\n ')}`,
|
|
88
|
-
).toEqual([]);
|
|
44
|
+
test('no production module script hand-builds SSH, and every runAppCommand* is justified', () => {
|
|
45
|
+
const violations = dirs.flatMap((d) => scanModuleDirectory(d));
|
|
46
|
+
expect(violations, `Module script policy violations:\n${formatViolations(violations)}`).toEqual(
|
|
47
|
+
[],
|
|
48
|
+
);
|
|
89
49
|
});
|
|
90
50
|
});
|
|
@@ -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(
|