@celilo/cli 2.2.0 → 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/schemas/system_config.json +5 -0
- package/src/__integration__/container-services-cli.integration.test.ts +1 -1
- package/src/ansible/inventory.test.ts +2 -1
- package/src/api/sessions.test.ts +5 -4
- package/src/api-clients/proxmox.ts +10 -7
- package/src/cli/backup-rename.test.ts +5 -4
- package/src/cli/cli.test.ts +3 -2
- package/src/cli/commands/console-get-chain.test.ts +2 -1
- package/src/cli/commands/events.test.ts +2 -2
- package/src/cli/commands/firewall-interface-list.test.ts +4 -3
- package/src/cli/commands/machine-list.test.ts +57 -0
- package/src/cli/commands/machine-list.ts +35 -4
- package/src/cli/commands/module-config.test.ts +2 -1
- package/src/cli/commands/module-deploy.ts +8 -2
- package/src/cli/commands/module-generate.test.ts +53 -0
- package/src/cli/commands/module-generate.ts +31 -26
- package/src/cli/commands/module-health.ts +1 -0
- package/src/cli/commands/module-import-aspect.test.ts +2 -1
- package/src/cli/commands/module-import-registry.test.ts +3 -2
- 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-operations.test.ts +2 -1
- package/src/cli/commands/module-publish.test.ts +11 -18
- package/src/cli/commands/module-update.test.ts +28 -12
- package/src/cli/commands/module-update.ts +4 -1
- package/src/cli/commands/module-upgrade.test.ts +130 -1
- package/src/cli/commands/module-upgrade.ts +77 -3
- package/src/cli/commands/module-verify.test.ts +3 -4
- package/src/cli/commands/module-verify.ts +0 -1
- package/src/cli/commands/notify-config.test.ts +5 -3
- 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 +3 -2
- package/src/cli/commands/registry-token.test.ts +3 -2
- package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
- package/src/cli/commands/system-audit.ts +20 -6
- package/src/cli/commands/system-config.test.ts +148 -0
- package/src/cli/commands/system-config.ts +26 -1
- package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
- package/src/cli/commands/system-doctor.test.ts +143 -6
- package/src/cli/commands/system-doctor.ts +142 -25
- package/src/cli/commands/system-init-deprecation.test.ts +7 -4
- package/src/cli/commands/system-migrate.test.ts +2 -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 +70 -3
- package/src/cli/parser.test.ts +37 -1
- package/src/cli/restore-command.test.ts +5 -4
- package/src/cli/restore-migration-failure.test.ts +4 -3
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/config/paths.test.ts +22 -22
- package/src/db/client.test.ts +46 -1
- package/src/db/client.ts +26 -0
- package/src/db/migrate.test.ts +2 -1
- package/src/db/migrate.ts +16 -16
- package/src/db/schema.ts +63 -0
- package/src/hooks/capability-loader-firewall.test.ts +9 -1
- package/src/hooks/capability-loader.test.ts +78 -0
- package/src/hooks/capability-loader.ts +45 -2
- package/src/hooks/executor.test.ts +161 -1
- package/src/hooks/executor.ts +87 -21
- package/src/hooks/hook-jail-unreachability.test.ts +17 -18
- package/src/hooks/hook-store.test.ts +6 -0
- package/src/hooks/hook-trespass.test.ts +26 -9
- package/src/hooks/jail.test.ts +152 -5
- package/src/hooks/jail.ts +93 -4
- package/src/manifest/contracts/v1.ts +13 -0
- package/src/module/packaging/audit.ts +9 -26
- package/src/module/packaging/build-paths.test.ts +127 -0
- package/src/module/packaging/build-paths.ts +175 -0
- package/src/module/packaging/build.test.ts +71 -1
- package/src/module/packaging/build.ts +60 -0
- package/src/module/packaging/extract.ts +1 -5
- package/src/policy/module-script-scan.ts +51 -107
- package/src/policy/no-hand-built-ssh.test.ts +5 -1
- package/src/policy/no-swallowed-refusal.test.ts +14 -15
- package/src/registry/client.test.ts +2 -2
- package/src/secrets/storage.test.ts +1 -1
- package/src/services/alerting/ack.test.ts +2 -1
- package/src/services/alerting/cadence-migration.test.ts +3 -2
- package/src/services/alerting/coverage-source.test.ts +2 -1
- package/src/services/alerting/deferral.test.ts +2 -1
- package/src/services/alerting/delivery-loop.test.ts +2 -1
- package/src/services/alerting/deploy-hooks.test.ts +2 -1
- package/src/services/alerting/inbound-poller.test.ts +2 -1
- package/src/services/alerting/inbound.test.ts +2 -1
- package/src/services/alerting/keys.test.ts +4 -0
- package/src/services/alerting/keys.ts +12 -2
- package/src/services/alerting/notification-responder.test.ts +2 -1
- package/src/services/alerting/run-monitor.test.ts +2 -1
- package/src/services/alerting/store.test.ts +2 -1
- package/src/services/alerting/sweep-runner.test.ts +2 -1
- package/src/services/alerting/tokens.test.ts +2 -1
- package/src/services/aspect-approvals.test.ts +2 -1
- package/src/services/aspect-reconcile.test.ts +4 -3
- package/src/services/aspect-runner.test.ts +2 -1
- 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 -22
- package/src/services/audit/module-integrity.ts +7 -16
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-age-agreement.test.ts +2 -1
- package/src/services/backup-create.ts +9 -0
- package/src/services/backup-envelope-roundtrip.test.ts +3 -2
- package/src/services/backup-in-flight-refusal.test.ts +3 -2
- 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 +3 -2
- package/src/services/bus-interview-park.test.ts +4 -3
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +3 -2
- package/src/services/capability-table-rows.test.ts +2 -1
- package/src/services/celilo-events.test.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +23 -5
- package/src/services/consumer-cleanup.test.ts +3 -2
- package/src/services/container-service.test.ts +3 -2
- package/src/services/control-plane-bootstrap.test.ts +2 -1
- package/src/services/cross-module-read.test.ts +4 -3
- package/src/services/deploy-preflight.ts +7 -0
- package/src/services/deploy-validation.test.ts +3 -2
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/dns-internal-records.test.ts +3 -2
- package/src/services/dns-provider-backfill.test.ts +2 -1
- package/src/services/dns-registrations.test.ts +2 -1
- package/src/services/ensure-interview.test.ts +3 -2
- package/src/services/fleet-checks.test.ts +126 -6
- package/src/services/fleet-checks.ts +176 -3
- package/src/services/fleet-key.test.ts +3 -2
- package/src/services/health-runner.test.ts +87 -2
- package/src/services/health-runner.ts +40 -11
- package/src/services/infrastructure-selector.test.ts +3 -2
- package/src/services/infrastructure-variable-resolver.test.ts +2 -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 +3 -2
- package/src/services/module-config.test.ts +2 -1
- package/src/services/module-deploy.dns-repoint.test.ts +2 -1
- package/src/services/module-deploy.ts +48 -24
- package/src/services/module-operations.test.ts +2 -1
- package/src/services/module-subscriptions.test.ts +4 -3
- package/src/services/network-discovery.test.ts +64 -1
- package/src/services/network-discovery.ts +30 -5
- package/src/services/port-forwards.test.ts +2 -1
- package/src/services/programmatic-responder.aspect.test.ts +2 -1
- package/src/services/proxmox-reconcile.test.ts +2 -1
- package/src/services/responder-probe.test.ts +1 -1
- package/src/services/restore-from-file.test.ts +7 -6
- package/src/services/restore-preflight.test.ts +3 -2
- package/src/services/secret-schema-loader.test.ts +2 -1
- package/src/services/ssh-key-manager.test.ts +4 -3
- package/src/services/static-content-converge.test.ts +5 -4
- package/src/services/static-content-converge.ts +33 -10
- package/src/services/system-state-stage.test.ts +4 -3
- package/src/services/trusted-sources.test.ts +3 -2
- package/src/services/update/orchestrator.test.ts +2 -0
- package/src/services/zone-detector.test.ts +2 -1
- package/src/test-utils/bus-responder.ts +6 -4
- package/src/test-utils/db-path.ts +25 -0
- package/src/test-utils/integration.ts +7 -0
- package/src/test-utils/module-fixtures.ts +5 -6
- package/src/module/packaging/generated-plane.test.ts +0 -79
- package/src/module/packaging/generated-plane.ts +0 -134
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
1
|
+
import { describe, expect, spyOn, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { eq } from 'drizzle-orm';
|
|
6
|
+
import { type DbClient, getDb } from '../db/client';
|
|
7
|
+
import { modules } from '../db/schema';
|
|
8
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
9
|
+
import * as controlPlaneHealth from './control-plane-health';
|
|
10
|
+
import type { HealthCheckItem } from './health-runner';
|
|
11
|
+
import { deriveHealthStatus, nextModuleState, runModuleHealthCheck } from './health-runner';
|
|
3
12
|
|
|
4
13
|
/**
|
|
5
14
|
* The D15 rule: a scheduled (monitor-driven) health check observes only and
|
|
@@ -9,6 +18,34 @@ import { nextModuleState } from './health-runner';
|
|
|
9
18
|
* throw, it just makes `celilo module list` flicker between INSTALLED and
|
|
10
19
|
* VERIFIED every monitor interval.
|
|
11
20
|
*/
|
|
21
|
+
describe('deriveHealthStatus', () => {
|
|
22
|
+
const item = (status: HealthCheckItem['status'], name = 'probe'): HealthCheckItem => ({
|
|
23
|
+
name,
|
|
24
|
+
status,
|
|
25
|
+
message: 'msg',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('fail dominates everything', () => {
|
|
29
|
+
expect(deriveHealthStatus([item('pass'), item('fail'), item('warn')])).toBe('unhealthy');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('warn without fail is degraded', () => {
|
|
33
|
+
expect(deriveHealthStatus([item('pass'), item('warn')])).toBe('degraded');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('measured passes with a skip are healthy — the audit carries the skip', () => {
|
|
37
|
+
expect(deriveHealthStatus([item('pass'), item('skip')])).toBe('healthy');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('only skips is no-checks — nothing was measured', () => {
|
|
41
|
+
expect(deriveHealthStatus([item('skip'), item('skip')])).toBe('no-checks');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('empty checks is healthy (the hook ran and reported nothing)', () => {
|
|
45
|
+
expect(deriveHealthStatus([])).toBe('healthy');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
12
49
|
describe('nextModuleState', () => {
|
|
13
50
|
describe('operator-invoked run (unattended = false)', () => {
|
|
14
51
|
test('passing check verifies an INSTALLED module', () => {
|
|
@@ -48,3 +85,51 @@ describe('nextModuleState', () => {
|
|
|
48
85
|
});
|
|
49
86
|
});
|
|
50
87
|
});
|
|
88
|
+
|
|
89
|
+
// celilo#1363: a failed upgrade leaves state ERROR with an errorMessage. The
|
|
90
|
+
// recovery path is an attended health check passing, which moves ERROR →
|
|
91
|
+
// VERIFIED. The message must move with the state, or `module list` keeps
|
|
92
|
+
// printing the old `Error:` line beside a healthy row.
|
|
93
|
+
describe('runModuleHealthCheck — clearing a recorded failure (celilo#1363)', () => {
|
|
94
|
+
test('an attended healthy check clears errorMessage along with the state', async () => {
|
|
95
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'celilo-health-'));
|
|
96
|
+
process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const db: DbClient = getDb();
|
|
100
|
+
db.insert(modules)
|
|
101
|
+
.values({
|
|
102
|
+
id: 'celilo-mgmt',
|
|
103
|
+
name: 'Control plane',
|
|
104
|
+
sourcePath: join(tempDir, 'celilo-mgmt'),
|
|
105
|
+
version: '1.0.0+5',
|
|
106
|
+
state: 'ERROR',
|
|
107
|
+
errorMessage: 'Upgrade to 1.0.0+6 failed to deploy: ssh: connection refused',
|
|
108
|
+
manifestData: {
|
|
109
|
+
celilo_contract: '1.0',
|
|
110
|
+
id: 'celilo-mgmt',
|
|
111
|
+
name: 'Control plane',
|
|
112
|
+
version: '1.0.0',
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
.run();
|
|
116
|
+
|
|
117
|
+
const checks: HealthCheckItem[] = [{ name: 'probe', status: 'pass', message: 'ok' }];
|
|
118
|
+
const spy = spyOn(controlPlaneHealth, 'controlPlaneHealthChecks').mockResolvedValue(checks);
|
|
119
|
+
try {
|
|
120
|
+
const result = await runModuleHealthCheck('celilo-mgmt', db, {});
|
|
121
|
+
expect(result.status).toBe('healthy');
|
|
122
|
+
} finally {
|
|
123
|
+
spy.mockRestore();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const row = db.select().from(modules).where(eq(modules.id, 'celilo-mgmt')).get();
|
|
127
|
+
if (!row) throw new Error('row missing after health check');
|
|
128
|
+
expect(row.state).toBe('VERIFIED');
|
|
129
|
+
expect(row.errorMessage).toBeNull();
|
|
130
|
+
} finally {
|
|
131
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
132
|
+
resetTestDbPath();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -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
|
}
|
|
@@ -241,6 +248,22 @@ export async function runModuleHealthCheck(
|
|
|
241
248
|
return finishHealthCheck(moduleId, module.state, checks, db, options, hookResult.artifactPaths);
|
|
242
249
|
}
|
|
243
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Derive the module-level verdict from the named checks.
|
|
253
|
+
*
|
|
254
|
+
* Pure so the folding rule is testable without a database or a hook
|
|
255
|
+
* subprocess. `skip` checks are folded toward nothing measured: they are
|
|
256
|
+
* neither pass nor fail, and a module whose checks all skipped is
|
|
257
|
+
* `no-checks` — nothing ran that deserves the word healthy.
|
|
258
|
+
*/
|
|
259
|
+
export function deriveHealthStatus(checks: HealthCheckItem[]): HealthCheckResult['status'] {
|
|
260
|
+
if (checks.some((c) => c.status === 'fail')) return 'unhealthy';
|
|
261
|
+
if (checks.some((c) => c.status === 'warn')) return 'degraded';
|
|
262
|
+
const measured = checks.filter((c) => c.status !== 'skip');
|
|
263
|
+
if (checks.length > 0 && measured.length === 0) return 'no-checks';
|
|
264
|
+
return 'healthy';
|
|
265
|
+
}
|
|
266
|
+
|
|
244
267
|
/**
|
|
245
268
|
* Derive the verdict from the named checks and move the module's state.
|
|
246
269
|
*
|
|
@@ -255,18 +278,24 @@ function finishHealthCheck(
|
|
|
255
278
|
options: HealthCheckOptions,
|
|
256
279
|
artifactPaths?: string[],
|
|
257
280
|
): 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';
|
|
281
|
+
const status = deriveHealthStatus(checks);
|
|
266
282
|
|
|
267
|
-
|
|
283
|
+
// Only a run that measured something may move lifecycle state. A module
|
|
284
|
+
// whose checks all skipped (or that declared no hook) stays as it was —
|
|
285
|
+
// `VERIFIED` asserts that a check was deliberately run and passed, and
|
|
286
|
+
// nothing was measured here.
|
|
287
|
+
const nextState =
|
|
288
|
+
status === 'healthy' || status === 'degraded' || status === 'unhealthy'
|
|
289
|
+
? nextModuleState(moduleState, status, options.unattended ?? false)
|
|
290
|
+
: null;
|
|
268
291
|
if (nextState) {
|
|
269
|
-
|
|
292
|
+
// Moving to VERIFIED/INSTALLED means the module just measured healthy (or
|
|
293
|
+
// was freshly deployed). Clear a recorded failure with the state, so a
|
|
294
|
+
// healthy row doesn't keep printing its old `Error:` line (celilo#1363).
|
|
295
|
+
db.update(modules)
|
|
296
|
+
.set({ state: nextState, errorMessage: null })
|
|
297
|
+
.where(eq(modules.id, moduleId))
|
|
298
|
+
.run();
|
|
270
299
|
}
|
|
271
300
|
|
|
272
301
|
return { moduleId, status, checks, ...(artifactPaths ? { artifactPaths } : {}) };
|
|
@@ -10,6 +10,7 @@ import { closeDb, createDbClient } from '../db/client';
|
|
|
10
10
|
import { runMigrations } from '../db/migrate';
|
|
11
11
|
import { modules } from '../db/schema';
|
|
12
12
|
import type { Module } from '../db/schema';
|
|
13
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
13
14
|
import { addContainerService } from './container-service';
|
|
14
15
|
import { InfrastructureError, selectInfrastructure } from './infrastructure-selector';
|
|
15
16
|
import { addMachine } from './machine-pool';
|
|
@@ -46,8 +47,8 @@ describe('infrastructure-selector', () => {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
// Clear environment variables
|
|
49
|
-
|
|
50
|
-
process.env.CELILO_MASTER_KEY_PATH
|
|
50
|
+
resetTestDbPath();
|
|
51
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
51
52
|
});
|
|
52
53
|
|
|
53
54
|
describe('selectInfrastructure', () => {
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from '@/db/schema';
|
|
12
12
|
import type { ModuleManifest } from '@/manifest/schema';
|
|
13
13
|
import { and, eq } from 'drizzle-orm';
|
|
14
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
14
15
|
import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
|
|
15
16
|
import { upsertModuleConfig } from './module-config';
|
|
16
17
|
|
|
@@ -38,7 +39,7 @@ beforeEach(() => {
|
|
|
38
39
|
|
|
39
40
|
afterEach(async () => {
|
|
40
41
|
db.$client.close();
|
|
41
|
-
|
|
42
|
+
resetTestDbPath();
|
|
42
43
|
|
|
43
44
|
// Clean up database files
|
|
44
45
|
if (existsSync(TEST_DB_PATH)) {
|
|
@@ -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
|
+
}
|
|
@@ -10,6 +10,7 @@ import { eq } from 'drizzle-orm';
|
|
|
10
10
|
import { closeDb, createDbClient, getDb } from '../db/client';
|
|
11
11
|
import { runMigrations } from '../db/migrate';
|
|
12
12
|
import { machines, moduleInfrastructure, modules } from '../db/schema';
|
|
13
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
13
14
|
import {
|
|
14
15
|
addMachine,
|
|
15
16
|
getMachine,
|
|
@@ -52,8 +53,8 @@ describe('machine-pool', () => {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
// Clear environment variables
|
|
55
|
-
|
|
56
|
-
process.env.CELILO_MASTER_KEY_PATH
|
|
56
|
+
resetTestDbPath();
|
|
57
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
57
58
|
});
|
|
58
59
|
|
|
59
60
|
describe('addMachine', () => {
|
|
@@ -9,6 +9,7 @@ import { join } from 'node:path';
|
|
|
9
9
|
import { closeDb, createDbClient } from '../db/client';
|
|
10
10
|
import { runMigrations } from '../db/migrate';
|
|
11
11
|
import { modules } from '../db/schema';
|
|
12
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
12
13
|
import {
|
|
13
14
|
formatConfigValue,
|
|
14
15
|
getAllModuleConfigValues,
|
|
@@ -59,7 +60,7 @@ describe('module-config service', () => {
|
|
|
59
60
|
afterEach(() => {
|
|
60
61
|
closeDb();
|
|
61
62
|
// Clean up environment variable
|
|
62
|
-
|
|
63
|
+
resetTestDbPath();
|
|
63
64
|
// Clean up test database
|
|
64
65
|
rmSync(testDir, { recursive: true, force: true });
|
|
65
66
|
});
|
|
@@ -9,6 +9,7 @@ import { eq } from 'drizzle-orm';
|
|
|
9
9
|
import { type DbClient, closeDb, getDb } from '../db/client';
|
|
10
10
|
import { runMigrations } from '../db/migrate';
|
|
11
11
|
import { capabilities, moduleSystems, modules, systemConfig } from '../db/schema';
|
|
12
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
12
13
|
import { InterviewUnansweredError } from './interview-errors';
|
|
13
14
|
import { planDnsRepoint, repointDnsPrimaryForProvider } from './module-deploy';
|
|
14
15
|
|
|
@@ -49,7 +50,7 @@ describe('repointDnsPrimaryForProvider (module-orchestrator-primitives slice 3)'
|
|
|
49
50
|
|
|
50
51
|
afterEach(() => {
|
|
51
52
|
closeDb();
|
|
52
|
-
|
|
53
|
+
resetTestDbPath();
|
|
53
54
|
try {
|
|
54
55
|
rmSync(dir, { recursive: true, force: true });
|
|
55
56
|
} catch {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Coordinates full module deployment workflow across all phases
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { rmSync } from 'node:fs';
|
|
7
8
|
import { join } from 'node:path';
|
|
8
9
|
import { ProgressDisplay } from '@celilo/cli-display';
|
|
9
10
|
import { and, eq } from 'drizzle-orm';
|
|
@@ -104,6 +105,17 @@ export interface DeployOptions {
|
|
|
104
105
|
* this mode — that requires a real hook run.
|
|
105
106
|
*/
|
|
106
107
|
stopAfterInterview?: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* D4 (control-plane-stops-building-modules): retain the generated project
|
|
110
|
+
* after a successful deploy instead of deleting it. The tree is ephemeral
|
|
111
|
+
* by design — rendered for this deploy from the signed payload plus live
|
|
112
|
+
* config, used, then removed — because a persisted generated tree cannot be
|
|
113
|
+
* validated (only its verbatim role assets have a meaningful digest, the
|
|
114
|
+
* rest is rendered and SUPPOSED to differ), it holds rendered secrets, and
|
|
115
|
+
* it is what the next deploy would ship. This flag keeps it for deliberate
|
|
116
|
+
* inspection. It is always kept when the deploy fails.
|
|
117
|
+
*/
|
|
118
|
+
keepGeneratedProject?: boolean;
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
/**
|
|
@@ -368,6 +380,26 @@ export async function deployModule(
|
|
|
368
380
|
closeDeployWindows(db, moduleId, new Date());
|
|
369
381
|
|
|
370
382
|
if (result.success) {
|
|
383
|
+
// D4 (control-plane-stops-building-modules): the generated project is
|
|
384
|
+
// ephemeral. It was rendered for THIS deploy from the signed payload plus
|
|
385
|
+
// live config; a persisted copy is a tree nothing can validate, it holds
|
|
386
|
+
// rendered secrets, and it is what a later deploy would ship. Now that
|
|
387
|
+
// the deploy that needed it is done — ansible, hooks, DNS backfill and
|
|
388
|
+
// aspects all ran inside the impl — it goes. Kept on failure (the impl's
|
|
389
|
+
// early returns) and under --keep.
|
|
390
|
+
if (!options.keepGeneratedProject) {
|
|
391
|
+
const deployed = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
392
|
+
if (deployed) {
|
|
393
|
+
const generatedPath = `${deployed.sourcePath}/generated`;
|
|
394
|
+
try {
|
|
395
|
+
rmSync(generatedPath, { recursive: true, force: true });
|
|
396
|
+
log.info(`Removed generated/ for '${moduleId}' (ephemeral by design; --keep retains it)`);
|
|
397
|
+
} catch (cleanupError) {
|
|
398
|
+
const msg = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
|
399
|
+
log.warn(`Failed to remove generated/ for '${moduleId}': ${msg}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
371
403
|
// A module that declares a suggested interval starts being watched on its
|
|
372
404
|
// first successful deploy — watching is the default, not a thing to
|
|
373
405
|
// remember.
|
|
@@ -792,26 +824,10 @@ async function deployModuleImpl(
|
|
|
792
824
|
|
|
793
825
|
log.success('Templates generated');
|
|
794
826
|
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
// into a refusal at the point of the fault. A verbatim role asset that
|
|
800
|
-
// failed to refresh is invisible downstream: `cp` reports no error, Ansible
|
|
801
|
-
// copies the stale bytes and reports `ok`, unchanged, and the module's
|
|
802
|
-
// version advances past code that never shipped. Local, milliseconds, no
|
|
803
|
-
// SSH. It deliberately does NOT verify after the deploy — Ansible's
|
|
804
|
-
// `changed` is truthful about what Ansible did, and the lie is upstream of
|
|
805
|
-
// Ansible.
|
|
806
|
-
const { refuseIfGeneratedIsStale } = await import('../module/packaging/generated-plane');
|
|
807
|
-
const staleGenerated = await refuseIfGeneratedIsStale(
|
|
808
|
-
moduleId,
|
|
809
|
-
module.sourcePath,
|
|
810
|
-
generatedPath,
|
|
811
|
-
);
|
|
812
|
-
if (staleGenerated) {
|
|
813
|
-
return { success: false, error: staleGenerated, phases };
|
|
814
|
-
}
|
|
827
|
+
// The generated-tree staleness pre-flight (D6 of module-integrity-rigor)
|
|
828
|
+
// retired here (D5 of control-plane-stops-building-modules): the tree is
|
|
829
|
+
// rendered for THIS deploy and deleted when it succeeds, so nothing
|
|
830
|
+
// persists that could be stale relative to what ships.
|
|
815
831
|
|
|
816
832
|
// Run validate_config hook if defined (e.g., credential validation via Playwright)
|
|
817
833
|
if (manifest.hooks?.validate_config) {
|
|
@@ -1032,8 +1048,13 @@ async function deployModuleImpl(
|
|
|
1032
1048
|
}
|
|
1033
1049
|
}
|
|
1034
1050
|
|
|
1035
|
-
// Transition to INSTALLED
|
|
1036
|
-
|
|
1051
|
+
// Transition to INSTALLED. A successful deploy ends any recorded
|
|
1052
|
+
// failure: leaving a stale errorMessage would print `Error:` beside a
|
|
1053
|
+
// healthy state in `module list` / `module status` (celilo#1363).
|
|
1054
|
+
db.update(modules)
|
|
1055
|
+
.set({ state: 'INSTALLED', errorMessage: null })
|
|
1056
|
+
.where(eq(modules.id, moduleId))
|
|
1057
|
+
.run();
|
|
1037
1058
|
|
|
1038
1059
|
// Run health checks for config-only modules too
|
|
1039
1060
|
if (manifest.hooks?.health_check) {
|
|
@@ -1700,8 +1721,11 @@ async function deployModuleImpl(
|
|
|
1700
1721
|
}
|
|
1701
1722
|
}
|
|
1702
1723
|
|
|
1703
|
-
// Transition to INSTALLED
|
|
1704
|
-
db.update(modules)
|
|
1724
|
+
// Transition to INSTALLED; see the first transition for errorMessage (celilo#1363).
|
|
1725
|
+
db.update(modules)
|
|
1726
|
+
.set({ state: 'INSTALLED', errorMessage: null })
|
|
1727
|
+
.where(eq(modules.id, moduleId))
|
|
1728
|
+
.run();
|
|
1705
1729
|
|
|
1706
1730
|
// Run health checks after successful deployment
|
|
1707
1731
|
if (manifest.hooks?.health_check) {
|
|
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { closeDb } from '../db/client';
|
|
7
7
|
import { runMigrations } from '../db/migrate';
|
|
8
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
8
9
|
import {
|
|
9
10
|
InFlightError,
|
|
10
11
|
OPERATIONS_SWEEP_PATTERN,
|
|
@@ -30,7 +31,7 @@ describe('module-operations', () => {
|
|
|
30
31
|
|
|
31
32
|
afterEach(() => {
|
|
32
33
|
closeDb();
|
|
33
|
-
|
|
34
|
+
resetTestDbPath();
|
|
34
35
|
try {
|
|
35
36
|
rmSync(dir, { recursive: true, force: true });
|
|
36
37
|
} catch {
|
|
@@ -10,6 +10,7 @@ import { ModuleManifestSchema } from '../manifest/schema';
|
|
|
10
10
|
import { ModuleSubscriptionSchema } from '../manifest/schema';
|
|
11
11
|
import type { ModuleManifest } from '../manifest/schema';
|
|
12
12
|
import { setupTestDatabaseAt as migrateDbFile } from '../test-utils/database';
|
|
13
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
13
14
|
import {
|
|
14
15
|
registerModuleSubscriptions,
|
|
15
16
|
resolveSubscription,
|
|
@@ -154,7 +155,7 @@ describe('register / unregister roundtrip', () => {
|
|
|
154
155
|
process.env.EVENT_BUS_DB = dbPath;
|
|
155
156
|
});
|
|
156
157
|
afterEach(() => {
|
|
157
|
-
process.env.EVENT_BUS_DB
|
|
158
|
+
delete process.env.EVENT_BUS_DB;
|
|
158
159
|
try {
|
|
159
160
|
rmSync(dir, { recursive: true, force: true });
|
|
160
161
|
} catch {
|
|
@@ -341,8 +342,8 @@ describe('resyncAllSubscriptions (ISS-0088)', () => {
|
|
|
341
342
|
});
|
|
342
343
|
afterEach(() => {
|
|
343
344
|
closeDb();
|
|
344
|
-
|
|
345
|
-
process.env.EVENT_BUS_DB
|
|
345
|
+
resetTestDbPath();
|
|
346
|
+
delete process.env.EVENT_BUS_DB;
|
|
346
347
|
try {
|
|
347
348
|
rmSync(dir, { recursive: true, force: true });
|
|
348
349
|
} catch {
|