@celilo/cli 2.2.0 → 2.2.1
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/package.json +2 -2
- package/schemas/system_config.json +5 -0
- package/src/ansible/inventory.test.ts +2 -1
- package/src/api/sessions.test.ts +2 -1
- package/src/cli/backup-rename.test.ts +2 -1
- package/src/cli/cli.test.ts +2 -1
- package/src/cli/commands/console-get-chain.test.ts +2 -1
- package/src/cli/commands/firewall-interface-list.test.ts +2 -1
- 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-import-aspect.test.ts +2 -1
- package/src/cli/commands/module-import-registry.test.ts +2 -1
- package/src/cli/commands/module-operations.test.ts +2 -1
- package/src/cli/commands/module-publish.test.ts +5 -12
- package/src/cli/commands/module-update.test.ts +6 -5
- package/src/cli/commands/module-upgrade.test.ts +15 -0
- package/src/cli/commands/module-upgrade.ts +54 -2
- package/src/cli/commands/module-verify.test.ts +2 -3
- package/src/cli/commands/module-verify.ts +0 -1
- package/src/cli/commands/notify-config.test.ts +5 -3
- package/src/cli/commands/registry-owner.test.ts +2 -1
- package/src/cli/commands/registry-token.test.ts +2 -1
- package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
- 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.test.ts +71 -0
- package/src/cli/commands/system-doctor.ts +110 -24
- package/src/cli/commands/system-init-deprecation.test.ts +6 -3
- package/src/cli/commands/system-migrate.test.ts +2 -1
- package/src/cli/index.ts +2 -1
- package/src/cli/restore-command.test.ts +2 -1
- package/src/cli/restore-migration-failure.test.ts +2 -1
- package/src/config/paths.test.ts +3 -3
- package/src/db/migrate.test.ts +2 -1
- package/src/db/migrate.ts +16 -16
- package/src/hooks/capability-loader.test.ts +78 -0
- package/src/hooks/capability-loader.ts +37 -2
- package/src/hooks/executor.test.ts +87 -1
- package/src/hooks/executor.ts +57 -21
- package/src/hooks/hook-store.test.ts +6 -0
- package/src/hooks/hook-trespass.test.ts +17 -7
- package/src/hooks/jail.test.ts +61 -2
- package/src/hooks/jail.ts +53 -1
- 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 +0 -40
- package/src/policy/no-swallowed-refusal.test.ts +9 -10
- 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/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/module-integrity.test.ts +0 -21
- package/src/services/audit/module-integrity.ts +0 -14
- package/src/services/backup-age-agreement.test.ts +2 -1
- package/src/services/backup-envelope-roundtrip.test.ts +2 -1
- package/src/services/backup-in-flight-refusal.test.ts +2 -1
- package/src/services/bus-ensure-flow.test.ts +2 -1
- package/src/services/bus-interview-park.test.ts +2 -1
- package/src/services/bus-secret-flow.test.ts +2 -1
- package/src/services/capability-table-rows.test.ts +2 -1
- package/src/services/consumer-cleanup.test.ts +3 -2
- package/src/services/container-service.test.ts +2 -1
- package/src/services/control-plane-bootstrap.test.ts +2 -1
- package/src/services/cross-module-read.test.ts +2 -1
- package/src/services/deploy-validation.test.ts +3 -2
- 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 +3 -2
- package/src/services/fleet-key.test.ts +3 -2
- package/src/services/infrastructure-selector.test.ts +2 -1
- package/src/services/infrastructure-variable-resolver.test.ts +2 -1
- package/src/services/machine-pool.test.ts +2 -1
- 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 +36 -20
- package/src/services/module-operations.test.ts +2 -1
- package/src/services/module-subscriptions.test.ts +2 -1
- 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/restore-from-file.test.ts +3 -2
- package/src/services/restore-preflight.test.ts +2 -1
- package/src/services/secret-schema-loader.test.ts +2 -1
- package/src/services/ssh-key-manager.test.ts +2 -1
- 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 +2 -1
- package/src/services/trusted-sources.test.ts +3 -2
- package/src/services/zone-detector.test.ts +2 -1
- package/src/test-utils/bus-responder.ts +5 -3
- 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
|
@@ -22,10 +22,10 @@ import { join } from 'node:path';
|
|
|
22
22
|
import { closeDb, getDb } from '../../db/client';
|
|
23
23
|
import { createPerson } from '../../services/alerting/people';
|
|
24
24
|
import { setupTestDatabaseAt } from '../../test-utils/database';
|
|
25
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
25
26
|
import { handlePerson } from './notify-config';
|
|
26
27
|
|
|
27
28
|
let dir: string;
|
|
28
|
-
const previousDbPath = process.env.CELILO_DB_PATH;
|
|
29
29
|
|
|
30
30
|
beforeEach(async () => {
|
|
31
31
|
dir = mkdtempSync(join(tmpdir(), 'celilo-person-'));
|
|
@@ -39,8 +39,10 @@ beforeEach(async () => {
|
|
|
39
39
|
|
|
40
40
|
afterEach(() => {
|
|
41
41
|
closeDb();
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// Reset to the scratch path, never restore: the saved value may be another
|
|
43
|
+
// suite's temp database or unset — which sends the next var-less reader to
|
|
44
|
+
// the operator's real celilo.db (celilo#1315).
|
|
45
|
+
resetTestDbPath();
|
|
44
46
|
rmSync(dir, { recursive: true, force: true });
|
|
45
47
|
});
|
|
46
48
|
|
|
@@ -11,6 +11,7 @@ import { tmpdir } from 'node:os';
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { closeDb, getDb } from '../../db/client';
|
|
13
13
|
import { modules } from '../../db/schema';
|
|
14
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
14
15
|
import {
|
|
15
16
|
handleRegistryOwnerList,
|
|
16
17
|
handleRegistryOwnerSet,
|
|
@@ -45,7 +46,7 @@ describe('registry owner commands', () => {
|
|
|
45
46
|
fetchSpy.mockRestore();
|
|
46
47
|
closeDb();
|
|
47
48
|
process.env.CELILO_DATA_DIR = undefined;
|
|
48
|
-
|
|
49
|
+
resetTestDbPath();
|
|
49
50
|
if (existsSync(dataDir)) await rm(dataDir, { recursive: true, force: true });
|
|
50
51
|
});
|
|
51
52
|
|
|
@@ -12,6 +12,7 @@ import { join } from 'node:path';
|
|
|
12
12
|
import { closeDb, getDb } from '../../db/client';
|
|
13
13
|
import { modules } from '../../db/schema';
|
|
14
14
|
import { CrossModuleDataManager } from '../../services/cross-module-data-manager';
|
|
15
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
15
16
|
import { handleRegistryTokenAdd, handleRegistryTokenRm } from './registry-token';
|
|
16
17
|
|
|
17
18
|
/** Read back the stored publish_tokens list via the production read path. */
|
|
@@ -50,7 +51,7 @@ describe('registry token add/rm (F2 clobber fix)', () => {
|
|
|
50
51
|
afterEach(async () => {
|
|
51
52
|
closeDb();
|
|
52
53
|
process.env.CELILO_DATA_DIR = undefined;
|
|
53
|
-
|
|
54
|
+
resetTestDbPath();
|
|
54
55
|
if (existsSync(dataDir)) {
|
|
55
56
|
await rm(dataDir, { recursive: true, force: true });
|
|
56
57
|
}
|
|
@@ -30,24 +30,22 @@ import { tmpdir } from 'node:os';
|
|
|
30
30
|
import { join } from 'node:path';
|
|
31
31
|
import { getDb } from '../../db/client';
|
|
32
32
|
import { loadExistingConfiguration } from '../../services/system-init';
|
|
33
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
33
34
|
import { handleSystemApplyConfig } from './system-apply-config';
|
|
34
35
|
import { handleSystemInit } from './system-init';
|
|
35
36
|
|
|
36
37
|
describe('system apply-config equivalence with system init --accept-defaults', () => {
|
|
37
38
|
let tmpDir: string;
|
|
38
|
-
let savedDbPath: string | undefined;
|
|
39
39
|
|
|
40
40
|
beforeEach(() => {
|
|
41
41
|
tmpDir = mkdtempSync(join(tmpdir(), 'celilo-apply-config-test-'));
|
|
42
|
-
savedDbPath = process.env.CELILO_DB_PATH;
|
|
43
42
|
});
|
|
44
43
|
|
|
45
44
|
afterEach(() => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
45
|
+
// Reset to the scratch path, never restore: the saved value may be
|
|
46
|
+
// another suite's temp database, or unset — which sends the next
|
|
47
|
+
// var-less reader to the operator's real celilo.db (celilo#1315).
|
|
48
|
+
resetTestDbPath();
|
|
51
49
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
52
50
|
});
|
|
53
51
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the set-time interview gate on `system config set`
|
|
3
|
+
* (slice 3 of hook-jail-config-surface, design D5 + peba's --force addition).
|
|
4
|
+
*
|
|
5
|
+
* Writing `hooks.jail_policy off` is a fleet-wide security posture change, so
|
|
6
|
+
* the write asks first over the bus interview and only proceeds on an explicit
|
|
7
|
+
* confirm. `auto` and `required` write directly. `--force` skips the ask per
|
|
8
|
+
* the house flags.force convention.
|
|
9
|
+
*
|
|
10
|
+
* Isolation: CELILO_DB_PATH / CELILO_DATA_DIR / EVENT_BUS_DB are set before the
|
|
11
|
+
* SUT is imported, so nothing touches the production database or event bus.
|
|
12
|
+
* bun test runs non-TTY, so `withInterviewSession` starts no terminal responder
|
|
13
|
+
* and the interview guard probes the isolated bus — a successful direct write
|
|
14
|
+
* with no responder running is itself proof the command never asked.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
18
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { eq } from 'drizzle-orm';
|
|
22
|
+
|
|
23
|
+
const testRoot = mkdtempSync(join(tmpdir(), 'celilo-jailgate-'));
|
|
24
|
+
const busDbPath = join(testRoot, 'bus.db');
|
|
25
|
+
process.env.CELILO_DB_PATH = join(testRoot, 'celilo.db');
|
|
26
|
+
process.env.CELILO_DATA_DIR = join(testRoot, 'data');
|
|
27
|
+
process.env.EVENT_BUS_DB = busDbPath;
|
|
28
|
+
|
|
29
|
+
const { getDb, closeDb } = await import('../../db/client');
|
|
30
|
+
const { systemConfig } = await import('../../db/schema');
|
|
31
|
+
const { handleSystemConfigSet } = await import('./system-config');
|
|
32
|
+
const { startProgrammaticResponder } = await import('../../services/programmatic-responder');
|
|
33
|
+
|
|
34
|
+
const KEY = 'hooks.jail_policy';
|
|
35
|
+
const CONFIRM_KEY = 'hooks-jail-policy.set-off';
|
|
36
|
+
|
|
37
|
+
function storedJailPolicy(): string | undefined {
|
|
38
|
+
return getDb().select().from(systemConfig).where(eq(systemConfig.key, KEY)).get()?.value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
afterAll(() => {
|
|
42
|
+
closeDb();
|
|
43
|
+
rmSync(testRoot, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('system config set — jail_policy value validation', () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
getDb().delete(systemConfig).where(eq(systemConfig.key, KEY)).run();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a value outside the enum is refused and names the allowed values', async () => {
|
|
52
|
+
const result = await handleSystemConfigSet([KEY, 'alwayssafe']);
|
|
53
|
+
|
|
54
|
+
expect(result.success).toBe(false);
|
|
55
|
+
if (result.success) throw new Error('expected a refusal');
|
|
56
|
+
expect(result.error).toContain('auto');
|
|
57
|
+
expect(result.error).toContain('required');
|
|
58
|
+
expect(storedJailPolicy()).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('system config set — jail_policy values that never ask', () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
getDb().delete(systemConfig).where(eq(systemConfig.key, KEY)).run();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('auto writes directly with no responder listening', async () => {
|
|
68
|
+
const result = await handleSystemConfigSet([KEY, 'auto']);
|
|
69
|
+
|
|
70
|
+
expect(result.success).toBe(true);
|
|
71
|
+
expect(storedJailPolicy()).toBe('auto');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('required writes directly with no responder listening', async () => {
|
|
75
|
+
const result = await handleSystemConfigSet([KEY, 'required']);
|
|
76
|
+
|
|
77
|
+
expect(result.success).toBe(true);
|
|
78
|
+
expect(storedJailPolicy()).toBe('required');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('system config set — the off interview gate', () => {
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
getDb().delete(systemConfig).where(eq(systemConfig.key, KEY)).run();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('off fails fast with no responder listening and writes nothing', async () => {
|
|
88
|
+
let threw: unknown;
|
|
89
|
+
try {
|
|
90
|
+
await handleSystemConfigSet([KEY, 'off']);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
threw = error;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
expect(threw).toBeInstanceOf(Error);
|
|
96
|
+
expect(String((threw as Error).message)).toContain('No responder is listening');
|
|
97
|
+
expect(storedJailPolicy()).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('off writes after a confirming reply over non-TTY, and the question was asked', async () => {
|
|
101
|
+
const responder = startProgrammaticResponder({
|
|
102
|
+
busDbPath,
|
|
103
|
+
db: getDb(),
|
|
104
|
+
onMissing: 'skip',
|
|
105
|
+
values: { interview: { [CONFIRM_KEY]: true } },
|
|
106
|
+
});
|
|
107
|
+
try {
|
|
108
|
+
const result = await handleSystemConfigSet([KEY, 'off']);
|
|
109
|
+
|
|
110
|
+
expect(result.success).toBe(true);
|
|
111
|
+
expect(storedJailPolicy()).toBe('off');
|
|
112
|
+
|
|
113
|
+
const asked = responder.seenInterviewPayloads();
|
|
114
|
+
expect(asked.length).toBe(1);
|
|
115
|
+
expect(asked[0].scope).toBe('hooks-jail-policy');
|
|
116
|
+
expect(asked[0].key).toBe('set-off');
|
|
117
|
+
expect(asked[0].message).toContain('unjailed');
|
|
118
|
+
} finally {
|
|
119
|
+
responder.close();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('off is cancelled when the reply declines, and writes nothing', async () => {
|
|
124
|
+
const responder = startProgrammaticResponder({
|
|
125
|
+
busDbPath,
|
|
126
|
+
db: getDb(),
|
|
127
|
+
onMissing: 'skip',
|
|
128
|
+
values: { interview: { [CONFIRM_KEY]: false } },
|
|
129
|
+
});
|
|
130
|
+
try {
|
|
131
|
+
const result = await handleSystemConfigSet([KEY, 'off']);
|
|
132
|
+
|
|
133
|
+
expect(result.success).toBe(false);
|
|
134
|
+
if (result.success) throw new Error('expected cancellation');
|
|
135
|
+
expect(result.error).toBe('Cancelled by user');
|
|
136
|
+
expect(storedJailPolicy()).toBeUndefined();
|
|
137
|
+
} finally {
|
|
138
|
+
responder.close();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('--force skips the ask and writes off with no responder listening', async () => {
|
|
143
|
+
const result = await handleSystemConfigSet([KEY, 'off'], { force: true });
|
|
144
|
+
|
|
145
|
+
expect(result.success).toBe(true);
|
|
146
|
+
expect(storedJailPolicy()).toBe('off');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -7,6 +7,7 @@ import { dirname, join } from 'node:path';
|
|
|
7
7
|
import { eq } from 'drizzle-orm';
|
|
8
8
|
import { getDb } from '../../db/client';
|
|
9
9
|
import { systemConfig } from '../../db/schema';
|
|
10
|
+
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
10
11
|
import type { SystemConfigSchema } from '../../services/system-config-schema-types';
|
|
11
12
|
import { validateKey, validateValue } from '../../services/system-config-validator';
|
|
12
13
|
import { getArg, validateRequiredArgs } from '../parser';
|
|
@@ -47,7 +48,10 @@ function loadSchema(): SystemConfigSchema {
|
|
|
47
48
|
* @param args - Command arguments
|
|
48
49
|
* @returns Command result
|
|
49
50
|
*/
|
|
50
|
-
export function handleSystemConfigSet(
|
|
51
|
+
export async function handleSystemConfigSet(
|
|
52
|
+
args: string[],
|
|
53
|
+
flags: Record<string, boolean | string> = {},
|
|
54
|
+
): Promise<CommandResult> {
|
|
51
55
|
// Validate arguments
|
|
52
56
|
const error = validateRequiredArgs(args, 2);
|
|
53
57
|
if (error) {
|
|
@@ -82,6 +86,27 @@ export function handleSystemConfigSet(args: string[]): CommandResult {
|
|
|
82
86
|
return { success: false, error: valueValidation.error };
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
// D5 of hook-jail-config-surface: turning jailing off fleet-wide is a security
|
|
90
|
+
// posture change, so the write asks first. `auto` and `required` write
|
|
91
|
+
// directly — `auto` is the status quo default and `required` tightens the
|
|
92
|
+
// posture. `--force` skips the ask, matching the house flags.force convention
|
|
93
|
+
// (backup-delete, machine-remove, service-remove, ...).
|
|
94
|
+
if (key === 'hooks.jail_policy' && value === 'off' && !flags.force) {
|
|
95
|
+
const confirmed = await withInterviewSession(() =>
|
|
96
|
+
askConfirm({
|
|
97
|
+
scope: 'hooks-jail-policy',
|
|
98
|
+
key: 'set-off',
|
|
99
|
+
message:
|
|
100
|
+
'Set hooks.jail_policy to off? Hooks will run unjailed fleet-wide and the doctor will warn.',
|
|
101
|
+
defaultValue: false,
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (!confirmed) {
|
|
106
|
+
return { success: false, error: 'Cancelled by user' };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
85
110
|
const db = getDb();
|
|
86
111
|
|
|
87
112
|
// Store value in database
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import type { JailModeRecord } from '../../hooks/jail';
|
|
3
3
|
import {
|
|
4
4
|
type HookExecutionInput,
|
|
5
|
+
assembleHookExecutionSection,
|
|
5
6
|
compareVersions,
|
|
6
7
|
renderHookExecutionSection,
|
|
7
8
|
} from './system-doctor';
|
|
@@ -61,6 +62,9 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
|
|
|
61
62
|
renderHookExecutionSection({
|
|
62
63
|
availability: { backend: 'bubblewrap' },
|
|
63
64
|
policy: 'auto',
|
|
65
|
+
source: 'default',
|
|
66
|
+
envValue: undefined,
|
|
67
|
+
configValue: undefined,
|
|
64
68
|
record: jailedRecord,
|
|
65
69
|
host: HOST,
|
|
66
70
|
monitored: true,
|
|
@@ -153,4 +157,71 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
|
|
|
153
157
|
expect(text(r)).toContain('belongs to host "old-box"');
|
|
154
158
|
expect(r.failCount).toBe(0);
|
|
155
159
|
});
|
|
160
|
+
|
|
161
|
+
test('the source line names the environment when the env var won (D4)', () => {
|
|
162
|
+
const r = render({
|
|
163
|
+
policy: 'off',
|
|
164
|
+
source: 'env',
|
|
165
|
+
envValue: 'off',
|
|
166
|
+
record: undefined,
|
|
167
|
+
});
|
|
168
|
+
expect(text(r)).toContain('policy from CELILO_HOOK_JAIL=off (environment override)');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('the source line names system config when the stored key won (D4)', () => {
|
|
172
|
+
const r = render({
|
|
173
|
+
policy: 'required',
|
|
174
|
+
source: 'config',
|
|
175
|
+
configValue: 'required',
|
|
176
|
+
record: undefined,
|
|
177
|
+
});
|
|
178
|
+
expect(text(r)).toContain('policy from system config (hooks.jail_policy)');
|
|
179
|
+
expect(text(r)).not.toContain('policy from CELILO_HOOK_JAIL');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('the source line names the default and how to change it (D4)', () => {
|
|
183
|
+
const r = render({ policy: 'auto', source: 'default', record: undefined });
|
|
184
|
+
expect(text(r)).toContain(
|
|
185
|
+
'policy default (auto) — set hooks.jail_policy or CELILO_HOOK_JAIL to change',
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('env and config disagreeing renders the losing value (D4)', () => {
|
|
190
|
+
const r = render({
|
|
191
|
+
policy: 'required',
|
|
192
|
+
source: 'env',
|
|
193
|
+
envValue: 'required',
|
|
194
|
+
configValue: 'off',
|
|
195
|
+
record: undefined,
|
|
196
|
+
});
|
|
197
|
+
expect(text(r)).toContain(
|
|
198
|
+
"system config holds 'off' but CELILO_HOOK_JAIL='required' overrides it",
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('env and config agreeing renders no disagreement line (D4)', () => {
|
|
203
|
+
const r = render({
|
|
204
|
+
policy: 'off',
|
|
205
|
+
source: 'env',
|
|
206
|
+
envValue: 'off',
|
|
207
|
+
configValue: 'off',
|
|
208
|
+
record: undefined,
|
|
209
|
+
});
|
|
210
|
+
expect(text(r)).not.toContain('system config holds');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('a bad stored row renders as a failing check naming the row, not a crash', () => {
|
|
214
|
+
const r = assembleHookExecutionSection({
|
|
215
|
+
envValue: () => undefined,
|
|
216
|
+
configValue: () => 'alwayssafe',
|
|
217
|
+
availability: () => ({ backend: 'bubblewrap' }),
|
|
218
|
+
record: () => undefined,
|
|
219
|
+
host: () => HOST,
|
|
220
|
+
monitored: () => true,
|
|
221
|
+
});
|
|
222
|
+
expect(r.lines[0]).toBe('Hook execution');
|
|
223
|
+
expect(r.lines[1]).toContain("hooks.jail_policy='alwayssafe' is not a hook jail policy");
|
|
224
|
+
expect(r.failCount).toBe(1);
|
|
225
|
+
expect(r.warnCount).toBe(0);
|
|
226
|
+
});
|
|
156
227
|
});
|
|
@@ -44,11 +44,12 @@ import {
|
|
|
44
44
|
type JailAvailability,
|
|
45
45
|
type JailModeRecord,
|
|
46
46
|
type JailPolicy,
|
|
47
|
+
type JailPolicySource,
|
|
47
48
|
SANDBOX_EXEC_AUTO_DEFERRED_REASON,
|
|
48
49
|
autoJailDefers,
|
|
49
50
|
detectJailBackend,
|
|
50
|
-
jailPolicy,
|
|
51
51
|
readJailMode,
|
|
52
|
+
resolveJailPolicy,
|
|
52
53
|
} from '../../hooks/jail';
|
|
53
54
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
54
55
|
import { HOOK_JAIL_CHECK } from '../../services/alerting/hook-jail';
|
|
@@ -353,6 +354,12 @@ function provisionedBrowserFlavor(): string | null {
|
|
|
353
354
|
export interface HookExecutionInput {
|
|
354
355
|
availability: JailAvailability;
|
|
355
356
|
policy: JailPolicy;
|
|
357
|
+
/** Which source decided `policy` (D4 of hook-jail-config-surface). */
|
|
358
|
+
source: JailPolicySource;
|
|
359
|
+
/** Raw `CELILO_HOOK_JAIL` value, when set; rendered on the source line. */
|
|
360
|
+
envValue: string | undefined;
|
|
361
|
+
/** Raw stored `hooks.jail_policy` value, when present; rendered on the disagreement line. */
|
|
362
|
+
configValue: string | undefined;
|
|
356
363
|
/** What the last real module-hook run recorded, if any. */
|
|
357
364
|
record: JailModeRecord | undefined;
|
|
358
365
|
host: string;
|
|
@@ -390,6 +397,31 @@ export function renderHookExecutionSection(input: HookExecutionInput): {
|
|
|
390
397
|
let failCount = 0;
|
|
391
398
|
let warnCount = 0;
|
|
392
399
|
|
|
400
|
+
// Which source decided the policy (D4 of hook-jail-config-surface). When
|
|
401
|
+
// env and config both hold values and disagree, name the loser too: a
|
|
402
|
+
// control whose two sources disagree and whose rendering hides one of them
|
|
403
|
+
// is the exact failure this change fixes, one level down.
|
|
404
|
+
if (input.source === 'env' && input.envValue !== undefined) {
|
|
405
|
+
lines.push(
|
|
406
|
+
` ${ANSI.dim}policy from CELILO_HOOK_JAIL=${input.envValue} (environment override)${ANSI.reset}`,
|
|
407
|
+
);
|
|
408
|
+
} else if (input.source === 'config') {
|
|
409
|
+
lines.push(` ${ANSI.dim}policy from system config (hooks.jail_policy)${ANSI.reset}`);
|
|
410
|
+
} else {
|
|
411
|
+
lines.push(
|
|
412
|
+
` ${ANSI.dim}policy default (auto) — set hooks.jail_policy or CELILO_HOOK_JAIL to change${ANSI.reset}`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
if (
|
|
416
|
+
input.source === 'env' &&
|
|
417
|
+
input.configValue !== undefined &&
|
|
418
|
+
input.envValue !== input.configValue
|
|
419
|
+
) {
|
|
420
|
+
lines.push(
|
|
421
|
+
` ${ANSI.dim}system config holds '${input.configValue}' but CELILO_HOOK_JAIL='${input.envValue}' overrides it${ANSI.reset}`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
393
425
|
// ce-29z: auto defers on sandbox-exec, so the doctor must not report a jail
|
|
394
426
|
// the executor will not build. One predicate, two consumers.
|
|
395
427
|
const autoDeferred = policy === 'auto' && autoJailDefers(availability);
|
|
@@ -456,6 +488,71 @@ export function renderHookExecutionSection(input: HookExecutionInput): {
|
|
|
456
488
|
return { lines, failCount, warnCount };
|
|
457
489
|
}
|
|
458
490
|
|
|
491
|
+
/** Where `assembleHookExecutionSection` gets what the machine says. Every
|
|
492
|
+
* reader is a thunk so any of their throws (a bad stored row, a corrupt
|
|
493
|
+
* record) land in the one catch that renders this section. */
|
|
494
|
+
export interface HookExecutionIO {
|
|
495
|
+
envValue: () => string | undefined;
|
|
496
|
+
configValue: () => string | undefined;
|
|
497
|
+
availability: () => JailAvailability;
|
|
498
|
+
record: () => JailModeRecord | undefined;
|
|
499
|
+
host: () => string;
|
|
500
|
+
monitored: () => boolean | null;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Resolve the effective jail policy from its two sources and render the
|
|
505
|
+
* Hook execution section (hook-jail-config-surface D2/D4).
|
|
506
|
+
*
|
|
507
|
+
* The single catch is the section's one error path: a typo'd env value or a
|
|
508
|
+
* bad stored row renders as a failing check rather than crashing the doctor,
|
|
509
|
+
* because the same throw is about to fail every hook invocation too.
|
|
510
|
+
*/
|
|
511
|
+
export function assembleHookExecutionSection(io: HookExecutionIO): {
|
|
512
|
+
lines: string[];
|
|
513
|
+
failCount: number;
|
|
514
|
+
warnCount: number;
|
|
515
|
+
} {
|
|
516
|
+
try {
|
|
517
|
+
const envValue = io.envValue();
|
|
518
|
+
const configValue = io.configValue();
|
|
519
|
+
const resolved = resolveJailPolicy(envValue, configValue);
|
|
520
|
+
return renderHookExecutionSection({
|
|
521
|
+
availability: io.availability(),
|
|
522
|
+
policy: resolved.policy,
|
|
523
|
+
source: resolved.source,
|
|
524
|
+
envValue,
|
|
525
|
+
configValue,
|
|
526
|
+
record: io.record(),
|
|
527
|
+
host: io.host(),
|
|
528
|
+
monitored: io.monitored(),
|
|
529
|
+
});
|
|
530
|
+
} catch (error) {
|
|
531
|
+
return {
|
|
532
|
+
lines: [
|
|
533
|
+
'Hook execution',
|
|
534
|
+
` ${ANSI.red}✗${ANSI.reset} ${error instanceof Error ? error.message : String(error)}`,
|
|
535
|
+
],
|
|
536
|
+
failCount: 1,
|
|
537
|
+
warnCount: 0,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* The stored `hooks.jail_policy` value, or undefined when there is no row or
|
|
544
|
+
* no celilo DB to ask (a dev box). Same row-read pattern as
|
|
545
|
+
* `ipam/auto-allocator.ts`; the drizzle table is `systemConfig` in
|
|
546
|
+
* `db/schema.ts`.
|
|
547
|
+
*/
|
|
548
|
+
function readStoredJailPolicy(): string | undefined {
|
|
549
|
+
if (!existsSync(getDbPath())) return undefined;
|
|
550
|
+
const row = getDb()
|
|
551
|
+
.$client.prepare('SELECT value FROM system_config WHERE key = ?')
|
|
552
|
+
.get('hooks.jail_policy') as { value: string } | undefined;
|
|
553
|
+
return row?.value;
|
|
554
|
+
}
|
|
555
|
+
|
|
459
556
|
/**
|
|
460
557
|
* mtime (ms) of the installed dispatcher code (`@celilo/event-bus`
|
|
461
558
|
* package.json). The fleet dispatcher check compares this against the
|
|
@@ -736,30 +833,19 @@ export async function handleSystemDoctor(
|
|
|
736
833
|
lines.push(...prereqResult.lines);
|
|
737
834
|
lines.push('');
|
|
738
835
|
|
|
739
|
-
// Hook execution mode (hook-process-boundary task 4.6
|
|
740
|
-
// throws on a typo'd
|
|
836
|
+
// Hook execution mode (hook-process-boundary task 4.6; sources per D4 of
|
|
837
|
+
// hook-jail-config-surface). The policy read throws on a typo'd
|
|
838
|
+
// CELILO_HOOK_JAIL or a bad stored row — report that rather than crash the
|
|
741
839
|
// doctor, since the same throw is about to fail every hook invocation too.
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
: null,
|
|
752
|
-
});
|
|
753
|
-
} catch (error) {
|
|
754
|
-
hookExec = {
|
|
755
|
-
lines: [
|
|
756
|
-
'Hook execution',
|
|
757
|
-
` ${ANSI.red}✗${ANSI.reset} ${error instanceof Error ? error.message : String(error)}`,
|
|
758
|
-
],
|
|
759
|
-
failCount: 1,
|
|
760
|
-
warnCount: 0,
|
|
761
|
-
};
|
|
762
|
-
}
|
|
840
|
+
const hookExec = assembleHookExecutionSection({
|
|
841
|
+
envValue: () => process.env.CELILO_HOOK_JAIL,
|
|
842
|
+
configValue: () => readStoredJailPolicy(),
|
|
843
|
+
availability: () => detectJailBackend(),
|
|
844
|
+
record: () => readJailMode(),
|
|
845
|
+
host: () => hostname(),
|
|
846
|
+
monitored: () =>
|
|
847
|
+
existsSync(getDbPath()) ? findMonitorByTarget(getDb(), HOOK_JAIL_CHECK) !== undefined : null,
|
|
848
|
+
});
|
|
763
849
|
lines.push(...hookExec.lines);
|
|
764
850
|
lines.push('');
|
|
765
851
|
|
|
@@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
|
12
12
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
13
13
|
import { tmpdir } from 'node:os';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
15
16
|
import { handleSystemInit } from './system-init';
|
|
16
17
|
|
|
17
18
|
interface CapturedStream {
|
|
@@ -35,19 +36,21 @@ function captureStderr(): CapturedStream {
|
|
|
35
36
|
|
|
36
37
|
describe('celilo system init deprecation banner', () => {
|
|
37
38
|
let tmpDir: string;
|
|
38
|
-
let savedDbPath: string | undefined;
|
|
39
39
|
let savedSuppress: string | undefined;
|
|
40
40
|
|
|
41
41
|
beforeEach(() => {
|
|
42
42
|
tmpDir = mkdtempSync(join(tmpdir(), 'celilo-deprecation-test-'));
|
|
43
|
-
savedDbPath = process.env.CELILO_DB_PATH;
|
|
44
43
|
savedSuppress = process.env.CELILO_SUPPRESS_DEPRECATION;
|
|
45
44
|
process.env.CELILO_DB_PATH = join(tmpDir, 'init.db');
|
|
46
45
|
process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
|
|
47
46
|
});
|
|
48
47
|
|
|
49
48
|
afterEach(() => {
|
|
50
|
-
|
|
49
|
+
// Never restore the previous value: it may be another suite's temp
|
|
50
|
+
// database (or, worse, unset — which sends the next var-less reader to
|
|
51
|
+
// the operator's real celilo.db). The scratch path is the neutral state
|
|
52
|
+
// (celilo#1315).
|
|
53
|
+
resetTestDbPath();
|
|
51
54
|
process.env.CELILO_SUPPRESS_DEPRECATION = savedSuppress;
|
|
52
55
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
53
56
|
});
|
|
@@ -4,6 +4,7 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { closeDb, findMigrationsFolder } from '../../db/client';
|
|
7
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
7
8
|
import { handleSystemMigrate } from './system-migrate';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -32,7 +33,7 @@ describe('handleSystemMigrate', () => {
|
|
|
32
33
|
});
|
|
33
34
|
afterEach(() => {
|
|
34
35
|
closeDb();
|
|
35
|
-
|
|
36
|
+
resetTestDbPath();
|
|
36
37
|
try {
|
|
37
38
|
rmSync(dir, { recursive: true, force: true });
|
|
38
39
|
} catch {
|
package/src/cli/index.ts
CHANGED
|
@@ -631,6 +631,7 @@ Subcommands:
|
|
|
631
631
|
--preflight Run pre-flight validation only (no deployment)
|
|
632
632
|
--verbose Keep all sub-events visible (no collapse-on-success)
|
|
633
633
|
--stop-after-interview Exit after config+secrets interview (no infra/hooks)
|
|
634
|
+
--keep Retain the generated project after a successful deploy (it is deleted by design)
|
|
634
635
|
|
|
635
636
|
health [module-id] Run health checks (transitions to VERIFIED on success)
|
|
636
637
|
Options:
|
|
@@ -2238,7 +2239,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
2238
2239
|
}
|
|
2239
2240
|
const configArgs = parsed.args.slice(1);
|
|
2240
2241
|
if (configSubcommand === 'set') {
|
|
2241
|
-
return handleSystemConfigSet(configArgs);
|
|
2242
|
+
return await handleSystemConfigSet(configArgs, parsed.flags);
|
|
2242
2243
|
}
|
|
2243
2244
|
if (configSubcommand === 'get') {
|
|
2244
2245
|
return handleSystemConfigGet(configArgs);
|
|
@@ -16,6 +16,7 @@ import { join } from 'node:path';
|
|
|
16
16
|
import { closeDb, getDb } from '../db/client';
|
|
17
17
|
import { runMigrations } from '../db/migrate';
|
|
18
18
|
import { modules } from '../db/schema';
|
|
19
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
19
20
|
import { runCli } from './index';
|
|
20
21
|
|
|
21
22
|
describe('celilo restore', () => {
|
|
@@ -32,7 +33,7 @@ describe('celilo restore', () => {
|
|
|
32
33
|
|
|
33
34
|
afterEach(() => {
|
|
34
35
|
closeDb();
|
|
35
|
-
|
|
36
|
+
resetTestDbPath();
|
|
36
37
|
process.env.CELILO_DATA_DIR = undefined;
|
|
37
38
|
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
38
39
|
process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
|
|
@@ -28,6 +28,7 @@ import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
|
28
28
|
import { encryptFileToFile } from '../services/backup-cipher';
|
|
29
29
|
import { buildManifest } from '../services/backup-manifest';
|
|
30
30
|
import { migrateRestoredDb } from '../services/restore-from-file';
|
|
31
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
31
32
|
import { runCli } from './index';
|
|
32
33
|
|
|
33
34
|
const FIXTURES_DIR = join(import.meta.dir, '../hooks/test-fixtures');
|
|
@@ -97,7 +98,7 @@ describe('restore migration failure reporting (celilo#1269)', () => {
|
|
|
97
98
|
|
|
98
99
|
afterEach(() => {
|
|
99
100
|
closeDb();
|
|
100
|
-
|
|
101
|
+
resetTestDbPath();
|
|
101
102
|
process.env.CELILO_DATA_DIR = undefined;
|
|
102
103
|
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
103
104
|
try {
|