@celilo/cli 2.2.1 → 3.0.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 +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/serve.ts +13 -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.test.ts +35 -0
- package/src/cli/commands/module-health.ts +12 -3
- 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 +28 -6
- package/src/cli/commands/module-publish.ts +14 -12
- package/src/cli/commands/module-update.test.ts +22 -7
- package/src/cli/commands/module-update.ts +73 -18
- package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
- package/src/cli/commands/module-upgrade.test.ts +115 -1
- package/src/cli/commands/module-upgrade.ts +66 -4
- package/src/cli/commands/module-verify.test.ts +1 -1
- package/src/cli/commands/publish/execute.ts +4 -1
- package/src/cli/commands/publish/helpers.ts +4 -3
- package/src/cli/commands/publish/index.ts +23 -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.test.ts +64 -0
- package/src/cli/commands/publish/plan.ts +52 -19
- package/src/cli/commands/publish/types.ts +20 -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/subscribers-install-daemon.test.ts +44 -0
- package/src/cli/commands/subscribers-install-daemon.ts +107 -0
- package/src/cli/commands/subscribers-serve.test.ts +22 -0
- package/src/cli/commands/subscribers-serve.ts +22 -4
- package/src/cli/commands/system-audit.ts +34 -11
- 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 +16 -6
- package/src/cli/completion.ts +18 -3
- package/src/cli/flag-surface-gate.test.ts +279 -0
- package/src/cli/fuel-gauge.ts +12 -4
- package/src/cli/index.ts +80 -2
- package/src/cli/json-output.test.ts +81 -0
- 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/cli/types.ts +9 -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/broker.test.ts +9 -3
- package/src/hooks/capability-loader-firewall.test.ts +9 -1
- package/src/hooks/capability-loader.ts +8 -0
- package/src/hooks/executor.test.ts +91 -14
- package/src/hooks/executor.ts +31 -1
- package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
- package/src/hooks/hook-jail-unreachability.test.ts +27 -20
- package/src/hooks/hook-trespass.test.ts +22 -6
- package/src/hooks/jail.test.ts +105 -17
- package/src/hooks/jail.ts +47 -10
- package/src/hooks/run-named-hook.ts +19 -16
- package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
- package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
- package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
- package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
- package/src/hooks/test-fixtures/silent-hook.ts +0 -1
- package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
- package/src/hooks/test-fixtures/success-hook.ts +4 -4
- package/src/manifest/contracts/v1.ts +32 -14
- package/src/manifest/json-schema-roundtrip.test.ts +1 -1
- package/src/manifest/schema.ts +66 -16
- package/src/manifest/validate.test.ts +47 -0
- package/src/policy/capability-shape-baseline.ts +63 -21
- package/src/policy/capability-shape-drift.test.ts +53 -1
- package/src/policy/capability-shape.test.ts +105 -0
- package/src/policy/capability-shape.ts +283 -2
- 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 +67 -2
- package/src/registry/client.ts +7 -7
- package/src/secrets/storage.test.ts +70 -4
- package/src/secrets/storage.ts +71 -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 +155 -2
- package/src/services/audit/health.ts +76 -2
- package/src/services/audit/index.test.ts +23 -1
- package/src/services/audit/index.ts +8 -2
- package/src/services/audit/interface-classification.test.ts +16 -5
- package/src/services/audit/interface-classification.ts +25 -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/module-versions.ts +5 -1
- package/src/services/audit/public-dns.test.ts +20 -0
- package/src/services/audit/public-dns.ts +7 -2
- package/src/services/audit/recurrence-gate.test.ts +225 -0
- package/src/services/audit/trusted-sources.test.ts +17 -0
- package/src/services/audit/trusted-sources.ts +21 -0
- 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-executor.test.ts +269 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +105 -105
- package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
- package/src/services/build-bus/hook-dispatch.test.ts +86 -116
- package/src/services/build-bus/hook-dispatch.ts +99 -121
- package/src/services/build-bus/hook-dispatcher.ts +143 -17
- package/src/services/build-bus/receiver-daemon.test.ts +189 -0
- package/src/services/build-bus/receiver-daemon.ts +355 -0
- package/src/services/build-bus/self-update.ts +156 -0
- 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/capability-compat.test.ts +90 -0
- package/src/services/capability-compat.ts +128 -0
- 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/deploy-terraform.ts +38 -1
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/events-daemon.test.ts +57 -0
- package/src/services/events-daemon.ts +76 -0
- package/src/services/firewall-reach.ts +21 -8
- package/src/services/fleet-checks.test.ts +159 -4
- package/src/services/fleet-checks.ts +206 -3
- package/src/services/health-runner.test.ts +87 -2
- package/src/services/health-runner.ts +83 -16
- 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-prune.test.ts +89 -0
- package/src/services/module-deploy.ts +84 -129
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/module-types-drift.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +13 -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/terraform-safety.test.ts +83 -0
- package/src/services/terraform-safety.ts +53 -0
- package/src/services/update/orchestrator.test.ts +3 -1
- package/src/test-utils/bus-responder.ts +1 -1
- package/tsconfig.json +2 -13
|
@@ -15,7 +15,7 @@ import { createHookStores } from '../hooks/hook-store';
|
|
|
15
15
|
import { loadHookConfigMap } from '../hooks/load-hook-config';
|
|
16
16
|
import { createCapturingLogger, createConsoleLogger, createGaugeLogger } from '../hooks/logger';
|
|
17
17
|
import type { HookLogger, HookResult } from '../hooks/types';
|
|
18
|
-
import type { ModuleManifest } from '../manifest/schema';
|
|
18
|
+
import type { HealthWaiver, ModuleManifest } from '../manifest/schema';
|
|
19
19
|
import { decryptSecret } from '../secrets/encryption';
|
|
20
20
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
21
21
|
import { controlPlaneHealthChecks } from './control-plane-health';
|
|
@@ -24,7 +24,14 @@ import { remoteAccessPolicy } from './remote-access';
|
|
|
24
24
|
|
|
25
25
|
export interface HealthCheckItem {
|
|
26
26
|
name: string;
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* `skip` — the check could not perform its real probe (a credential is
|
|
29
|
+
* not provisioned, a peer is unreachable from the control plane) and
|
|
30
|
+
* refuses to guess. It is not a pass: a module whose checks all report
|
|
31
|
+
* skip is unmeasured, and `system audit` renders the fleet UNKNOWN, not
|
|
32
|
+
* READY. Never claim `pass` for a probe you did not run (celilo#1326).
|
|
33
|
+
*/
|
|
34
|
+
status: 'pass' | 'warn' | 'fail' | 'skip';
|
|
28
35
|
message: string;
|
|
29
36
|
details?: string;
|
|
30
37
|
}
|
|
@@ -44,12 +51,26 @@ export interface HealthCheckResult {
|
|
|
44
51
|
* watching to re-run the check by hand.
|
|
45
52
|
*/
|
|
46
53
|
artifactPaths?: string[];
|
|
54
|
+
/**
|
|
55
|
+
* The module's recorded health waiver, if its manifest declares one
|
|
56
|
+
* (openspec/changes/health-waiver-mechanism). It does not change this
|
|
57
|
+
* result's severity — an unmeasured module stays unmeasured — it
|
|
58
|
+
* annotates the finding with who decided and why.
|
|
59
|
+
*/
|
|
60
|
+
waiver?: HealthWaiver;
|
|
47
61
|
}
|
|
48
62
|
|
|
49
63
|
export interface HealthCheckOptions {
|
|
50
64
|
debug?: boolean;
|
|
51
65
|
noInteractive?: boolean;
|
|
52
66
|
onProgress?: (msg: string) => void;
|
|
67
|
+
/**
|
|
68
|
+
* Presentation-only silence: no gauge and no plain stdout lines, hook
|
|
69
|
+
* output captured instead. Unlike `unattended` this does NOT touch the
|
|
70
|
+
* state-transition guard — a `--json` command still runs real checks
|
|
71
|
+
* and still moves lifecycle state (celilo#1362).
|
|
72
|
+
*/
|
|
73
|
+
quiet?: boolean;
|
|
53
74
|
/**
|
|
54
75
|
* Scheduled/monitor-driven run: no FuelGauge, no stdout, and — the part
|
|
55
76
|
* that matters beyond cosmetics — the module's lifecycle state is NOT
|
|
@@ -98,6 +119,15 @@ function createHealthCheckPresentation(
|
|
|
98
119
|
return { logger: createCapturingLogger().logger, finish: NO_OP_FINISH };
|
|
99
120
|
}
|
|
100
121
|
|
|
122
|
+
// Non-interactive stdout (redirected, piped, or an explicit --json
|
|
123
|
+
// caller): stdout is a data channel here (celilo#699), so neither the
|
|
124
|
+
// gauge's frames nor the gauge logger's plain lines may reach it.
|
|
125
|
+
// Capture everything instead (celilo#1362). State transitions still
|
|
126
|
+
// happen — only the presentation goes quiet.
|
|
127
|
+
if (options.quiet || !process.stdout.isTTY) {
|
|
128
|
+
return { logger: createCapturingLogger().logger, finish: NO_OP_FINISH };
|
|
129
|
+
}
|
|
130
|
+
|
|
101
131
|
const gauge = new FuelGauge('Testing app', { skipAnimation: options.noInteractive });
|
|
102
132
|
gauge.start();
|
|
103
133
|
return {
|
|
@@ -171,7 +201,7 @@ export async function runModuleHealthCheck(
|
|
|
171
201
|
const hookDef = manifest.hooks?.health_check;
|
|
172
202
|
|
|
173
203
|
if (!hookDef) {
|
|
174
|
-
return { moduleId, status: 'no-checks', checks: [] };
|
|
204
|
+
return { moduleId, status: 'no-checks', checks: [], waiver: manifest.health_waiver };
|
|
175
205
|
}
|
|
176
206
|
|
|
177
207
|
// Build config + secrets for hook context. The config map shape
|
|
@@ -238,7 +268,31 @@ export async function runModuleHealthCheck(
|
|
|
238
268
|
};
|
|
239
269
|
|
|
240
270
|
const checks = outputs.checks || [];
|
|
241
|
-
return finishHealthCheck(
|
|
271
|
+
return finishHealthCheck(
|
|
272
|
+
moduleId,
|
|
273
|
+
module.state,
|
|
274
|
+
checks,
|
|
275
|
+
db,
|
|
276
|
+
options,
|
|
277
|
+
hookResult.artifactPaths,
|
|
278
|
+
manifest.health_waiver,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Derive the module-level verdict from the named checks.
|
|
284
|
+
*
|
|
285
|
+
* Pure so the folding rule is testable without a database or a hook
|
|
286
|
+
* subprocess. `skip` checks are folded toward nothing measured: they are
|
|
287
|
+
* neither pass nor fail, and a module whose checks all skipped is
|
|
288
|
+
* `no-checks` — nothing ran that deserves the word healthy.
|
|
289
|
+
*/
|
|
290
|
+
export function deriveHealthStatus(checks: HealthCheckItem[]): HealthCheckResult['status'] {
|
|
291
|
+
if (checks.some((c) => c.status === 'fail')) return 'unhealthy';
|
|
292
|
+
if (checks.some((c) => c.status === 'warn')) return 'degraded';
|
|
293
|
+
const measured = checks.filter((c) => c.status !== 'skip');
|
|
294
|
+
if (checks.length > 0 && measured.length === 0) return 'no-checks';
|
|
295
|
+
return 'healthy';
|
|
242
296
|
}
|
|
243
297
|
|
|
244
298
|
/**
|
|
@@ -254,22 +308,35 @@ function finishHealthCheck(
|
|
|
254
308
|
db: DbClient,
|
|
255
309
|
options: HealthCheckOptions,
|
|
256
310
|
artifactPaths?: string[],
|
|
311
|
+
waiver?: HealthWaiver,
|
|
257
312
|
): HealthCheckResult {
|
|
258
|
-
const
|
|
259
|
-
const hasWarn = checks.some((c) => c.status === 'warn');
|
|
260
|
-
|
|
261
|
-
const status: HealthCheckResult['status'] = hasFail
|
|
262
|
-
? 'unhealthy'
|
|
263
|
-
: hasWarn
|
|
264
|
-
? 'degraded'
|
|
265
|
-
: 'healthy';
|
|
313
|
+
const status = deriveHealthStatus(checks);
|
|
266
314
|
|
|
267
|
-
|
|
315
|
+
// Only a run that measured something may move lifecycle state. A module
|
|
316
|
+
// whose checks all skipped (or that declared no hook) stays as it was —
|
|
317
|
+
// `VERIFIED` asserts that a check was deliberately run and passed, and
|
|
318
|
+
// nothing was measured here.
|
|
319
|
+
const nextState =
|
|
320
|
+
status === 'healthy' || status === 'degraded' || status === 'unhealthy'
|
|
321
|
+
? nextModuleState(moduleState, status, options.unattended ?? false)
|
|
322
|
+
: null;
|
|
268
323
|
if (nextState) {
|
|
269
|
-
|
|
324
|
+
// Moving to VERIFIED/INSTALLED means the module just measured healthy (or
|
|
325
|
+
// was freshly deployed). Clear a recorded failure with the state, so a
|
|
326
|
+
// healthy row doesn't keep printing its old `Error:` line (celilo#1363).
|
|
327
|
+
db.update(modules)
|
|
328
|
+
.set({ state: nextState, errorMessage: null })
|
|
329
|
+
.where(eq(modules.id, moduleId))
|
|
330
|
+
.run();
|
|
270
331
|
}
|
|
271
332
|
|
|
272
|
-
return {
|
|
333
|
+
return {
|
|
334
|
+
moduleId,
|
|
335
|
+
status,
|
|
336
|
+
checks,
|
|
337
|
+
...(artifactPaths ? { artifactPaths } : {}),
|
|
338
|
+
...(waiver ? { waiver } : {}),
|
|
339
|
+
};
|
|
273
340
|
}
|
|
274
341
|
|
|
275
342
|
/**
|
|
@@ -314,7 +381,7 @@ async function mapConcurrent<T, R>(
|
|
|
314
381
|
*/
|
|
315
382
|
export async function runAllHealthChecks(
|
|
316
383
|
db: DbClient,
|
|
317
|
-
options: { debug?: boolean; onProgress?: (msg: string) => void } = {},
|
|
384
|
+
options: { debug?: boolean; onProgress?: (msg: string) => void; quiet?: boolean } = {},
|
|
318
385
|
): Promise<HealthCheckResult[]> {
|
|
319
386
|
const eligible = db
|
|
320
387
|
.select()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared jail-exemption collector (per-module-jail-policy
|
|
3
|
+
* task 3.1-3.3).
|
|
4
|
+
*
|
|
5
|
+
* Every reporting surface — `module list`, `system doctor`, `system audit` —
|
|
6
|
+
* builds its view from this one function, so these tests are what stops the
|
|
7
|
+
* three from disagreeing about what an exemption is. Isolation mirrors
|
|
8
|
+
* module-jail.test.ts: CELILO_DB_PATH / CELILO_DATA_DIR are set before the
|
|
9
|
+
* SUT is imported.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
13
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { eq } from 'drizzle-orm';
|
|
17
|
+
|
|
18
|
+
const testRoot = mkdtempSync(join(tmpdir(), 'celilo-jail-exemptions-'));
|
|
19
|
+
process.env.CELILO_DB_PATH = join(testRoot, 'celilo.db');
|
|
20
|
+
process.env.CELILO_DATA_DIR = join(testRoot, 'data');
|
|
21
|
+
|
|
22
|
+
const { getDb, closeDb } = await import('../db/client');
|
|
23
|
+
const { moduleJailPolicies, modules, systemConfig } = await import('../db/schema');
|
|
24
|
+
const { collectJailExemptions } = await import('./jail-exemptions');
|
|
25
|
+
|
|
26
|
+
function insertModule(id: string): void {
|
|
27
|
+
getDb()
|
|
28
|
+
.insert(modules)
|
|
29
|
+
.values({ id, name: id, sourcePath: `/tmp/${id}`, version: '1.0.0', manifestData: {} })
|
|
30
|
+
.run();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function setModulePolicy(moduleId: string, policy: string): void {
|
|
34
|
+
const typed = policy as 'auto' | 'off' | 'required';
|
|
35
|
+
getDb()
|
|
36
|
+
.insert(moduleJailPolicies)
|
|
37
|
+
.values({ moduleId, policy: typed, updatedAt: new Date() })
|
|
38
|
+
.onConflictDoUpdate({
|
|
39
|
+
target: moduleJailPolicies.moduleId,
|
|
40
|
+
set: { policy: typed, updatedAt: new Date() },
|
|
41
|
+
})
|
|
42
|
+
.run();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setSystemPolicy(value: string | undefined): void {
|
|
46
|
+
const db = getDb();
|
|
47
|
+
if (value === undefined) {
|
|
48
|
+
db.delete(systemConfig).where(eq(systemConfig.key, 'hooks.jail_policy')).run();
|
|
49
|
+
} else {
|
|
50
|
+
db.insert(systemConfig)
|
|
51
|
+
.values({ key: 'hooks.jail_policy', value })
|
|
52
|
+
.onConflictDoUpdate({ target: systemConfig.key, set: { value } })
|
|
53
|
+
.run();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
afterAll(() => {
|
|
58
|
+
closeDb();
|
|
59
|
+
rmSync(testRoot, { recursive: true, force: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('collectJailExemptions (per-module-jail-policy task 3.4)', () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
const db = getDb();
|
|
65
|
+
db.delete(moduleJailPolicies).run();
|
|
66
|
+
db.delete(systemConfig).where(eq(systemConfig.key, 'hooks.jail_policy')).run();
|
|
67
|
+
db.delete(modules).run();
|
|
68
|
+
insertModule('strictmod');
|
|
69
|
+
insertModule('exemptmod');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('no per-module rows produce no exemptions', () => {
|
|
73
|
+
expect(collectJailExemptions(getDb())).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a module policy stronger than the system's is not an exemption", () => {
|
|
77
|
+
// The fleet default is `off`, so `auto` STRENGTHENS the posture. Reporting
|
|
78
|
+
// it as an exemption would be a surface reporting an exemption nobody set.
|
|
79
|
+
setModulePolicy('strictmod', 'required');
|
|
80
|
+
expect(collectJailExemptions(getDb())).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a module policy equal to the system's is not an exemption", () => {
|
|
84
|
+
setSystemPolicy('auto');
|
|
85
|
+
setModulePolicy('strictmod', 'auto');
|
|
86
|
+
expect(collectJailExemptions(getDb())).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a module policy weaker than the system's is reported, with both values", () => {
|
|
90
|
+
setSystemPolicy('auto');
|
|
91
|
+
setModulePolicy('exemptmod', 'off');
|
|
92
|
+
expect(collectJailExemptions(getDb())).toEqual([
|
|
93
|
+
{ moduleId: 'exemptmod', policy: 'off', systemPolicy: 'auto' },
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('a weaker exemption against the unset-system default (off) is impossible', () => {
|
|
98
|
+
// off is the floor; nothing can be weaker than it. This is the negative
|
|
99
|
+
// half of task 3.4: with nobody having set the system key, no module row
|
|
100
|
+
// — whatever it holds — may read as an exemption.
|
|
101
|
+
setModulePolicy('exemptmod', 'off');
|
|
102
|
+
expect(collectJailExemptions(getDb())).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('two exemptions are both reported', () => {
|
|
106
|
+
insertModule('exemptmod2');
|
|
107
|
+
setSystemPolicy('required');
|
|
108
|
+
setModulePolicy('exemptmod', 'auto');
|
|
109
|
+
setModulePolicy('exemptmod2', 'off');
|
|
110
|
+
const exemptions = collectJailExemptions(getDb());
|
|
111
|
+
expect(exemptions).toHaveLength(2);
|
|
112
|
+
expect(exemptions.map((e) => e.moduleId).sort()).toEqual(['exemptmod', 'exemptmod2']);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a stored module policy outside the accepted set throws, not coerces', () => {
|
|
116
|
+
setModulePolicy('exemptmod', 'sometimes');
|
|
117
|
+
expect(() => collectJailExemptions(getDb())).toThrow(/not a hook jail policy/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a stored system policy outside the accepted set throws, naming the fix', () => {
|
|
121
|
+
setSystemPolicy('jail-harder');
|
|
122
|
+
setModulePolicy('exemptmod', 'off');
|
|
123
|
+
expect(() => collectJailExemptions(getDb())).toThrow(/hooks\.jail_policy='jail-harder'/);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-module jail exemptions, as one query every reporting surface shares
|
|
3
|
+
* (per-module-jail-policy task 3.1-3.3).
|
|
4
|
+
*
|
|
5
|
+
* An exemption is a recorded module policy weaker than the system's
|
|
6
|
+
* `hooks.jail_policy`. The three surfaces — `module list`'s marker,
|
|
7
|
+
* `system doctor`'s count, `system audit`'s category — MUST agree on what
|
|
8
|
+
* that is, so the comparison lives in `isWeakerJailPolicy` and this file only
|
|
9
|
+
* fetches rows and applies it. A surface that re-derived the definition here
|
|
10
|
+
* would eventually report a different set from the other two, and a countable
|
|
11
|
+
* exemption that two surfaces miss is the failure this slice exists to close.
|
|
12
|
+
*
|
|
13
|
+
* Read-time refusal follows the rule the resolvers already follow: a stored
|
|
14
|
+
* value outside the accepted set THROWS (peba's ruling on ce-8832). The set
|
|
15
|
+
* path validates, so a bad row can only arrive via a restore or a hand edit —
|
|
16
|
+
* the exact foreign state the rule refuses to coerce into a silent default.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { eq } from 'drizzle-orm';
|
|
20
|
+
import type { getDb as getDbType } from '../db/client';
|
|
21
|
+
import { moduleJailPolicies, systemConfig } from '../db/schema';
|
|
22
|
+
import { type JailPolicy, isWeakerJailPolicy, resolveJailPolicy } from '../hooks/jail';
|
|
23
|
+
|
|
24
|
+
const JAIL_SYSTEM_KEY = 'hooks.jail_policy';
|
|
25
|
+
|
|
26
|
+
export interface JailExemption {
|
|
27
|
+
moduleId: string;
|
|
28
|
+
/** The module's own recorded policy — the weaker of the two. */
|
|
29
|
+
policy: JailPolicy;
|
|
30
|
+
/** The system policy it is weaker than: the stored key or the default. */
|
|
31
|
+
systemPolicy: JailPolicy;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Db = ReturnType<typeof getDbType>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The system-wide policy every module without its own row follows.
|
|
38
|
+
*
|
|
39
|
+
* Throws on a stored value outside the accepted set, matching the read-time
|
|
40
|
+
* refusal rule; the error names the fix, and the same throw will be failing
|
|
41
|
+
* every hook invocation too (the executor resolves through the same key).
|
|
42
|
+
*/
|
|
43
|
+
function systemJailPolicy(db: Db): JailPolicy {
|
|
44
|
+
const stored = db
|
|
45
|
+
.select()
|
|
46
|
+
.from(systemConfig)
|
|
47
|
+
.where(eq(systemConfig.key, JAIL_SYSTEM_KEY))
|
|
48
|
+
.get()?.value;
|
|
49
|
+
if (stored === undefined) return 'off';
|
|
50
|
+
try {
|
|
51
|
+
return resolveJailPolicy(undefined, undefined, stored).policy;
|
|
52
|
+
} catch {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Stored ${JAIL_SYSTEM_KEY}='${stored}' is not a hook jail policy. Fix it with 'celilo system config set ${JAIL_SYSTEM_KEY} <auto|off|required>'.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every module running under a recorded policy weaker than the system's.
|
|
61
|
+
*
|
|
62
|
+
* A module row stronger than or equal to the system's is NOT an exemption —
|
|
63
|
+
* the operator strengthened that module's posture, and reporting it as an
|
|
64
|
+
* exemption would be a phantom finding (per-module-jail-policy task 3.4).
|
|
65
|
+
* A bad module row throws rather than being skipped or coerced.
|
|
66
|
+
*/
|
|
67
|
+
export function collectJailExemptions(db: Db): JailExemption[] {
|
|
68
|
+
const system = systemJailPolicy(db);
|
|
69
|
+
const rows = db.select().from(moduleJailPolicies).all();
|
|
70
|
+
|
|
71
|
+
const exemptions: JailExemption[] = [];
|
|
72
|
+
for (const row of rows) {
|
|
73
|
+
// Read-time validation through the same resolver the executor uses, so a
|
|
74
|
+
// hand-edited row fails with the same message here that a hook would get.
|
|
75
|
+
const policy = resolveJailPolicy(undefined, row.policy, undefined).policy;
|
|
76
|
+
if (isWeakerJailPolicy(policy, system)) {
|
|
77
|
+
exemptions.push({ moduleId: row.moduleId, policy, systemPolicy: system });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return exemptions;
|
|
81
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { isPreservedGeneratedPath, pruneGeneratedProject } from './module-deploy';
|
|
5
|
+
|
|
6
|
+
const ROOT = '/tmp/celilo-prune-test';
|
|
7
|
+
const GENERATED = join(ROOT, 'generated');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Mirrors the layout observed on celilo-mgr at 2026-09-16T00:51Z:
|
|
11
|
+
* `generated/{ansible,deploy.log,terraform}` with state inside terraform/.
|
|
12
|
+
* Derived from the real tree rather than invented — a hand-written mirror gives
|
|
13
|
+
* confident results about a world that does not exist.
|
|
14
|
+
*/
|
|
15
|
+
function buildGeneratedTree(): void {
|
|
16
|
+
mkdirSync(join(GENERATED, 'ansible', 'roles'), { recursive: true });
|
|
17
|
+
mkdirSync(join(GENERATED, 'terraform', '.terraform', 'providers'), { recursive: true });
|
|
18
|
+
|
|
19
|
+
writeFileSync(join(GENERATED, 'deploy.log'), 'deploy output');
|
|
20
|
+
writeFileSync(join(GENERATED, 'ansible', 'secrets.yml'), 'vault-encrypted secrets');
|
|
21
|
+
writeFileSync(join(GENERATED, 'ansible', 'roles', 'task.yml'), 'rendered task');
|
|
22
|
+
writeFileSync(join(GENERATED, 'terraform', 'main.tf'), 'rendered terraform');
|
|
23
|
+
writeFileSync(join(GENERATED, 'terraform', 'terraform.tfvars'), 'rendered vars');
|
|
24
|
+
writeFileSync(join(GENERATED, 'terraform', 'terraform.tfstate'), '{"lxc":207}');
|
|
25
|
+
writeFileSync(join(GENERATED, 'terraform', 'terraform.tfstate.backup'), '{"lxc":207,"old":1}');
|
|
26
|
+
writeFileSync(join(GENERATED, 'terraform', '.terraform.lock.hcl'), 'provider lock');
|
|
27
|
+
writeFileSync(join(GENERATED, 'terraform', '.terraform', 'providers', 'bin'), 'huge binary');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
rmSync(ROOT, { recursive: true, force: true });
|
|
32
|
+
buildGeneratedTree();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
rmSync(ROOT, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('pruneGeneratedProject', () => {
|
|
40
|
+
// The recurrence gate for celilo#1374's root cause. Deleting this file is
|
|
41
|
+
// what armed every container module for destruction on its next deploy.
|
|
42
|
+
test('keeps terraform state and its lock', () => {
|
|
43
|
+
pruneGeneratedProject(GENERATED);
|
|
44
|
+
|
|
45
|
+
expect(existsSync(join(GENERATED, 'terraform', 'terraform.tfstate'))).toBe(true);
|
|
46
|
+
expect(existsSync(join(GENERATED, 'terraform', 'terraform.tfstate.backup'))).toBe(true);
|
|
47
|
+
expect(existsSync(join(GENERATED, 'terraform', '.terraform.lock.hcl'))).toBe(true);
|
|
48
|
+
// Intact, not merely present.
|
|
49
|
+
expect(readFileSync(join(GENERATED, 'terraform', 'terraform.tfstate'), 'utf-8')).toBe(
|
|
50
|
+
'{"lxc":207}',
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// D4's actual concern. If this ever passes, the fix has quietly become
|
|
55
|
+
// "persist the rendered project", which D4 refused for good reasons.
|
|
56
|
+
test('still deletes every rendered template and secret', () => {
|
|
57
|
+
pruneGeneratedProject(GENERATED);
|
|
58
|
+
|
|
59
|
+
expect(existsSync(join(GENERATED, 'ansible'))).toBe(false);
|
|
60
|
+
expect(existsSync(join(GENERATED, 'deploy.log'))).toBe(false);
|
|
61
|
+
expect(existsSync(join(GENERATED, 'terraform', 'main.tf'))).toBe(false);
|
|
62
|
+
expect(existsSync(join(GENERATED, 'terraform', 'terraform.tfvars'))).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ~19 MB per provider, rebuilt by `terraform init`; excluded from the backup
|
|
66
|
+
// envelope for the same reason (cross-module-read.ts).
|
|
67
|
+
test('drops the provider cache', () => {
|
|
68
|
+
pruneGeneratedProject(GENERATED);
|
|
69
|
+
|
|
70
|
+
expect(existsSync(join(GENERATED, 'terraform', '.terraform'))).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// A deploy that failed before rendering never made one.
|
|
74
|
+
test('is a no-op when generated/ is absent', () => {
|
|
75
|
+
rmSync(GENERATED, { recursive: true, force: true });
|
|
76
|
+
|
|
77
|
+
expect(() => pruneGeneratedProject(GENERATED)).not.toThrow();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('isPreservedGeneratedPath', () => {
|
|
82
|
+
test('preserves only state files directly under terraform/', () => {
|
|
83
|
+
expect(isPreservedGeneratedPath('terraform/terraform.tfstate')).toBe(true);
|
|
84
|
+
expect(isPreservedGeneratedPath('terraform/main.tf')).toBe(false);
|
|
85
|
+
expect(isPreservedGeneratedPath('ansible/terraform.tfstate')).toBe(false);
|
|
86
|
+
// A nested lookalike must not smuggle a whole tree through.
|
|
87
|
+
expect(isPreservedGeneratedPath('terraform/nested/terraform.tfstate')).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|