@celilo/cli 2.2.1 → 2.3.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 +1 -0
- package/CELILO_SUBSYSTEMS.md +1 -1
- package/README.md +1 -1
- package/drizzle/0032_module_jail_policies.sql +28 -0
- package/drizzle/0033_build_bus_hook_runs.sql +37 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/__integration__/container-services-cli.integration.test.ts +1 -1
- package/src/api/sessions.test.ts +3 -3
- package/src/api-clients/proxmox.ts +10 -7
- package/src/cli/backup-rename.test.ts +3 -3
- package/src/cli/cli.test.ts +1 -1
- package/src/cli/commands/events.test.ts +2 -2
- package/src/cli/commands/firewall-interface-list.test.ts +2 -2
- package/src/cli/commands/machine-list.test.ts +57 -0
- package/src/cli/commands/machine-list.ts +35 -4
- package/src/cli/commands/module-health.ts +1 -0
- package/src/cli/commands/module-import-registry.test.ts +1 -1
- package/src/cli/commands/module-jail.test.ts +242 -0
- package/src/cli/commands/module-jail.ts +227 -0
- package/src/cli/commands/module-list-jail.test.ts +136 -0
- package/src/cli/commands/module-list.ts +30 -3
- package/src/cli/commands/module-publish.test.ts +6 -6
- package/src/cli/commands/module-update.test.ts +22 -7
- package/src/cli/commands/module-update.ts +4 -1
- package/src/cli/commands/module-upgrade.test.ts +115 -1
- package/src/cli/commands/module-upgrade.ts +23 -1
- package/src/cli/commands/module-verify.test.ts +1 -1
- package/src/cli/commands/publish/execute.ts +4 -1
- package/src/cli/commands/publish/index.ts +11 -1
- package/src/cli/commands/publish/module-registry.test.ts +24 -1
- package/src/cli/commands/publish/module-registry.ts +23 -4
- package/src/cli/commands/publish/plan.ts +1 -1
- package/src/cli/commands/publish/types.ts +7 -0
- package/src/cli/commands/registry-owner.test.ts +1 -1
- package/src/cli/commands/registry-token.test.ts +1 -1
- package/src/cli/commands/system-audit.ts +20 -6
- package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
- package/src/cli/commands/system-doctor.test.ts +76 -10
- package/src/cli/commands/system-doctor.ts +34 -3
- package/src/cli/commands/system-init-deprecation.test.ts +1 -1
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +8 -2
- package/src/cli/flag-surface-gate.test.ts +279 -0
- package/src/cli/index.ts +68 -2
- package/src/cli/parser.test.ts +37 -1
- package/src/cli/restore-command.test.ts +3 -3
- package/src/cli/restore-migration-failure.test.ts +2 -2
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/config/paths.test.ts +19 -19
- package/src/db/client.test.ts +46 -1
- package/src/db/client.ts +26 -0
- package/src/db/schema.ts +63 -0
- package/src/hooks/capability-loader-firewall.test.ts +9 -1
- package/src/hooks/capability-loader.ts +8 -0
- package/src/hooks/executor.test.ts +86 -12
- package/src/hooks/executor.ts +31 -1
- package/src/hooks/hook-jail-unreachability.test.ts +17 -18
- package/src/hooks/hook-trespass.test.ts +9 -2
- package/src/hooks/jail.test.ts +105 -17
- package/src/hooks/jail.ts +47 -10
- package/src/manifest/contracts/v1.ts +13 -0
- package/src/policy/module-script-scan.ts +52 -68
- package/src/policy/no-hand-built-ssh.test.ts +5 -1
- package/src/policy/no-swallowed-refusal.test.ts +14 -14
- package/src/registry/client.test.ts +2 -2
- package/src/secrets/storage.test.ts +1 -1
- package/src/services/alerting/keys.test.ts +4 -0
- package/src/services/alerting/keys.ts +12 -2
- package/src/services/audit/health.test.ts +97 -2
- package/src/services/audit/health.ts +64 -2
- package/src/services/audit/index.test.ts +22 -0
- package/src/services/audit/index.ts +8 -2
- package/src/services/audit/jail-exemptions.test.ts +42 -0
- package/src/services/audit/jail-exemptions.ts +44 -0
- package/src/services/audit/module-integrity.test.ts +23 -1
- package/src/services/audit/module-integrity.ts +7 -2
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-create.ts +9 -0
- package/src/services/backup-envelope-roundtrip.test.ts +1 -1
- package/src/services/backup-in-flight-refusal.test.ts +1 -1
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +48 -0
- package/src/services/build-bus/hook-dispatcher.ts +49 -1
- package/src/services/bus-ensure-flow.test.ts +1 -1
- package/src/services/bus-interview-park.test.ts +2 -2
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/celilo-events.test.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +23 -5
- package/src/services/container-service.test.ts +1 -1
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-preflight.ts +7 -0
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/fleet-checks.test.ts +123 -4
- package/src/services/fleet-checks.ts +176 -3
- package/src/services/health-runner.test.ts +87 -2
- package/src/services/health-runner.ts +40 -11
- package/src/services/infrastructure-selector.test.ts +1 -1
- package/src/services/jail-exemptions.test.ts +125 -0
- package/src/services/jail-exemptions.ts +81 -0
- package/src/services/machine-pool.test.ts +1 -1
- package/src/services/module-deploy.ts +12 -4
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/network-discovery.test.ts +64 -1
- package/src/services/network-discovery.ts +30 -5
- package/src/services/responder-probe.test.ts +1 -1
- package/src/services/restore-from-file.test.ts +4 -4
- package/src/services/restore-preflight.test.ts +1 -1
- package/src/services/ssh-key-manager.test.ts +2 -2
- package/src/services/system-state-stage.test.ts +2 -2
- package/src/services/update/orchestrator.test.ts +2 -0
- package/src/test-utils/bus-responder.ts +1 -1
|
@@ -1,14 +1,109 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import type { HealthCheckResult } from '../health-runner';
|
|
3
3
|
import { auditHealth } from './health';
|
|
4
|
+
import { computeVerdict } from './types';
|
|
4
5
|
|
|
5
6
|
describe('auditHealth', () => {
|
|
6
|
-
test('
|
|
7
|
+
test('a healthy module produces no finding; a module with no hook is unmeasured, never green', async () => {
|
|
7
8
|
const results: HealthCheckResult[] = [
|
|
8
9
|
{ moduleId: 'caddy', status: 'healthy', checks: [] },
|
|
9
10
|
{ moduleId: 'iptables', status: 'no-checks', checks: [] },
|
|
10
11
|
];
|
|
11
|
-
|
|
12
|
+
const result = await auditHealth({ results });
|
|
13
|
+
expect(result).toHaveLength(1);
|
|
14
|
+
expect(result[0]).toMatchObject({
|
|
15
|
+
category: 'health',
|
|
16
|
+
severity: 'unmeasured',
|
|
17
|
+
code: 'health_unmeasured',
|
|
18
|
+
subject: 'iptables',
|
|
19
|
+
actionable: false,
|
|
20
|
+
});
|
|
21
|
+
expect(result[0].message).toContain('no health_check hook');
|
|
22
|
+
expect(computeVerdict(result)).toBe('UNKNOWN');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('a module whose checks all report skip is unmeasured and names the skipped checks', async () => {
|
|
26
|
+
// The smoketest_bot_token incident, as an audit finding: the hook ran,
|
|
27
|
+
// every meaningful check refused to guess, and the fleet must not read
|
|
28
|
+
// READY off that.
|
|
29
|
+
const results: HealthCheckResult[] = [
|
|
30
|
+
{
|
|
31
|
+
moduleId: 'smoketest',
|
|
32
|
+
status: 'no-checks',
|
|
33
|
+
checks: [
|
|
34
|
+
{
|
|
35
|
+
name: 'bot_token_valid',
|
|
36
|
+
status: 'skip',
|
|
37
|
+
message: 'smoketest_bot_token not yet provisioned',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'webhook_reachable',
|
|
41
|
+
status: 'skip',
|
|
42
|
+
message: 'smoketest_bot_token not yet provisioned',
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
const result = await auditHealth({ results });
|
|
48
|
+
expect(result).toHaveLength(1);
|
|
49
|
+
expect(result[0]).toMatchObject({
|
|
50
|
+
severity: 'unmeasured',
|
|
51
|
+
code: 'health_unmeasured',
|
|
52
|
+
subject: 'smoketest',
|
|
53
|
+
actionable: true,
|
|
54
|
+
remediation: 'celilo module health smoketest --debug',
|
|
55
|
+
});
|
|
56
|
+
expect(result[0].message).toContain('all 2 health checks skipped');
|
|
57
|
+
expect(result[0].details).toContain('bot_token_valid');
|
|
58
|
+
expect(result[0].details).toContain('not yet provisioned');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('skipped checks on an otherwise healthy module render unmeasured', async () => {
|
|
62
|
+
const results: HealthCheckResult[] = [
|
|
63
|
+
{
|
|
64
|
+
moduleId: 'caddy',
|
|
65
|
+
status: 'healthy',
|
|
66
|
+
checks: [
|
|
67
|
+
{ name: 'config_valid', status: 'pass', message: 'config parses' },
|
|
68
|
+
{
|
|
69
|
+
name: 'origin_reachable',
|
|
70
|
+
status: 'skip',
|
|
71
|
+
message: 'peer unreachable from control plane',
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
const result = await auditHealth({ results });
|
|
77
|
+
expect(result).toHaveLength(1);
|
|
78
|
+
expect(result[0]).toMatchObject({
|
|
79
|
+
severity: 'unmeasured',
|
|
80
|
+
code: 'health_checks_skipped',
|
|
81
|
+
subject: 'caddy',
|
|
82
|
+
});
|
|
83
|
+
expect(result[0].message).toContain('1 of 2');
|
|
84
|
+
expect(result[0].details).toContain('origin_reachable');
|
|
85
|
+
expect(computeVerdict(result)).toBe('UNKNOWN');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a degraded module with a skipped check yields both a drift and an unmeasured finding', async () => {
|
|
89
|
+
const results: HealthCheckResult[] = [
|
|
90
|
+
{
|
|
91
|
+
moduleId: 'iptables',
|
|
92
|
+
status: 'degraded',
|
|
93
|
+
checks: [
|
|
94
|
+
{
|
|
95
|
+
name: 'persistence_drift',
|
|
96
|
+
status: 'warn',
|
|
97
|
+
message: '1 live rule(s) absent from rules.v4',
|
|
98
|
+
},
|
|
99
|
+
{ name: 'ssh_access', status: 'skip', message: 'fw01 unreachable' },
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
const result = await auditHealth({ results });
|
|
104
|
+
expect(result).toHaveLength(2);
|
|
105
|
+
expect(result.map((f) => f.severity).sort()).toEqual(['drift', 'unmeasured']);
|
|
106
|
+
expect(computeVerdict(result)).toBe('UNKNOWN');
|
|
12
107
|
});
|
|
13
108
|
|
|
14
109
|
test('drift finding for unhealthy module', async () => {
|
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
* informational (DRIFT, not BLOCKED) — the user might want to update
|
|
6
6
|
* even if some module is currently unhealthy (an update could fix it).
|
|
7
7
|
*
|
|
8
|
+
* Unmeasured is reported, never folded into green: a module with no
|
|
9
|
+
* health_check hook, and a module whose checks all reported skip, have
|
|
10
|
+
* contributed no measurement at all, and both render the fleet UNKNOWN
|
|
11
|
+
* rather than READY (celilo#1326). The same rule reaches one level down —
|
|
12
|
+
* a module that measured something but skipped a check gets an
|
|
13
|
+
* `unmeasured` finding beside its verdict, so a VERIFIED module whose
|
|
14
|
+
* meaningful probe was skipped is still visible.
|
|
15
|
+
*
|
|
8
16
|
* The health-runner result is passed in directly so this file stays
|
|
9
17
|
* pure for testing. The aggregator wires `runAllHealthChecks` to it.
|
|
10
18
|
*/
|
|
@@ -16,13 +24,67 @@ export interface HealthAuditDeps {
|
|
|
16
24
|
results: HealthCheckResult[];
|
|
17
25
|
}
|
|
18
26
|
|
|
27
|
+
function skippedChecksFinding(r: HealthCheckResult, code: string, message: string): DriftFinding {
|
|
28
|
+
const details = r.checks
|
|
29
|
+
.filter((c) => c.status === 'skip')
|
|
30
|
+
.map((c) => ` • ${c.name}: ${c.message}`)
|
|
31
|
+
.join('\n');
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
category: 'health',
|
|
35
|
+
severity: 'unmeasured',
|
|
36
|
+
code,
|
|
37
|
+
message,
|
|
38
|
+
details: details || undefined,
|
|
39
|
+
remediation: `celilo module health ${r.moduleId} --debug`,
|
|
40
|
+
actionable: true,
|
|
41
|
+
subject: r.moduleId,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
19
45
|
export async function auditHealth(deps: HealthAuditDeps): Promise<DriftFinding[]> {
|
|
20
46
|
const findings: DriftFinding[] = [];
|
|
21
47
|
|
|
22
48
|
for (const r of deps.results) {
|
|
23
|
-
|
|
49
|
+
const skipped = r.checks.filter((c) => c.status === 'skip');
|
|
50
|
+
const measured = r.checks.filter((c) => c.status !== 'skip');
|
|
51
|
+
|
|
52
|
+
if (r.status === 'no-checks') {
|
|
53
|
+
// Either the module declares no health_check hook, or the hook ran
|
|
54
|
+
// and every check reported skip. Neither measured anything.
|
|
55
|
+
findings.push(
|
|
56
|
+
skipped.length > 0
|
|
57
|
+
? skippedChecksFinding(
|
|
58
|
+
r,
|
|
59
|
+
'health_unmeasured',
|
|
60
|
+
`${r.moduleId}: all ${skipped.length} health checks skipped — nothing measured`,
|
|
61
|
+
)
|
|
62
|
+
: {
|
|
63
|
+
category: 'health',
|
|
64
|
+
severity: 'unmeasured',
|
|
65
|
+
code: 'health_unmeasured',
|
|
66
|
+
message: `${r.moduleId}: no health_check hook — never measured`,
|
|
67
|
+
remediation: `Add a health_check hook to the ${r.moduleId} module manifest`,
|
|
68
|
+
actionable: false,
|
|
69
|
+
subject: r.moduleId,
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (skipped.length > 0) {
|
|
76
|
+
findings.push(
|
|
77
|
+
skippedChecksFinding(
|
|
78
|
+
r,
|
|
79
|
+
'health_checks_skipped',
|
|
80
|
+
`${r.moduleId}: ${skipped.length} of ${r.checks.length} health checks skipped`,
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (r.status === 'healthy') continue;
|
|
24
86
|
|
|
25
|
-
const failingChecks =
|
|
87
|
+
const failingChecks = measured
|
|
26
88
|
.filter((c) => c.status === 'fail' || c.status === 'warn')
|
|
27
89
|
.map((c) => ` • ${c.name}: ${c.message}`)
|
|
28
90
|
.join('\n');
|
|
@@ -32,6 +32,7 @@ const emptyDeps = {
|
|
|
32
32
|
moduleConfigs: { modules: [] },
|
|
33
33
|
moduleIntegrity: { results: [] },
|
|
34
34
|
detectWithoutConverge: { modules: [] },
|
|
35
|
+
jailExemptions: { exemptions: [] },
|
|
35
36
|
health: { results: [] },
|
|
36
37
|
backups: { modules: [] },
|
|
37
38
|
abandonedOperations: { records: [] },
|
|
@@ -41,6 +42,7 @@ const emptyDeps = {
|
|
|
41
42
|
secretsDecryptable: { results: [] },
|
|
42
43
|
servicesReachable: { results: [] },
|
|
43
44
|
machinesReachable: { results: [] },
|
|
45
|
+
diskSpace: { results: [] },
|
|
44
46
|
transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
|
|
45
47
|
trustedSources: { firewalls: [] },
|
|
46
48
|
publicDns: { records: [], probe: unusedPublicDnsProbe },
|
|
@@ -88,6 +90,26 @@ describe('runAudit', () => {
|
|
|
88
90
|
expect(report.findings.some((f) => f.category === 'schema')).toBe(true);
|
|
89
91
|
});
|
|
90
92
|
|
|
93
|
+
test('disk_space is wired into the report (celilo#1336)', async () => {
|
|
94
|
+
const events: string[] = [];
|
|
95
|
+
const report = await runAudit(
|
|
96
|
+
{
|
|
97
|
+
...emptyDeps,
|
|
98
|
+
diskSpace: {
|
|
99
|
+
results: [{ hostname: 'celilo-mgr', ipAddress: '10.0.0.5', usedPercent: 96 }],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
(event) => events.push(`${event.category}:${event.phase}`),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// 96% is at the blocked threshold, so the wired category alone flips
|
|
106
|
+
// the fleet verdict — which is the point of wiring it.
|
|
107
|
+
expect(report.verdict).toBe('BLOCKED');
|
|
108
|
+
expect(report.findings.map((f) => f.category)).toContain('disk_space');
|
|
109
|
+
expect(events).toContain('disk_space:start');
|
|
110
|
+
expect(events).toContain('disk_space:end');
|
|
111
|
+
});
|
|
112
|
+
|
|
91
113
|
test('aggregates findings across all categories', async () => {
|
|
92
114
|
const report = await runAudit({
|
|
93
115
|
...emptyDeps,
|
|
@@ -22,7 +22,9 @@ import {
|
|
|
22
22
|
type DetectWithoutConvergeAuditDeps,
|
|
23
23
|
auditDetectWithoutConverge,
|
|
24
24
|
} from './detect-without-converge';
|
|
25
|
+
import { type DiskSpaceAuditDeps, auditDiskSpace } from './disk-space';
|
|
25
26
|
import { type HealthAuditDeps, auditHealth } from './health';
|
|
27
|
+
import { type JailExemptionsAuditDeps, auditJailExemptions } from './jail-exemptions';
|
|
26
28
|
import { type MachinesReachableAuditDeps, auditMachinesReachable } from './machines-reachable';
|
|
27
29
|
import { type ModuleConfigsAuditDeps, auditModuleConfigs } from './module-configs';
|
|
28
30
|
import { type ModuleIntegrityAuditDeps, auditModuleIntegrity } from './module-integrity';
|
|
@@ -60,6 +62,7 @@ export interface AuditDeps {
|
|
|
60
62
|
moduleConfigs: ModuleConfigsAuditDeps;
|
|
61
63
|
moduleIntegrity: ModuleIntegrityAuditDeps;
|
|
62
64
|
detectWithoutConverge: DetectWithoutConvergeAuditDeps;
|
|
65
|
+
jailExemptions: JailExemptionsAuditDeps;
|
|
63
66
|
health: HealthAuditDeps;
|
|
64
67
|
backups: BackupsAuditDeps;
|
|
65
68
|
abandonedOperations: AbandonedOperationsAuditDeps;
|
|
@@ -70,6 +73,7 @@ export interface AuditDeps {
|
|
|
70
73
|
servicesReachable: ServicesReachableAuditDeps;
|
|
71
74
|
machinesReachable: MachinesReachableAuditDeps;
|
|
72
75
|
publicDns: PublicDnsAuditDeps;
|
|
76
|
+
diskSpace: DiskSpaceAuditDeps;
|
|
73
77
|
transportReads: TransportReadsAuditDeps;
|
|
74
78
|
trustedSources: TrustedSourcesAuditDeps;
|
|
75
79
|
/** Defaults to `Date.now()`-based ISO string. */
|
|
@@ -95,8 +99,8 @@ export async function runAudit(
|
|
|
95
99
|
): Promise<SystemAuditReport> {
|
|
96
100
|
// Wrap each category's promise so the caller sees per-category
|
|
97
101
|
// start/end events. `start` fires synchronously when this function
|
|
98
|
-
// schedules the category; in practice all
|
|
99
|
-
// since Promise.all spawns them in parallel.
|
|
102
|
+
// schedules the category; in practice all of them fire roughly at
|
|
103
|
+
// once since Promise.all spawns them in parallel.
|
|
100
104
|
const wrap = (category: DriftCategory, p: Promise<DriftFinding[]>): Promise<DriftFinding[]> => {
|
|
101
105
|
onProgress?.({ category, phase: 'start' });
|
|
102
106
|
return p.then((findings) => {
|
|
@@ -119,6 +123,7 @@ export async function runAudit(
|
|
|
119
123
|
'detect_without_converge',
|
|
120
124
|
Promise.resolve(auditDetectWithoutConverge(deps.detectWithoutConverge)),
|
|
121
125
|
),
|
|
126
|
+
wrap('jail_exemptions', Promise.resolve(auditJailExemptions(deps.jailExemptions))),
|
|
122
127
|
wrap('health', auditHealth(deps.health)),
|
|
123
128
|
wrap('backups', auditBackups(deps.backups)),
|
|
124
129
|
wrap(
|
|
@@ -132,6 +137,7 @@ export async function runAudit(
|
|
|
132
137
|
wrap('services_reachable', auditServicesReachable(deps.servicesReachable)),
|
|
133
138
|
wrap('machines_reachable', auditMachinesReachable(deps.machinesReachable)),
|
|
134
139
|
wrap('public_dns', auditPublicDns(deps.publicDns)),
|
|
140
|
+
wrap('disk_space', Promise.resolve(auditDiskSpace(deps.diskSpace))),
|
|
135
141
|
wrap('transport_reads', auditTransportReads(deps.transportReads)),
|
|
136
142
|
wrap('trusted_sources', auditTrustedSources(deps.trustedSources)),
|
|
137
143
|
]);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `jail_exemptions` audit category (per-module-jail-policy
|
|
3
|
+
* task 3.3/3.4).
|
|
4
|
+
*
|
|
5
|
+
* The severity contract is the point: an operator-recorded exemption is
|
|
6
|
+
* `todo`, never `drift`, so `system update` never treats it as work. And the
|
|
7
|
+
* negative half: a fleet with no exemptions contributes no findings.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import { auditJailExemptions } from './jail-exemptions';
|
|
12
|
+
|
|
13
|
+
const EXEMPT = { moduleId: 'caddy', policy: 'off' as const, systemPolicy: 'auto' as const };
|
|
14
|
+
|
|
15
|
+
describe('auditJailExemptions (per-module-jail-policy task 3.3)', () => {
|
|
16
|
+
test('no exemptions produce no findings (the negative half of task 3.4)', async () => {
|
|
17
|
+
expect(await auditJailExemptions({ exemptions: [] })).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('each exemption is one finding, severity todo, never drift', async () => {
|
|
21
|
+
const findings = await auditJailExemptions({
|
|
22
|
+
exemptions: [EXEMPT, { moduleId: 'vault', policy: 'auto', systemPolicy: 'required' }],
|
|
23
|
+
});
|
|
24
|
+
expect(findings).toHaveLength(2);
|
|
25
|
+
for (const f of findings) {
|
|
26
|
+
expect(f.category).toBe('jail_exemptions');
|
|
27
|
+
expect(f.severity).toBe('todo');
|
|
28
|
+
expect(f.severity).not.toBe('drift');
|
|
29
|
+
expect(f.code).toBe('hook_jail_exemption');
|
|
30
|
+
expect(f.actionable).toBe(false);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('the finding names the module and both policies', async () => {
|
|
35
|
+
const [finding] = await auditJailExemptions({ exemptions: [EXEMPT] });
|
|
36
|
+
expect(finding).toBeDefined();
|
|
37
|
+
expect(finding?.subject).toBe('caddy');
|
|
38
|
+
expect(finding?.message).toContain('caddy');
|
|
39
|
+
expect(finding?.message).toContain("'off'");
|
|
40
|
+
expect(finding?.message).toContain("'auto'");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-module hook jail exemption check (per-module-jail-policy task 3.3).
|
|
3
|
+
*
|
|
4
|
+
* One finding per module the operator exempted from the fleet's jail policy.
|
|
5
|
+
* Severity `todo`, deliberately NOT `drift`: the operator chose this, so it
|
|
6
|
+
* is a list of recorded exceptions to revisit, not a system that moved away
|
|
7
|
+
* from a desired state. `todo` never escalates the verdict past READY, which
|
|
8
|
+
* is what keeps `system update` from treating an exemption as work to do.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { JailExemption } from '../jail-exemptions';
|
|
12
|
+
import type { DriftFinding } from './types';
|
|
13
|
+
|
|
14
|
+
export interface JailExemptionsAuditDeps {
|
|
15
|
+
/**
|
|
16
|
+
* The exemptions, from the shared collector (`services/jail-exemptions.ts`).
|
|
17
|
+
* A fleet with none contributes nothing — that is the normal state, and
|
|
18
|
+
* emitting a "no exemptions" finding would be a surface reporting an
|
|
19
|
+
* exemption nobody set (task 3.4).
|
|
20
|
+
*/
|
|
21
|
+
exemptions: JailExemption[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function auditJailExemptions(deps: JailExemptionsAuditDeps): Promise<DriftFinding[]> {
|
|
25
|
+
return deps.exemptions.map((exemption) => ({
|
|
26
|
+
category: 'jail_exemptions' as const,
|
|
27
|
+
severity: 'todo' as const,
|
|
28
|
+
code: 'hook_jail_exemption',
|
|
29
|
+
subject: exemption.moduleId,
|
|
30
|
+
message: `${exemption.moduleId} runs under jail policy '${exemption.policy}', weaker than the system's '${exemption.systemPolicy}'`,
|
|
31
|
+
details: [
|
|
32
|
+
'Its hooks run with the weaker posture while the rest of the fleet jails.',
|
|
33
|
+
'The operator recorded this per-module policy with `celilo module jail`.',
|
|
34
|
+
'Exemptions are deliberate, which is why this is todo and not drift —',
|
|
35
|
+
'but an exemption nobody remembers is how a jail quietly stops covering',
|
|
36
|
+
'the fleet. Revisit it: either the reason still holds, or clear it.',
|
|
37
|
+
'',
|
|
38
|
+
`To review: celilo module jail ${exemption.moduleId}`,
|
|
39
|
+
`To clear: celilo module jail ${exemption.moduleId} --clear`,
|
|
40
|
+
].join('\n'),
|
|
41
|
+
remediation: `Clear it with 'celilo module jail ${exemption.moduleId} --clear' if the module now jails cleanly`,
|
|
42
|
+
actionable: false,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
@@ -58,7 +58,9 @@ describe('auditModuleIntegrity', () => {
|
|
|
58
58
|
expect(findings).toHaveLength(1);
|
|
59
59
|
expect(findings[0]?.code).toBe('module_integrity_stale_baseline');
|
|
60
60
|
expect(findings[0]?.severity).toBe('unmeasured');
|
|
61
|
-
expect(findings[0]?.remediation).toBe(
|
|
61
|
+
expect(findings[0]?.remediation).toBe(
|
|
62
|
+
'no automatic remediation: the baseline predates version stamping',
|
|
63
|
+
);
|
|
62
64
|
});
|
|
63
65
|
|
|
64
66
|
test('file drift under a TRUSTWORTHY baseline is drift, and lists every file', () => {
|
|
@@ -122,4 +124,24 @@ describe('auditModuleIntegrity', () => {
|
|
|
122
124
|
expect(f.actionable).toBe(true);
|
|
123
125
|
}
|
|
124
126
|
});
|
|
127
|
+
|
|
128
|
+
test('a stale baseline says there is no command, it does not prescribe one (celilo#1308)', () => {
|
|
129
|
+
const findings = auditModuleIntegrity({
|
|
130
|
+
results: [
|
|
131
|
+
{
|
|
132
|
+
...clean('c'),
|
|
133
|
+
success: false,
|
|
134
|
+
violations: [
|
|
135
|
+
{ type: 'stale-baseline', path: 'x', message: 'baseline is v1, manifest v2' },
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
expect(findings).toHaveLength(1);
|
|
141
|
+
expect(findings[0].code).toBe('module_integrity_stale_baseline');
|
|
142
|
+
// `module update` takes a source path, not the module id this finding
|
|
143
|
+
// carries, and no other verb restamps a baseline. Name the gap.
|
|
144
|
+
expect(findings[0].remediation).not.toMatch(/celilo /);
|
|
145
|
+
expect(findings[0].actionable).toBe(false);
|
|
146
|
+
});
|
|
125
147
|
});
|
|
@@ -52,8 +52,13 @@ export function auditModuleIntegrity(deps: ModuleIntegrityAuditDeps): DriftFindi
|
|
|
52
52
|
severity: 'unmeasured',
|
|
53
53
|
code: 'module_integrity_stale_baseline',
|
|
54
54
|
message: `${result.moduleId}: ${violation.message}`,
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
// No command restamps a baseline. `module update` takes a module
|
|
56
|
+
// source PATH, not an id (celilo#1308: prescribing it with the id
|
|
57
|
+
// named an argument form that does not exist), and `module upgrade`
|
|
58
|
+
// deploys the registry version instead. Say so rather than name a
|
|
59
|
+
// command that cannot run.
|
|
60
|
+
remediation: 'no automatic remediation: the baseline predates version stamping',
|
|
61
|
+
actionable: false,
|
|
57
62
|
subject: result.moduleId,
|
|
58
63
|
});
|
|
59
64
|
}
|
|
@@ -45,7 +45,8 @@ export type DriftCategory =
|
|
|
45
45
|
| 'trusted_sources'
|
|
46
46
|
| 'interface_classification'
|
|
47
47
|
| 'module_integrity'
|
|
48
|
-
| 'detect_without_converge'
|
|
48
|
+
| 'detect_without_converge'
|
|
49
|
+
| 'jail_exemptions';
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* `unmeasured` is a check that could not reach its subject
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
import type { Cadence } from './cadence';
|
|
36
36
|
import { materializeCrossModuleRoot, moduleHasCrossModuleRead } from './cross-module-read';
|
|
37
37
|
import { getModuleSystems } from './deployed-systems';
|
|
38
|
+
import { listMachines } from './machine-pool';
|
|
38
39
|
import { parseStoredConfigValue } from './module-config';
|
|
39
40
|
import {
|
|
40
41
|
completeOperation,
|
|
@@ -367,6 +368,14 @@ export async function createModuleBackup(
|
|
|
367
368
|
|
|
368
369
|
const staged = stageSystemState(join(tempDir, 'system-state'));
|
|
369
370
|
hookInputs.system_state_root = staged.root;
|
|
371
|
+
|
|
372
|
+
// The machine inventory rides the same privilege. on_backup used to
|
|
373
|
+
// shell `celilo machine list --json` for this, which the hook jail kills
|
|
374
|
+
// (no celilo binary, celilo#1225) and which never worked before that:
|
|
375
|
+
// the CLI had no --json flag, so the hook's JSON.parse failed and its
|
|
376
|
+
// catch wrote [] into every envelope. listMachines() is the same read
|
|
377
|
+
// the CLI performs, offered as staged data instead of a spawn.
|
|
378
|
+
hookInputs.machine_pool = JSON.stringify(await listMachines());
|
|
370
379
|
if (!staged.masterKeyStaged) {
|
|
371
380
|
logger.warn(
|
|
372
381
|
`master.key not found at ${getMasterKeyPath()} — secrets in the DB snapshot will be unreadable on restore.`,
|
|
@@ -66,7 +66,7 @@ describe('backup envelope round-trip', () => {
|
|
|
66
66
|
afterEach(() => {
|
|
67
67
|
closeDb();
|
|
68
68
|
resetTestDbPath();
|
|
69
|
-
process.env.CELILO_MASTER_KEY_PATH
|
|
69
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
70
70
|
try {
|
|
71
71
|
rmSync(dir, { recursive: true, force: true });
|
|
72
72
|
} catch {
|
|
@@ -36,7 +36,7 @@ describe('backup/restore in-flight refusal', () => {
|
|
|
36
36
|
afterEach(() => {
|
|
37
37
|
closeDb();
|
|
38
38
|
resetTestDbPath();
|
|
39
|
-
process.env.CELILO_MASTER_KEY_PATH
|
|
39
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
40
40
|
try {
|
|
41
41
|
rmSync(dir, { recursive: true, force: true });
|
|
42
42
|
} catch {
|
|
@@ -155,3 +155,51 @@ exit 0
|
|
|
155
155
|
expect(results).toEqual([]);
|
|
156
156
|
});
|
|
157
157
|
});
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The durable outcome record (celilo#1304): every dispatched run lands in
|
|
161
|
+
* the `build_bus_hook_runs` ledger via the injectable recordRun. Without it
|
|
162
|
+
* a failed self-update existed only as daemon console output, which no
|
|
163
|
+
* surface reads.
|
|
164
|
+
*/
|
|
165
|
+
describe('hook-dispatcher outcome record', () => {
|
|
166
|
+
test('a failing script records exit code, stderr tail, and the event identity', async () => {
|
|
167
|
+
const failDir = mkdtempSync(join(tmpdir(), 'hook-record-test-'));
|
|
168
|
+
const scriptPath = join(failDir, 'fail.sh');
|
|
169
|
+
writeFileSync(scriptPath, '#!/usr/bin/env bash\necho "boom" >&2\nexit 7\n');
|
|
170
|
+
chmodSync(scriptPath, 0o755);
|
|
171
|
+
|
|
172
|
+
const recorded: Array<{
|
|
173
|
+
event: PublishEvent;
|
|
174
|
+
result: { exitCode: number | null; stderr: string };
|
|
175
|
+
}> = [];
|
|
176
|
+
const dispatcher = await startHookDispatcher({
|
|
177
|
+
loadModules: () => [
|
|
178
|
+
{
|
|
179
|
+
moduleId: 'celilo-mgmt',
|
|
180
|
+
sourcePath: failDir,
|
|
181
|
+
hooks: [
|
|
182
|
+
{
|
|
183
|
+
name: 'self-update-celilo-cli',
|
|
184
|
+
match: { registry: 'npm', tag: 'latest', package_pattern: '@celilo/cli' },
|
|
185
|
+
script: 'fail.sh',
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
recordRun: (event, result) => recorded.push({ event, result }),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await dispatcher.handleEvent(
|
|
194
|
+
buildEvent({ package: { name: '@celilo/cli', version: '2.2.1' } }),
|
|
195
|
+
);
|
|
196
|
+
await dispatcher.stop();
|
|
197
|
+
rmSync(failDir, { recursive: true, force: true });
|
|
198
|
+
|
|
199
|
+
expect(recorded).toHaveLength(1);
|
|
200
|
+
expect(recorded[0].event.eventId).toBe('evt-1');
|
|
201
|
+
expect(recorded[0].event.package.name).toBe('@celilo/cli');
|
|
202
|
+
expect(recorded[0].result.exitCode).toBe(7);
|
|
203
|
+
expect(recorded[0].result.stderr).toContain('boom');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -20,7 +20,7 @@ import { join } from 'node:path';
|
|
|
20
20
|
import type { PublishEvent } from '@celilo/event-bus/build-bus';
|
|
21
21
|
import { parse as parseYaml } from 'yaml';
|
|
22
22
|
import { getDb } from '../../db/client';
|
|
23
|
-
import { modules } from '../../db/schema';
|
|
23
|
+
import { buildBusHookRuns, modules } from '../../db/schema';
|
|
24
24
|
import {
|
|
25
25
|
type HookRunResult,
|
|
26
26
|
type ModuleHookContext,
|
|
@@ -43,12 +43,59 @@ export interface HookDispatcherOptions {
|
|
|
43
43
|
* modules table.
|
|
44
44
|
*/
|
|
45
45
|
loadModules?: () => ModuleHookContext[];
|
|
46
|
+
/**
|
|
47
|
+
* Override the durable outcome record. The production default writes one
|
|
48
|
+
* `build_bus_hook_runs` row per run — without it a failed self-update
|
|
49
|
+
* exists only as daemon console output, which no surface reads
|
|
50
|
+
* (celilo#1304). Tests inject a capture to stay DB-free.
|
|
51
|
+
*/
|
|
52
|
+
recordRun?: (event: PublishEvent, result: HookRunResult) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Bound on stdout/stderr tails so one chatty hook cannot bloat the ledger. */
|
|
56
|
+
const TAIL_CHARS = 2000;
|
|
57
|
+
|
|
58
|
+
function tail(s: string): string {
|
|
59
|
+
return s.length > TAIL_CHARS ? s.slice(-TAIL_CHARS) : s;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The production outcome record: one `build_bus_hook_runs` row per run.
|
|
64
|
+
* Throwing here would 500 nothing (the webhook already succeeded) but
|
|
65
|
+
* would kill dispatch of later plans, so a record failure is logged and
|
|
66
|
+
* swallowed — the console outcome lines below remain the fallback signal.
|
|
67
|
+
*/
|
|
68
|
+
function recordHookRun(event: PublishEvent, result: HookRunResult): void {
|
|
69
|
+
try {
|
|
70
|
+
getDb()
|
|
71
|
+
.insert(buildBusHookRuns)
|
|
72
|
+
.values({
|
|
73
|
+
eventId: event.eventId,
|
|
74
|
+
packageName: event.package.name,
|
|
75
|
+
packageVersion: event.package.version,
|
|
76
|
+
tag: event.tag,
|
|
77
|
+
moduleId: result.module,
|
|
78
|
+
hookName: result.hookName,
|
|
79
|
+
scriptPath: result.scriptPath,
|
|
80
|
+
exitCode: result.exitCode,
|
|
81
|
+
timedOut: result.timedOut,
|
|
82
|
+
durationMs: result.durationMs,
|
|
83
|
+
stdoutTail: result.stdout ? tail(result.stdout) : null,
|
|
84
|
+
stderrTail: result.stderr ? tail(result.stderr) : null,
|
|
85
|
+
})
|
|
86
|
+
.run();
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.warn(
|
|
89
|
+
`[build-bus] could not record hook run outcome: ${err instanceof Error ? err.message : String(err)}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
46
92
|
}
|
|
47
93
|
|
|
48
94
|
export async function startHookDispatcher(
|
|
49
95
|
opts: HookDispatcherOptions = {},
|
|
50
96
|
): Promise<HookDispatcher> {
|
|
51
97
|
const loadModules = opts.loadModules ?? loadInstalledModulesWithHooks;
|
|
98
|
+
const recordRun = opts.recordRun ?? recordHookRun;
|
|
52
99
|
|
|
53
100
|
return {
|
|
54
101
|
async handleEvent(event) {
|
|
@@ -66,6 +113,7 @@ export async function startHookDispatcher(
|
|
|
66
113
|
`[build-bus] ${event.package.name}@${event.package.version} → ${plan.module.moduleId} (${plan.hook.name ?? plan.hook.script})`,
|
|
67
114
|
);
|
|
68
115
|
const result = await runHookDispatch(plan);
|
|
116
|
+
recordRun(event, result);
|
|
69
117
|
results.push(result);
|
|
70
118
|
if (result.exitCode === 0) {
|
|
71
119
|
console.log(` ✓ exit 0 in ${result.durationMs}ms`);
|
|
@@ -176,7 +176,7 @@ describe('bus-mediated interviewForEnsureInputs', () => {
|
|
|
176
176
|
bus.close();
|
|
177
177
|
rmSync(tempDir, { recursive: true, force: true });
|
|
178
178
|
resetTestDbPath();
|
|
179
|
-
process.env.EVENT_BUS_DB
|
|
179
|
+
delete process.env.EVENT_BUS_DB;
|
|
180
180
|
});
|
|
181
181
|
|
|
182
182
|
test('mixed inputs: array applied deterministic, config + secret routed through bus', async () => {
|
|
@@ -93,8 +93,8 @@ afterEach(() => {
|
|
|
93
93
|
server.stop(true);
|
|
94
94
|
rmSync(dir, { recursive: true, force: true });
|
|
95
95
|
resetTestDbPath();
|
|
96
|
-
process.env.CELILO_ORIGINAL_CWD
|
|
97
|
-
process.env.EVENT_BUS_DB
|
|
96
|
+
delete process.env.CELILO_ORIGINAL_CWD;
|
|
97
|
+
delete process.env.EVENT_BUS_DB;
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
test('a command parks on an unanswered interview, then resumes with an answer given later', async () => {
|
|
@@ -24,7 +24,7 @@ describe('busInterview', () => {
|
|
|
24
24
|
process.env.EVENT_BUS_DB = dbPath;
|
|
25
25
|
});
|
|
26
26
|
afterEach(() => {
|
|
27
|
-
if (origEnv === undefined) process.env.EVENT_BUS_DB
|
|
27
|
+
if (origEnv === undefined) delete process.env.EVENT_BUS_DB;
|
|
28
28
|
else process.env.EVENT_BUS_DB = origEnv;
|
|
29
29
|
try {
|
|
30
30
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -156,7 +156,7 @@ describe('multi-select payload roundtrip', () => {
|
|
|
156
156
|
process.env.EVENT_BUS_DB = dbPath;
|
|
157
157
|
});
|
|
158
158
|
afterEach(() => {
|
|
159
|
-
if (origEnv === undefined) process.env.EVENT_BUS_DB
|
|
159
|
+
if (origEnv === undefined) delete process.env.EVENT_BUS_DB;
|
|
160
160
|
else process.env.EVENT_BUS_DB = origEnv;
|
|
161
161
|
try {
|
|
162
162
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -156,7 +156,7 @@ describe('bus-mediated interviewForMissingSecrets', () => {
|
|
|
156
156
|
bus.close();
|
|
157
157
|
rmSync(tempDir, { recursive: true, force: true });
|
|
158
158
|
resetTestDbPath();
|
|
159
|
-
process.env.EVENT_BUS_DB
|
|
159
|
+
delete process.env.EVENT_BUS_DB;
|
|
160
160
|
});
|
|
161
161
|
|
|
162
162
|
test('user_provided: emits secret.required, responder writes, value lands in store', async () => {
|