@celilo/cli 2.2.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +1 -0
- package/CELILO_SUBSYSTEMS.md +1 -1
- package/README.md +1 -1
- package/drizzle/0032_module_jail_policies.sql +28 -0
- package/drizzle/0033_build_bus_hook_runs.sql +37 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/__integration__/container-services-cli.integration.test.ts +1 -1
- package/src/api/sessions.test.ts +3 -3
- package/src/api-clients/proxmox.ts +10 -7
- package/src/cli/backup-rename.test.ts +3 -3
- package/src/cli/cli.test.ts +1 -1
- package/src/cli/commands/events.test.ts +2 -2
- package/src/cli/commands/firewall-interface-list.test.ts +2 -2
- package/src/cli/commands/machine-list.test.ts +57 -0
- package/src/cli/commands/machine-list.ts +35 -4
- package/src/cli/commands/module-health.ts +1 -0
- package/src/cli/commands/module-import-registry.test.ts +1 -1
- package/src/cli/commands/module-jail.test.ts +242 -0
- package/src/cli/commands/module-jail.ts +227 -0
- package/src/cli/commands/module-list-jail.test.ts +136 -0
- package/src/cli/commands/module-list.ts +30 -3
- package/src/cli/commands/module-publish.test.ts +6 -6
- package/src/cli/commands/module-update.test.ts +22 -7
- package/src/cli/commands/module-update.ts +4 -1
- package/src/cli/commands/module-upgrade.test.ts +115 -1
- package/src/cli/commands/module-upgrade.ts +23 -1
- package/src/cli/commands/module-verify.test.ts +1 -1
- package/src/cli/commands/publish/execute.ts +4 -1
- package/src/cli/commands/publish/index.ts +11 -1
- package/src/cli/commands/publish/module-registry.test.ts +24 -1
- package/src/cli/commands/publish/module-registry.ts +23 -4
- package/src/cli/commands/publish/plan.ts +1 -1
- package/src/cli/commands/publish/types.ts +7 -0
- package/src/cli/commands/registry-owner.test.ts +1 -1
- package/src/cli/commands/registry-token.test.ts +1 -1
- package/src/cli/commands/system-audit.ts +20 -6
- package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
- package/src/cli/commands/system-doctor.test.ts +76 -10
- package/src/cli/commands/system-doctor.ts +34 -3
- package/src/cli/commands/system-init-deprecation.test.ts +1 -1
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +8 -2
- package/src/cli/flag-surface-gate.test.ts +279 -0
- package/src/cli/index.ts +68 -2
- package/src/cli/parser.test.ts +37 -1
- package/src/cli/restore-command.test.ts +3 -3
- package/src/cli/restore-migration-failure.test.ts +2 -2
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/config/paths.test.ts +19 -19
- package/src/db/client.test.ts +46 -1
- package/src/db/client.ts +26 -0
- package/src/db/schema.ts +63 -0
- package/src/hooks/capability-loader-firewall.test.ts +9 -1
- package/src/hooks/capability-loader.ts +8 -0
- package/src/hooks/executor.test.ts +86 -12
- package/src/hooks/executor.ts +31 -1
- package/src/hooks/hook-jail-unreachability.test.ts +17 -18
- package/src/hooks/hook-trespass.test.ts +9 -2
- package/src/hooks/jail.test.ts +105 -17
- package/src/hooks/jail.ts +47 -10
- package/src/manifest/contracts/v1.ts +13 -0
- package/src/policy/module-script-scan.ts +52 -68
- package/src/policy/no-hand-built-ssh.test.ts +5 -1
- package/src/policy/no-swallowed-refusal.test.ts +14 -14
- package/src/registry/client.test.ts +2 -2
- package/src/secrets/storage.test.ts +1 -1
- package/src/services/alerting/keys.test.ts +4 -0
- package/src/services/alerting/keys.ts +12 -2
- package/src/services/audit/health.test.ts +97 -2
- package/src/services/audit/health.ts +64 -2
- package/src/services/audit/index.test.ts +22 -0
- package/src/services/audit/index.ts +8 -2
- package/src/services/audit/jail-exemptions.test.ts +42 -0
- package/src/services/audit/jail-exemptions.ts +44 -0
- package/src/services/audit/module-integrity.test.ts +23 -1
- package/src/services/audit/module-integrity.ts +7 -2
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-create.ts +9 -0
- package/src/services/backup-envelope-roundtrip.test.ts +1 -1
- package/src/services/backup-in-flight-refusal.test.ts +1 -1
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +48 -0
- package/src/services/build-bus/hook-dispatcher.ts +49 -1
- package/src/services/bus-ensure-flow.test.ts +1 -1
- package/src/services/bus-interview-park.test.ts +2 -2
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/celilo-events.test.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +23 -5
- package/src/services/container-service.test.ts +1 -1
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-preflight.ts +7 -0
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/fleet-checks.test.ts +123 -4
- package/src/services/fleet-checks.ts +176 -3
- package/src/services/health-runner.test.ts +87 -2
- package/src/services/health-runner.ts +40 -11
- package/src/services/infrastructure-selector.test.ts +1 -1
- package/src/services/jail-exemptions.test.ts +125 -0
- package/src/services/jail-exemptions.ts +81 -0
- package/src/services/machine-pool.test.ts +1 -1
- package/src/services/module-deploy.ts +12 -4
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/network-discovery.test.ts +64 -1
- package/src/services/network-discovery.ts +30 -5
- package/src/services/responder-probe.test.ts +1 -1
- package/src/services/restore-from-file.test.ts +4 -4
- package/src/services/restore-preflight.test.ts +1 -1
- package/src/services/ssh-key-manager.test.ts +2 -2
- package/src/services/system-state-stage.test.ts +2 -2
- package/src/services/update/orchestrator.test.ts +2 -0
- package/src/test-utils/bus-responder.ts +1 -1
|
@@ -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 } : {}) };
|
|
@@ -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
|
+
}
|
|
@@ -1048,8 +1048,13 @@ async function deployModuleImpl(
|
|
|
1048
1048
|
}
|
|
1049
1049
|
}
|
|
1050
1050
|
|
|
1051
|
-
// Transition to INSTALLED
|
|
1052
|
-
|
|
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();
|
|
1053
1058
|
|
|
1054
1059
|
// Run health checks for config-only modules too
|
|
1055
1060
|
if (manifest.hooks?.health_check) {
|
|
@@ -1716,8 +1721,11 @@ async function deployModuleImpl(
|
|
|
1716
1721
|
}
|
|
1717
1722
|
}
|
|
1718
1723
|
|
|
1719
|
-
// Transition to INSTALLED
|
|
1720
|
-
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();
|
|
1721
1729
|
|
|
1722
1730
|
// Run health checks after successful deployment
|
|
1723
1731
|
if (manifest.hooks?.health_check) {
|
|
@@ -155,7 +155,7 @@ describe('register / unregister roundtrip', () => {
|
|
|
155
155
|
process.env.EVENT_BUS_DB = dbPath;
|
|
156
156
|
});
|
|
157
157
|
afterEach(() => {
|
|
158
|
-
process.env.EVENT_BUS_DB
|
|
158
|
+
delete process.env.EVENT_BUS_DB;
|
|
159
159
|
try {
|
|
160
160
|
rmSync(dir, { recursive: true, force: true });
|
|
161
161
|
} catch {
|
|
@@ -343,7 +343,7 @@ describe('resyncAllSubscriptions (ISS-0088)', () => {
|
|
|
343
343
|
afterEach(() => {
|
|
344
344
|
closeDb();
|
|
345
345
|
resetTestDbPath();
|
|
346
|
-
process.env.EVENT_BUS_DB
|
|
346
|
+
delete process.env.EVENT_BUS_DB;
|
|
347
347
|
try {
|
|
348
348
|
rmSync(dir, { recursive: true, force: true });
|
|
349
349
|
} catch {
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
16
16
|
import { eq } from 'drizzle-orm';
|
|
17
17
|
import type { DbClient } from '../db/client';
|
|
18
|
-
import { systemConfig } from '../db/schema';
|
|
18
|
+
import { modules, systemConfig } from '../db/schema';
|
|
19
19
|
import { setupTestDatabase } from '../test-utils/database';
|
|
20
|
+
import { CONTROL_PLANE_MODULE_ID, upsertDeployedSystem } from './deployed-systems';
|
|
20
21
|
import {
|
|
21
22
|
discoverAndRecordNetwork,
|
|
22
23
|
discoveredNetworkKeys,
|
|
@@ -102,6 +103,33 @@ describe('discoveredNetworkKeys (issue #300 redeploy idempotency)', () => {
|
|
|
102
103
|
});
|
|
103
104
|
});
|
|
104
105
|
|
|
106
|
+
test('first install with control plane in secure-mgmt records there (celilo#1356)', () => {
|
|
107
|
+
// The default topology (ce-8mxz) puts celilo-mgr on secure-mgmt, so a fresh
|
|
108
|
+
// segmented bootstrap must not file the control-plane subnet under
|
|
109
|
+
// `network.internal.*` — celilo would otherwise be blind to its own
|
|
110
|
+
// internal zone.
|
|
111
|
+
expect(discoveredNetworkKeys(internal, false, { controlPlaneZone: 'secure-mgmt' })).toEqual({
|
|
112
|
+
'network.secure-mgmt.subnet': '192.168.1.0/24',
|
|
113
|
+
'network.secure-mgmt.gateway': '192.168.1.1',
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('first install with control plane in internal is unchanged', () => {
|
|
118
|
+
expect(discoveredNetworkKeys(internal, false, { controlPlaneZone: 'internal' })).toEqual({
|
|
119
|
+
'network.internal.subnet': '192.168.1.0/24',
|
|
120
|
+
'network.internal.gateway': '192.168.1.1',
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('first install never clobbers an already-set control-plane zone value', () => {
|
|
125
|
+
expect(
|
|
126
|
+
discoveredNetworkKeys(internal, false, {
|
|
127
|
+
controlPlaneZone: 'secure-mgmt',
|
|
128
|
+
secureMgmtAlreadySet: true,
|
|
129
|
+
}),
|
|
130
|
+
).toEqual({});
|
|
131
|
+
});
|
|
132
|
+
|
|
105
133
|
test('preserves an already-set subnet on redeploy (no override)', () => {
|
|
106
134
|
// The regression: a redeploy on a box off the internal network discovers the
|
|
107
135
|
// WRONG subnet — it must not clobber the operator's value.
|
|
@@ -165,6 +193,41 @@ describe('discoverAndRecordNetwork', () => {
|
|
|
165
193
|
expect(read('network.internal.gateway')).toBe('10.31.7.1');
|
|
166
194
|
});
|
|
167
195
|
|
|
196
|
+
test('first install for a control plane deployed in secure-mgmt records secure-mgmt (celilo#1356)', () => {
|
|
197
|
+
// Seed the module_systems row the deploy pipeline writes before
|
|
198
|
+
// bootstrapControlPlane runs, placing celilo-mgmt in secure-mgmt.
|
|
199
|
+
db.insert(modules)
|
|
200
|
+
.values({
|
|
201
|
+
id: CONTROL_PLANE_MODULE_ID,
|
|
202
|
+
name: 'Celilo Management Server',
|
|
203
|
+
version: '0.9.0',
|
|
204
|
+
manifestData: {},
|
|
205
|
+
sourcePath: '/tmp/modules/celilo-mgmt',
|
|
206
|
+
})
|
|
207
|
+
.run();
|
|
208
|
+
upsertDeployedSystem(db, CONTROL_PLANE_MODULE_ID, {
|
|
209
|
+
name: 'main',
|
|
210
|
+
hostname: 'celilo-mgr',
|
|
211
|
+
ipv4Address: '10.226.120.10',
|
|
212
|
+
zone: 'secure-mgmt',
|
|
213
|
+
infraType: 'machine',
|
|
214
|
+
machineId: null,
|
|
215
|
+
serviceId: null,
|
|
216
|
+
vmid: null,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const result = discoverAndRecordNetwork(db, () =>
|
|
220
|
+
ipRouteFor('10.226.120.0', '10.226.120.1', '10.226.120.10'),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
expect(result.applied).toEqual([
|
|
224
|
+
'network.secure-mgmt.subnet = 10.226.120.0/24',
|
|
225
|
+
'network.secure-mgmt.gateway = 10.226.120.1',
|
|
226
|
+
]);
|
|
227
|
+
expect(read('network.internal.subnet')).toBeUndefined();
|
|
228
|
+
expect(read('network.secure-mgmt.subnet')).toBe('10.226.120.0/24');
|
|
229
|
+
});
|
|
230
|
+
|
|
168
231
|
test('a redeploy on the same box changes nothing and says so', () => {
|
|
169
232
|
const routes = () => ipRouteFor('10.31.7.0', '10.31.7.1', '10.31.7.20');
|
|
170
233
|
discoverAndRecordNetwork(db, routes);
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
import { execFileSync } from 'node:child_process';
|
|
25
25
|
import { eq } from 'drizzle-orm';
|
|
26
26
|
import type { DbClient } from '../db/client';
|
|
27
|
-
import { systemConfig } from '../db/schema';
|
|
27
|
+
import { type NetworkZone, systemConfig } from '../db/schema';
|
|
28
|
+
import { CONTROL_PLANE_MODULE_ID, asZone, getModuleSystems } from './deployed-systems';
|
|
28
29
|
|
|
29
30
|
export interface DiscoveredNetwork {
|
|
30
31
|
subnet: string;
|
|
@@ -61,7 +62,14 @@ export function parseInternalNetwork(ipRouteOutput: string): DiscoveredNetwork |
|
|
|
61
62
|
* LAN, so what it discovered IS `internal`. First install records it there.
|
|
62
63
|
* - Segmented deployment: the box sits on a dedicated control-plane network.
|
|
63
64
|
* `internal` is then someone else's subnet — the semi-trusted LAN — and the
|
|
64
|
-
* discovered value belongs under `secure-mgmt
|
|
65
|
+
* discovered value belongs under the control plane's own zone (`secure-mgmt`
|
|
66
|
+
* in the default topology, celilo#1356).
|
|
67
|
+
*
|
|
68
|
+
* On FIRST INSTALL the zone is decided the same way the rest of celilo decides
|
|
69
|
+
* it: from where the control plane actually landed. `discoverAndRecordNetwork`
|
|
70
|
+
* reads `celilo-mgmt`'s deployed-system row and passes that zone in as
|
|
71
|
+
* `opts.controlPlaneZone`; with no row recorded yet the fallback stays
|
|
72
|
+
* `internal`, the pre-#1356 behaviour.
|
|
65
73
|
*
|
|
66
74
|
* Issue #300 spotted the second case as "discovery returns the WRONG subnet" and
|
|
67
75
|
* mitigated it by DISCARDING the value whenever `internal` was already set. That
|
|
@@ -76,14 +84,21 @@ export function parseInternalNetwork(ipRouteOutput: string): DiscoveredNetwork |
|
|
|
76
84
|
export function discoveredNetworkKeys(
|
|
77
85
|
host: DiscoveredNetwork | null,
|
|
78
86
|
internalAlreadySet: boolean,
|
|
79
|
-
opts: {
|
|
87
|
+
opts: {
|
|
88
|
+
internalSubnet?: string;
|
|
89
|
+
secureMgmtAlreadySet?: boolean;
|
|
90
|
+
/** Zone the control plane itself occupies; first install records there, not `internal`. */
|
|
91
|
+
controlPlaneZone?: NetworkZone;
|
|
92
|
+
} = {},
|
|
80
93
|
): Record<string, string> {
|
|
81
94
|
if (!host) return {};
|
|
82
95
|
|
|
83
96
|
if (!internalAlreadySet) {
|
|
97
|
+
const targetZone = opts.controlPlaneZone ?? 'internal';
|
|
98
|
+
if (targetZone !== 'internal' && opts.secureMgmtAlreadySet) return {};
|
|
84
99
|
return {
|
|
85
|
-
|
|
86
|
-
|
|
100
|
+
[`network.${targetZone}.subnet`]: host.subnet,
|
|
101
|
+
[`network.${targetZone}.gateway`]: host.gateway,
|
|
87
102
|
};
|
|
88
103
|
}
|
|
89
104
|
|
|
@@ -118,6 +133,15 @@ function readValue(db: DbClient, key: string): string | undefined {
|
|
|
118
133
|
return db.select().from(systemConfig).where(eq(systemConfig.key, key)).get()?.value ?? undefined;
|
|
119
134
|
}
|
|
120
135
|
|
|
136
|
+
/**
|
|
137
|
+
* The zone the control plane actually landed in, from its deployed-system row
|
|
138
|
+
* (same source `loadControlPlaneSubnet` reads). Undefined when celilo-mgmt has
|
|
139
|
+
* no system recorded yet, so the caller falls back to `internal`.
|
|
140
|
+
*/
|
|
141
|
+
function controlPlaneZone(db: DbClient): NetworkZone | undefined {
|
|
142
|
+
return asZone(getModuleSystems(CONTROL_PLANE_MODULE_ID, db)[0]?.zone) ?? undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
121
145
|
/**
|
|
122
146
|
* Discover the network this box sits on and record it. Idempotent, and never
|
|
123
147
|
* overwrites a value that is already set.
|
|
@@ -143,6 +167,7 @@ export function discoverAndRecordNetwork(
|
|
|
143
167
|
const keys = discoveredNetworkKeys(host, internalSubnet !== undefined, {
|
|
144
168
|
internalSubnet,
|
|
145
169
|
secureMgmtAlreadySet: readValue(db, 'network.secure-mgmt.subnet') !== undefined,
|
|
170
|
+
controlPlaneZone: controlPlaneZone(db),
|
|
146
171
|
});
|
|
147
172
|
|
|
148
173
|
const applied: string[] = [];
|
|
@@ -32,7 +32,7 @@ describe('ensureResponderForInterview (ISS-0025)', () => {
|
|
|
32
32
|
origTTY = process.stdin.isTTY;
|
|
33
33
|
});
|
|
34
34
|
afterEach(() => {
|
|
35
|
-
if (origEnv === undefined) process.env.EVENT_BUS_DB
|
|
35
|
+
if (origEnv === undefined) delete process.env.EVENT_BUS_DB;
|
|
36
36
|
else process.env.EVENT_BUS_DB = origEnv;
|
|
37
37
|
setTTY(origTTY);
|
|
38
38
|
try {
|
|
@@ -46,8 +46,8 @@ describe('applyStagedSystemFiles', () => {
|
|
|
46
46
|
afterEach(() => {
|
|
47
47
|
closeDb();
|
|
48
48
|
resetTestDbPath();
|
|
49
|
-
process.env.CELILO_MASTER_KEY_PATH
|
|
50
|
-
process.env.CELILO_DATA_DIR
|
|
49
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
50
|
+
delete process.env.CELILO_DATA_DIR;
|
|
51
51
|
try {
|
|
52
52
|
rmSync(dir, { recursive: true, force: true });
|
|
53
53
|
} catch {
|
|
@@ -207,8 +207,8 @@ describe('restoreFromArtifactFile error paths', () => {
|
|
|
207
207
|
afterEach(() => {
|
|
208
208
|
closeDb();
|
|
209
209
|
resetTestDbPath();
|
|
210
|
-
process.env.CELILO_DATA_DIR
|
|
211
|
-
process.env.CELILO_MASTER_KEY_PATH
|
|
210
|
+
delete process.env.CELILO_DATA_DIR;
|
|
211
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
212
212
|
try {
|
|
213
213
|
rmSync(dir, { recursive: true, force: true });
|
|
214
214
|
} catch {
|
|
@@ -99,8 +99,8 @@ describe('ssh-key-manager', () => {
|
|
|
99
99
|
|
|
100
100
|
// Clear environment variables
|
|
101
101
|
resetTestDbPath();
|
|
102
|
-
process.env.CELILO_DATA_DIR
|
|
103
|
-
process.env.CELILO_MASTER_KEY_PATH
|
|
102
|
+
delete process.env.CELILO_DATA_DIR;
|
|
103
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
104
104
|
});
|
|
105
105
|
|
|
106
106
|
describe('writeTemporarySshKey', () => {
|
|
@@ -38,9 +38,9 @@ describe('stageSystemState', () => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
afterEach(() => {
|
|
41
|
-
process.env.CELILO_DATA_DIR
|
|
41
|
+
delete process.env.CELILO_DATA_DIR;
|
|
42
42
|
resetTestDbPath();
|
|
43
|
-
process.env.CELILO_MASTER_KEY_PATH
|
|
43
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
44
44
|
rmSync(dataDir, { recursive: true, force: true });
|
|
45
45
|
});
|
|
46
46
|
|
|
@@ -78,6 +78,7 @@ const cleanAudit: AuditDeps = {
|
|
|
78
78
|
moduleConfigs: { modules: [] },
|
|
79
79
|
moduleIntegrity: { results: [] },
|
|
80
80
|
detectWithoutConverge: { modules: [] },
|
|
81
|
+
jailExemptions: { exemptions: [] },
|
|
81
82
|
health: { results: [] },
|
|
82
83
|
backups: { modules: [] },
|
|
83
84
|
abandonedOperations: { records: [] },
|
|
@@ -87,6 +88,7 @@ const cleanAudit: AuditDeps = {
|
|
|
87
88
|
secretsDecryptable: { results: [] },
|
|
88
89
|
servicesReachable: { results: [] },
|
|
89
90
|
machinesReachable: { results: [] },
|
|
91
|
+
diskSpace: { results: [] },
|
|
90
92
|
transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
|
|
91
93
|
trustedSources: { firewalls: [] },
|
|
92
94
|
publicDns: { records: [], probe: unusedPublicDnsProbe },
|
|
@@ -130,7 +130,7 @@ export function startBusResponderFixture(opts: BusResponderFixtureOptions): BusR
|
|
|
130
130
|
close: () => {
|
|
131
131
|
handle.close();
|
|
132
132
|
db.$client.close();
|
|
133
|
-
if (prevDataDir === undefined) process.env.CELILO_DATA_DIR
|
|
133
|
+
if (prevDataDir === undefined) delete process.env.CELILO_DATA_DIR;
|
|
134
134
|
else process.env.CELILO_DATA_DIR = prevDataDir;
|
|
135
135
|
// Reset to the scratch path, never restore the previous value: it may
|
|
136
136
|
// be another suite's temp database, or unset — which sends the next
|