@celilo/cli 2.0.0 → 2.2.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 -1
- package/CELILO_SUBSYSTEMS.md +1 -0
- package/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/schemas/system_config.json +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/commands/firewall-interface-list.test.ts +156 -7
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +20 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-update.test.ts +82 -0
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +4 -3
- package/src/cli/restore-migration-failure.test.ts +159 -0
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +61 -135
- package/src/db/migrate.ts +7 -2
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.ts +16 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +19 -18
- package/src/hooks/executor.ts +88 -11
- package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
- package/src/hooks/hook-jail-unreachability.test.ts +55 -29
- package/src/hooks/hook-protocol.ts +46 -1
- package/src/hooks/hook-runner.ts +36 -0
- package/src/hooks/hook-store-proxy.test.ts +109 -0
- package/src/hooks/hook-store-proxy.ts +85 -0
- package/src/hooks/hook-store.test.ts +162 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +94 -14
- package/src/hooks/jail.test.ts +1 -1
- package/src/hooks/jail.ts +194 -32
- package/src/hooks/mount-set.test.ts +296 -1
- package/src/hooks/mount-set.ts +216 -13
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
- package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
- package/src/hooks/test-fixtures/store-backed.ts +47 -0
- package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
- package/src/hooks/unjailed-lint.test.ts +27 -8
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/build.ts +70 -2
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +275 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +265 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -1
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +45 -2
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-interview.ts +37 -14
- package/src/services/config-provenance.ts +4 -0
- package/src/services/control-plane-bootstrap.test.ts +297 -0
- package/src/services/control-plane-bootstrap.ts +223 -0
- package/src/services/control-plane-health.test.ts +66 -0
- package/src/services/control-plane-health.ts +67 -0
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +22 -0
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/deployed-systems.ts +12 -0
- package/src/services/dns-discovery.test.ts +147 -0
- package/src/services/dns-discovery.ts +134 -0
- package/src/services/fleet-checks.ts +6 -2
- package/src/services/fleet-key.test.ts +66 -2
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +36 -3
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +187 -0
- package/src/services/module-deploy.ts +214 -1
- package/src/services/module-validator/git-hygiene.test.ts +122 -3
- package/src/services/module-validator/git-hygiene.ts +83 -14
- package/src/services/remote-access.test.ts +88 -4
- package/src/services/remote-access.ts +52 -1
- package/src/services/restore-from-file.test.ts +20 -0
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/static-content-converge.test.ts +140 -2
- package/src/services/static-content-converge.ts +55 -8
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/trusted-sources.test.ts +30 -0
- package/src/services/trusted-sources.ts +47 -10
- package/src/templates/generator.ts +9 -2
- package/src/variables/context.ts +16 -5
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
|
|
18
18
|
import { getEventBusPath } from '../config/paths';
|
|
19
|
-
import { InterviewAbandonedError } from './interview-errors';
|
|
19
|
+
import { InterviewAbandonedError, InterviewUnansweredError } from './interview-errors';
|
|
20
20
|
import { ensureResponderForInterview } from './responder-probe';
|
|
21
21
|
|
|
22
22
|
const NO_SCHEMAS = defineEvents({});
|
|
@@ -271,17 +271,26 @@ export async function busInterview<TReply>(
|
|
|
271
271
|
type: string,
|
|
272
272
|
payload: object,
|
|
273
273
|
ownerBus?: Bus,
|
|
274
|
+
timeoutMs = 0,
|
|
274
275
|
): Promise<TReply> {
|
|
275
276
|
const ownsBus = !ownerBus;
|
|
276
277
|
const bus: Bus = ownerBus ?? openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
|
|
277
278
|
try {
|
|
278
279
|
const replies = await bus.query(type as never, payload as never, {
|
|
279
|
-
timeoutMs
|
|
280
|
+
timeoutMs, // 0 = wait forever — race semantics, no timeout
|
|
280
281
|
pollIntervalMs: 250,
|
|
281
282
|
expect: 'first',
|
|
282
283
|
});
|
|
283
284
|
if (replies.length === 0) {
|
|
284
|
-
|
|
285
|
+
// Typed so a bounded caller can distinguish "nobody answered in time"
|
|
286
|
+
// from a decline. With the default 0 this is unreachable (the query
|
|
287
|
+
// waits forever), but keeping it typed costs nothing.
|
|
288
|
+
throw new InterviewUnansweredError(
|
|
289
|
+
type,
|
|
290
|
+
timeoutMs > 0
|
|
291
|
+
? `No reply within ${timeoutMs}ms (${type})`
|
|
292
|
+
: `bus-interview: ${type} returned no reply`,
|
|
293
|
+
);
|
|
285
294
|
}
|
|
286
295
|
return replies[0].payload as unknown as TReply;
|
|
287
296
|
} finally {
|
|
@@ -305,9 +314,10 @@ export async function busInterviewGuarded<TReply>(
|
|
|
305
314
|
type: string,
|
|
306
315
|
payload: object,
|
|
307
316
|
ownerBus?: Bus,
|
|
317
|
+
timeoutMs = 0,
|
|
308
318
|
): Promise<TReply> {
|
|
309
319
|
await ensureResponderForInterview(type);
|
|
310
|
-
return busInterview<TReply>(type, payload, ownerBus);
|
|
320
|
+
return busInterview<TReply>(type, payload, ownerBus, timeoutMs);
|
|
311
321
|
}
|
|
312
322
|
|
|
313
323
|
/**
|
|
@@ -326,9 +336,10 @@ export async function busInterviewGuarded<TReply>(
|
|
|
326
336
|
export async function askInterview(
|
|
327
337
|
payload: InterviewRequiredPayload,
|
|
328
338
|
ownerBus?: Bus,
|
|
339
|
+
timeoutMs = 0,
|
|
329
340
|
): Promise<unknown> {
|
|
330
341
|
const type = EVENT_TYPES.interviewRequired(payload.scope, payload.key);
|
|
331
|
-
const reply = await busInterviewGuarded<InterviewReply>(type, payload, ownerBus);
|
|
342
|
+
const reply = await busInterviewGuarded<InterviewReply>(type, payload, ownerBus, timeoutMs);
|
|
332
343
|
if (reply.abandoned) throw new InterviewAbandonedError(type, reply.abandoned.reason);
|
|
333
344
|
return reply.value;
|
|
334
345
|
}
|
|
@@ -463,16 +474,28 @@ export async function askConfirm(opts: {
|
|
|
463
474
|
message: string;
|
|
464
475
|
defaultValue?: boolean;
|
|
465
476
|
description?: string;
|
|
477
|
+
/**
|
|
478
|
+
* Bound the wait for a reply. 0 (the default) waits forever, which is
|
|
479
|
+
* right when the operator IS the responder (TTY). A headless caller whose
|
|
480
|
+
* responder may be attached but not staged for this question passes a
|
|
481
|
+
* finite bound so it skips instead of hanging until the suite budget
|
|
482
|
+
* kills it (module-orchestrator-primitives design.md D5).
|
|
483
|
+
*/
|
|
484
|
+
timeoutMs?: number;
|
|
466
485
|
}): Promise<boolean> {
|
|
467
|
-
const value = await askInterview(
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
486
|
+
const value = await askInterview(
|
|
487
|
+
{
|
|
488
|
+
scope: opts.scope,
|
|
489
|
+
key: opts.key,
|
|
490
|
+
kind: 'confirm',
|
|
491
|
+
message: opts.message,
|
|
492
|
+
required: true,
|
|
493
|
+
defaultValue: opts.defaultValue === undefined ? undefined : String(opts.defaultValue),
|
|
494
|
+
description: opts.description,
|
|
495
|
+
},
|
|
496
|
+
undefined,
|
|
497
|
+
opts.timeoutMs,
|
|
498
|
+
);
|
|
476
499
|
if (typeof value !== 'boolean') {
|
|
477
500
|
throw new Error(
|
|
478
501
|
`askConfirm(${opts.scope}.${opts.key}): expected a boolean reply, got ${typeof value}`,
|
|
@@ -74,6 +74,8 @@ export function describeDerivedSource(variable: VariableDeclare): string {
|
|
|
74
74
|
return 'from the infrastructure celilo selected for this module';
|
|
75
75
|
case 'terraform':
|
|
76
76
|
return 'from a Terraform output, at deploy time';
|
|
77
|
+
case 'hook':
|
|
78
|
+
return `by the module's own hooks at runtime (via context.config.set), and never by an operator`;
|
|
77
79
|
default:
|
|
78
80
|
return `computed by celilo (source: ${variable.source})`;
|
|
79
81
|
}
|
|
@@ -98,6 +100,8 @@ export function explainNotSettable(moduleId: string, variable: VariableDeclare):
|
|
|
98
100
|
return `${header}\n${origin}\n • node placement: set the service default for NEW deploys (celilo service reconfigure); move an existing container with 'celilo proxmox migrate'.\n • vmid / IP: auto-allocated by IPAM.`;
|
|
99
101
|
case 'terraform':
|
|
100
102
|
return `${header}\n${origin}\n • It is read back from Terraform outputs after the deploy creates the resource.`;
|
|
103
|
+
case 'hook':
|
|
104
|
+
return `${header}\n${origin}\n • It is written by the module's own hook via context.config.set, not by any operator path.`;
|
|
101
105
|
default:
|
|
102
106
|
return `${header}\n${origin}`;
|
|
103
107
|
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The control-plane bootstrap, which replaced celilo-mgmt's `on_install`
|
|
3
|
+
* (celilo#1225).
|
|
4
|
+
*
|
|
5
|
+
* Every dependency is injected, so these run against a real database and no
|
|
6
|
+
* host: no resolver file is read, no ssh-keygen runs, no bus is opened. That is
|
|
7
|
+
* the property the hook could not have. Its equivalents shelled out to the CLI,
|
|
8
|
+
* so the only way to exercise them was to have a celilo on PATH — which is also
|
|
9
|
+
* why the jail broke them and no unit test noticed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, 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
|
+
import type { DbClient } from '../db/client';
|
|
18
|
+
import { capabilities, modules, systemConfig } from '../db/schema';
|
|
19
|
+
import { setupTestDatabaseAt } from '../test-utils/database';
|
|
20
|
+
import {
|
|
21
|
+
type ControlPlaneBootstrapOptions,
|
|
22
|
+
bootstrapControlPlane,
|
|
23
|
+
} from './control-plane-bootstrap';
|
|
24
|
+
import type { FleetFinding } from './fleet-checks';
|
|
25
|
+
|
|
26
|
+
const DNS = { primary: '9.9.9.9', fallback: '8.8.4.4' };
|
|
27
|
+
const PUBLIC_KEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI test celilo-fleet';
|
|
28
|
+
|
|
29
|
+
function dispatcher(status: FleetFinding['status']): FleetFinding {
|
|
30
|
+
return {
|
|
31
|
+
id: 'dispatcher',
|
|
32
|
+
title: 'Event dispatcher',
|
|
33
|
+
status,
|
|
34
|
+
summary: `dispatcher ${status}`,
|
|
35
|
+
detail: [],
|
|
36
|
+
remediation: status === 'ok' ? null : 'start the dispatcher',
|
|
37
|
+
autoFixable: false,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function configValue(db: DbClient, key: string): string | undefined {
|
|
42
|
+
return db.select().from(systemConfig).where(eq(systemConfig.key, key)).get()?.value ?? undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('bootstrapControlPlane', () => {
|
|
46
|
+
let dir: string;
|
|
47
|
+
let db: DbClient;
|
|
48
|
+
/** Every sleep the poll performed, so waiting is asserted rather than timed. */
|
|
49
|
+
let sleeps: number[];
|
|
50
|
+
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
dir = mkdtempSync(join(tmpdir(), 'cpb-'));
|
|
53
|
+
const dbPath = join(dir, 'celilo.db');
|
|
54
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
55
|
+
db = await setupTestDatabaseAt(dbPath);
|
|
56
|
+
sleeps = [];
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
db.$client.close();
|
|
61
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
62
|
+
try {
|
|
63
|
+
rmSync(dir, { recursive: true, force: true });
|
|
64
|
+
} catch {
|
|
65
|
+
/* ignore */
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
function run(overrides: Partial<ControlPlaneBootstrapOptions> = {}) {
|
|
70
|
+
return bootstrapControlPlane({
|
|
71
|
+
db,
|
|
72
|
+
discoverDnsImpl: () => DNS,
|
|
73
|
+
ensureFleetKeyImpl: () => ({ publicKey: PUBLIC_KEY, created: true }),
|
|
74
|
+
discoverNetworkImpl: () => ({ applied: ['network.internal.subnet = 10.0.0.0/24'] }),
|
|
75
|
+
probeDispatcher: () => dispatcher('ok'),
|
|
76
|
+
sleep: async (ms) => {
|
|
77
|
+
sleeps.push(ms);
|
|
78
|
+
},
|
|
79
|
+
...overrides,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test('records the discovered DNS and the fleet public key in system config', async () => {
|
|
84
|
+
await run();
|
|
85
|
+
|
|
86
|
+
expect(configValue(db, 'dns.primary')).toBe('9.9.9.9');
|
|
87
|
+
expect(configValue(db, 'dns.fallback')).toBe('8.8.4.4');
|
|
88
|
+
expect(configValue(db, 'ssh.public_key')).toBe(PUBLIC_KEY);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('hands discovery every address a deployed resolver advertises (celilo#1239)', async () => {
|
|
92
|
+
// The recurrence gate's second half: the refusal only works if bootstrap
|
|
93
|
+
// actually tells discovery what to refuse. Seed capability rows the way a
|
|
94
|
+
// deployed resolver provider (technitium) and its secondary
|
|
95
|
+
// (knot-unbound-secondary) write them, including the CIDR suffix IPAM
|
|
96
|
+
// resolves target_ip to. Selection is by the declared `fleet_resolver`
|
|
97
|
+
// marker in the data, never the capability name.
|
|
98
|
+
const [module] = db
|
|
99
|
+
.insert(modules)
|
|
100
|
+
.values({
|
|
101
|
+
id: 'technitium',
|
|
102
|
+
name: 'technitium',
|
|
103
|
+
version: '1.0.0',
|
|
104
|
+
manifestData: {},
|
|
105
|
+
sourcePath: '/test/technitium',
|
|
106
|
+
})
|
|
107
|
+
.returning()
|
|
108
|
+
.all();
|
|
109
|
+
db.insert(capabilities)
|
|
110
|
+
.values([
|
|
111
|
+
{
|
|
112
|
+
moduleId: module.id,
|
|
113
|
+
capabilityName: 'dns_internal',
|
|
114
|
+
version: '1.0.0',
|
|
115
|
+
data: {
|
|
116
|
+
fleet_resolver: true,
|
|
117
|
+
server: { ip: '10.0.10.13', internal_ip: '192.168.0.151/24' },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
moduleId: module.id,
|
|
122
|
+
capabilityName: 'dns_internal_secondary',
|
|
123
|
+
version: '1.0.0',
|
|
124
|
+
data: { fleet_resolver: true, server: { ip: '10.0.20.14' } },
|
|
125
|
+
},
|
|
126
|
+
])
|
|
127
|
+
.run();
|
|
128
|
+
|
|
129
|
+
const seen: string[][] = [];
|
|
130
|
+
await run({
|
|
131
|
+
discoverDnsImpl: (fleetResolverIps) => {
|
|
132
|
+
seen.push([...fleetResolverIps]);
|
|
133
|
+
return DNS;
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(seen).toHaveLength(1);
|
|
138
|
+
expect(seen[0].sort()).toEqual(['10.0.10.13', '192.168.0.151/24', '10.0.20.14'].sort());
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('a resolver capability without the declared marker is not a fleet resolver', async () => {
|
|
142
|
+
// Selection is by the DECLARED marker, not the capability name. A row
|
|
143
|
+
// named like a resolver but not declaring fleet_resolver must be ignored,
|
|
144
|
+
// otherwise renaming this to a name check would pass every other test
|
|
145
|
+
// here and silently re-open celilo#1239 to name-shaped drift.
|
|
146
|
+
const [module] = db
|
|
147
|
+
.insert(modules)
|
|
148
|
+
.values({
|
|
149
|
+
id: 'technitium',
|
|
150
|
+
name: 'technitium',
|
|
151
|
+
version: '1.0.0',
|
|
152
|
+
manifestData: {},
|
|
153
|
+
sourcePath: '/test/technitium',
|
|
154
|
+
})
|
|
155
|
+
.returning()
|
|
156
|
+
.all();
|
|
157
|
+
db.insert(capabilities)
|
|
158
|
+
.values({
|
|
159
|
+
moduleId: module.id,
|
|
160
|
+
capabilityName: 'dns_internal',
|
|
161
|
+
version: '1.0.0',
|
|
162
|
+
data: { server: { ip: '10.0.10.13', internal_ip: '192.168.0.151/24' } },
|
|
163
|
+
})
|
|
164
|
+
.run();
|
|
165
|
+
|
|
166
|
+
const seen: string[][] = [];
|
|
167
|
+
await run({
|
|
168
|
+
discoverDnsImpl: (fleetResolverIps) => {
|
|
169
|
+
seen.push([...fleetResolverIps]);
|
|
170
|
+
return DNS;
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(seen).toEqual([[]]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('a capability of any name declaring the marker is a fleet resolver', async () => {
|
|
178
|
+
// The other half of the same proof: a NEW resolver provider needs no core
|
|
179
|
+
// change. Its capability name is irrelevant; the declaration is what counts.
|
|
180
|
+
const [module] = db
|
|
181
|
+
.insert(modules)
|
|
182
|
+
.values({
|
|
183
|
+
id: 'technitium',
|
|
184
|
+
name: 'technitium',
|
|
185
|
+
version: '1.0.0',
|
|
186
|
+
manifestData: {},
|
|
187
|
+
sourcePath: '/test/technitium',
|
|
188
|
+
})
|
|
189
|
+
.returning()
|
|
190
|
+
.all();
|
|
191
|
+
db.insert(capabilities)
|
|
192
|
+
.values({
|
|
193
|
+
moduleId: module.id,
|
|
194
|
+
capabilityName: 'some_future_resolver',
|
|
195
|
+
version: '1.0.0',
|
|
196
|
+
data: { fleet_resolver: true, server: { ip: '10.0.30.15' } },
|
|
197
|
+
})
|
|
198
|
+
.run();
|
|
199
|
+
|
|
200
|
+
const seen: string[][] = [];
|
|
201
|
+
await run({
|
|
202
|
+
discoverDnsImpl: (fleetResolverIps) => {
|
|
203
|
+
seen.push([...fleetResolverIps]);
|
|
204
|
+
return DNS;
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(seen).toEqual([['10.0.30.15']]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('returns what each step found, so the caller renders and this does not', async () => {
|
|
212
|
+
const result = await run();
|
|
213
|
+
|
|
214
|
+
expect(result.dns).toEqual(DNS);
|
|
215
|
+
expect(result.fleetKey).toEqual({ publicKey: PUBLIC_KEY, created: true });
|
|
216
|
+
expect(result.network.applied).toEqual(['network.internal.subnet = 10.0.0.0/24']);
|
|
217
|
+
expect(result.dispatcher.status).toBe('ok');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('a dispatcher already up is probed once and never waited for', async () => {
|
|
221
|
+
let probes = 0;
|
|
222
|
+
await run({
|
|
223
|
+
probeDispatcher: () => {
|
|
224
|
+
probes++;
|
|
225
|
+
return dispatcher('ok');
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(probes).toBe(1);
|
|
230
|
+
expect(sleeps).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('a dispatcher still starting is polled until it answers', async () => {
|
|
234
|
+
// The role enables the supervisor unit moments before this runs, so the
|
|
235
|
+
// first probe legitimately finds nothing. Two failures then success.
|
|
236
|
+
const readings: FleetFinding['status'][] = ['fail', 'fail', 'ok'];
|
|
237
|
+
let probes = 0;
|
|
238
|
+
const result = await run({
|
|
239
|
+
probeDispatcher: () => dispatcher(readings[probes++] ?? 'ok'),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
expect(probes).toBe(3);
|
|
243
|
+
expect(sleeps).toEqual([1000, 1000]);
|
|
244
|
+
expect(result.dispatcher.status).toBe('ok');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('a dispatcher that never answers gives up after the attempt budget', async () => {
|
|
248
|
+
let probes = 0;
|
|
249
|
+
const result = await run({
|
|
250
|
+
probeDispatcher: () => {
|
|
251
|
+
probes++;
|
|
252
|
+
return dispatcher('fail');
|
|
253
|
+
},
|
|
254
|
+
dispatcherAttempts: 5,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(probes).toBe(5);
|
|
258
|
+
expect(sleeps).toHaveLength(4);
|
|
259
|
+
// Reported, not thrown. The caller decides how loud to be, which is what
|
|
260
|
+
// lets `module deploy` fail the install and a later caller merely warn.
|
|
261
|
+
expect(result.dispatcher.status).toBe('fail');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('a warn reading is accepted rather than polled against', async () => {
|
|
265
|
+
let probes = 0;
|
|
266
|
+
await run({
|
|
267
|
+
probeDispatcher: () => {
|
|
268
|
+
probes++;
|
|
269
|
+
return dispatcher('warn');
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(probes).toBe(1);
|
|
274
|
+
expect(sleeps).toEqual([]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('a box with no default route still gets its DNS and key recorded', async () => {
|
|
278
|
+
// `discoverAndRecordNetwork` reports a skip rather than throwing, and a
|
|
279
|
+
// box celilo cannot read a network from must not fail the whole deploy.
|
|
280
|
+
const result = await run({
|
|
281
|
+
discoverNetworkImpl: () => ({ applied: [], skipped: 'no default route' }),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
expect(result.network.skipped).toBe('no default route');
|
|
285
|
+
expect(configValue(db, 'dns.primary')).toBe('9.9.9.9');
|
|
286
|
+
expect(configValue(db, 'ssh.public_key')).toBe(PUBLIC_KEY);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('an existing fleet key is recorded without being reminted', async () => {
|
|
290
|
+
const result = await run({
|
|
291
|
+
ensureFleetKeyImpl: () => ({ publicKey: PUBLIC_KEY, created: false }),
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(result.fleetKey.created).toBe(false);
|
|
295
|
+
expect(configValue(db, 'ssh.public_key')).toBe(PUBLIC_KEY);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Initialise the management box's own celilo state, during the celilo-mgmt
|
|
3
|
+
* deploy.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is here and not in a hook
|
|
6
|
+
*
|
|
7
|
+
* It was a hook until celilo#1225. `modules/celilo-mgmt/scripts/on_install.ts`
|
|
8
|
+
* reached every one of these operations by spawning the `celilo` CLI, which a
|
|
9
|
+
* jailed hook cannot do: the jail's mount set binds no `/usr/bin`, no
|
|
10
|
+
* `/usr/local/bin` and no shell, so `execFileSync` dies on the missing binary
|
|
11
|
+
* and `execSync` dies one step earlier on the missing `/bin/sh`. The deploy ran
|
|
12
|
+
* Ansible to completion and then failed in its own install hook, on every host
|
|
13
|
+
* where a jail backend exists.
|
|
14
|
+
*
|
|
15
|
+
* The fix is not to let the hook reach the CLI. It is that none of this wanted
|
|
16
|
+
* to be in a hook. celilo-mgmt is never deployed to a remote box (ruled
|
|
17
|
+
* 2026-09-02), so the host being configured is always the host celilo runs on,
|
|
18
|
+
* and every step below is celilo acting on itself: read this box's resolvers,
|
|
19
|
+
* mint celilo's own fleet key, record celilo's own network, check celilo's own
|
|
20
|
+
* event bus. A hook was a process boundary with celilo on both sides of it.
|
|
21
|
+
*
|
|
22
|
+
* ## What it does NOT do
|
|
23
|
+
*
|
|
24
|
+
* It does not decide whether to run. The deploy path does that, from the
|
|
25
|
+
* control-plane module id, which celilo already keys on in three other places
|
|
26
|
+
* (`manifest/validate.ts`'s privileged allow-list, `capability-loader.ts`'s
|
|
27
|
+
* `CONTROL_PLANE_MODULE_ID`, `fleet-checks.ts`'s `CONTROL_PLANE_MODULE`).
|
|
28
|
+
*
|
|
29
|
+
* It also does not print. It returns what happened and the caller renders it,
|
|
30
|
+
* so the same function serves a deploy, a test and any later caller without
|
|
31
|
+
* carrying a logger's opinions (Rule 10.1).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
|
|
35
|
+
import { getEventBusPath } from '../config/paths';
|
|
36
|
+
import type { DbClient } from '../db/client';
|
|
37
|
+
import { capabilities } from '../db/schema';
|
|
38
|
+
import { type DnsServers, discoverDns } from './dns-discovery';
|
|
39
|
+
import { type FleetFinding, checkDispatcher } from './fleet-checks';
|
|
40
|
+
import { type FleetKey, ensureFleetKey } from './fleet-key';
|
|
41
|
+
import { type NetworkDiscoveryResult, discoverAndRecordNetwork } from './network-discovery';
|
|
42
|
+
import { initializeSystem } from './system-init';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How long to give a dispatcher that is still coming up.
|
|
46
|
+
*
|
|
47
|
+
* The Ansible role enables its supervisor unit moments before this runs, so a
|
|
48
|
+
* first probe legitimately finds nothing. Five one-second attempts is what the
|
|
49
|
+
* hook this replaced used, and no case has argued for more.
|
|
50
|
+
*/
|
|
51
|
+
const DISPATCHER_ATTEMPTS = 5;
|
|
52
|
+
const DISPATCHER_INTERVAL_MS = 1_000;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The marker a resolver provider declares in its capability data to say "the
|
|
56
|
+
* addresses I advertise are a resolver celilo itself deployed". Core reads
|
|
57
|
+
* this declaration and names no capability (module-business-barrier Scan B).
|
|
58
|
+
* Declared today by knot-unbound-internal, knot-unbound-secondary and
|
|
59
|
+
* technitium (celilo#1239).
|
|
60
|
+
*/
|
|
61
|
+
const FLEET_RESOLVER_MARKER = 'fleet_resolver';
|
|
62
|
+
|
|
63
|
+
/** The declared shape of a resolver capability's data. Values checked before use. */
|
|
64
|
+
type ResolverCapabilityData = {
|
|
65
|
+
fleet_resolver?: unknown;
|
|
66
|
+
server?: { ip?: unknown; internal_ip?: unknown };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Every address a deployed resolver provider advertises for itself.
|
|
71
|
+
*
|
|
72
|
+
* A capability row counts when its data declares {@link FLEET_RESOLVER_MARKER};
|
|
73
|
+
* the capability NAME is irrelevant to this selection, so a new resolver
|
|
74
|
+
* provider needs no core change. Discovery hands the addresses to
|
|
75
|
+
* {@link discoverDns} so a resolv.conf the provider's own aspect wrote is
|
|
76
|
+
* refused rather than adopted as the fleet's upstream (celilo#1239). Addresses
|
|
77
|
+
* come from the declared data only (`server.ip`, `server.internal_ip`). Values
|
|
78
|
+
* may carry a CIDR suffix; discovery normalises both sides before comparing.
|
|
79
|
+
*/
|
|
80
|
+
export function fleetResolverAddresses(db: DbClient): string[] {
|
|
81
|
+
const addresses: string[] = [];
|
|
82
|
+
const rows = db.select({ data: capabilities.data }).from(capabilities).all();
|
|
83
|
+
for (const row of rows) {
|
|
84
|
+
const data = row.data as ResolverCapabilityData | undefined;
|
|
85
|
+
if (data?.[FLEET_RESOLVER_MARKER] !== true) continue;
|
|
86
|
+
for (const value of [data.server?.ip, data.server?.internal_ip]) {
|
|
87
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
88
|
+
addresses.push(value);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return addresses;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ControlPlaneBootstrapOptions {
|
|
96
|
+
db: DbClient;
|
|
97
|
+
/**
|
|
98
|
+
* Injectable so tests drive the whole sequence without a host or a bus.
|
|
99
|
+
* Receives the addresses of resolvers celilo itself deployed, which the
|
|
100
|
+
* discovery must refuse (celilo#1239).
|
|
101
|
+
*/
|
|
102
|
+
discoverDnsImpl?: (fleetResolverIps: readonly string[]) => DnsServers;
|
|
103
|
+
ensureFleetKeyImpl?: () => FleetKey;
|
|
104
|
+
discoverNetworkImpl?: (db: DbClient) => NetworkDiscoveryResult;
|
|
105
|
+
/**
|
|
106
|
+
* One dispatcher reading. Injectable so a test needs no bus at all; the
|
|
107
|
+
* default opens celilo's own and runs the four-part check against it.
|
|
108
|
+
*/
|
|
109
|
+
probeDispatcher?: () => FleetFinding;
|
|
110
|
+
sleep?: (ms: number) => Promise<void>;
|
|
111
|
+
dispatcherAttempts?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ControlPlaneBootstrapResult {
|
|
115
|
+
dns: DnsServers;
|
|
116
|
+
fleetKey: FleetKey;
|
|
117
|
+
network: NetworkDiscoveryResult;
|
|
118
|
+
/** The last dispatcher reading. `fail` means no dispatcher ever answered. */
|
|
119
|
+
dispatcher: FleetFinding;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The control plane reads the bus's own health tables and emits nothing, so it
|
|
124
|
+
* registers no event schemas — the same empty registry `celilo events` opens
|
|
125
|
+
* with.
|
|
126
|
+
*/
|
|
127
|
+
const NO_SCHEMAS = defineEvents({});
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Read the dispatcher's state from celilo's own bus.
|
|
131
|
+
*
|
|
132
|
+
* `checkDispatcher` is deliberately used in place of the "is the process up?"
|
|
133
|
+
* read the hook did. Its docblock records why: a naive liveness check reports
|
|
134
|
+
* green on a dispatcher that is running, unsupervised, on stale code, and
|
|
135
|
+
* delivering nothing.
|
|
136
|
+
*/
|
|
137
|
+
/**
|
|
138
|
+
* Open celilo's own bus, do one thing with it, close it.
|
|
139
|
+
*
|
|
140
|
+
* There is an async twin below and the two are NOT interchangeable: handing an
|
|
141
|
+
* async function to this one closes the bus when the promise is CREATED, not
|
|
142
|
+
* when it settles, so the work runs against a closed handle.
|
|
143
|
+
*/
|
|
144
|
+
export function withCeliloBus<T>(fn: (bus: Bus) => T): T {
|
|
145
|
+
const bus: Bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
|
|
146
|
+
try {
|
|
147
|
+
return fn(bus);
|
|
148
|
+
} finally {
|
|
149
|
+
bus.close();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The async twin. See the warning on `withCeliloBus`. */
|
|
154
|
+
export async function withCeliloBusAsync<T>(fn: (bus: Bus) => Promise<T>): Promise<T> {
|
|
155
|
+
const bus: Bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
|
|
156
|
+
try {
|
|
157
|
+
return await fn(bus);
|
|
158
|
+
} finally {
|
|
159
|
+
bus.close();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function probeOwnDispatcher(): FleetFinding {
|
|
164
|
+
return withCeliloBus((bus) => checkDispatcher(bus));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Poll until a dispatcher answers, or the attempts run out. */
|
|
168
|
+
async function waitForDispatcher(
|
|
169
|
+
probe: () => FleetFinding,
|
|
170
|
+
attempts: number,
|
|
171
|
+
sleep: (ms: number) => Promise<void>,
|
|
172
|
+
): Promise<FleetFinding> {
|
|
173
|
+
let finding = probe();
|
|
174
|
+
for (let attempt = 1; attempt < attempts && finding.status === 'fail'; attempt++) {
|
|
175
|
+
await sleep(DISPATCHER_INTERVAL_MS);
|
|
176
|
+
finding = probe();
|
|
177
|
+
}
|
|
178
|
+
return finding;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Record what celilo needs to know about the box it runs on.
|
|
183
|
+
*
|
|
184
|
+
* Ordering is not cosmetic. The fleet key and the DNS servers are written in
|
|
185
|
+
* ONE `initializeSystem` call, because that function applies defaults and the
|
|
186
|
+
* init sentinel alongside the keys it is given, and calling it twice makes the
|
|
187
|
+
* second call re-derive everything the first already settled. The hook this
|
|
188
|
+
* replaced made two round trips through the CLI for exactly that reason and
|
|
189
|
+
* had no way not to.
|
|
190
|
+
*
|
|
191
|
+
* The network is recorded after, not in the same call: `system apply-config`
|
|
192
|
+
* REFUSES `network.*` keys outright, so a network is not something you write
|
|
193
|
+
* through the config surface at all. `discoverAndRecordNetwork` is the only
|
|
194
|
+
* path, it is idempotent, and it never overwrites an operator's value.
|
|
195
|
+
*/
|
|
196
|
+
export async function bootstrapControlPlane(
|
|
197
|
+
options: ControlPlaneBootstrapOptions,
|
|
198
|
+
): Promise<ControlPlaneBootstrapResult> {
|
|
199
|
+
const {
|
|
200
|
+
db,
|
|
201
|
+
discoverDnsImpl = (fleetResolverIps: readonly string[]): DnsServers =>
|
|
202
|
+
discoverDns(undefined, { fleetResolverIps }),
|
|
203
|
+
ensureFleetKeyImpl = ensureFleetKey,
|
|
204
|
+
discoverNetworkImpl = discoverAndRecordNetwork,
|
|
205
|
+
probeDispatcher = probeOwnDispatcher,
|
|
206
|
+
sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms)),
|
|
207
|
+
dispatcherAttempts = DISPATCHER_ATTEMPTS,
|
|
208
|
+
} = options;
|
|
209
|
+
|
|
210
|
+
const dns = discoverDnsImpl(fleetResolverAddresses(db));
|
|
211
|
+
const fleetKey = ensureFleetKeyImpl();
|
|
212
|
+
|
|
213
|
+
initializeSystem(db, {
|
|
214
|
+
'dns.primary': dns.primary,
|
|
215
|
+
'dns.fallback': dns.fallback,
|
|
216
|
+
'ssh.public_key': fleetKey.publicKey,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const network = discoverNetworkImpl(db);
|
|
220
|
+
const dispatcher = await waitForDispatcher(probeDispatcher, dispatcherAttempts, sleep);
|
|
221
|
+
|
|
222
|
+
return { dns, fleetKey, network, dispatcher };
|
|
223
|
+
}
|