@celilo/cli 2.0.0 → 2.1.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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Read the upstream DNS resolvers this box uses.
3
+ *
4
+ * This sits beside `network-discovery.ts` and for the same reason: celilo
5
+ * records facts about the management box itself, and a module is not the right
6
+ * place to read them. The network half moved first
7
+ * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md),
8
+ * the fleet key followed (openspec/changes/hook-process-boundary, design D9b),
9
+ * and DNS is the last of the three. It came from
10
+ * `modules/celilo-mgmt/scripts/discovery.ts`, whose hook could only reach
11
+ * celilo by spawning the CLI — which a jailed hook cannot do at all
12
+ * (celilo#1225).
13
+ *
14
+ * Reading it here is not merely tidier, it is the only place it can be
15
+ * correct. celilo-mgmt is never deployed to a remote box (ruled 2026-09-02),
16
+ * so the host whose resolvers we want is always the host celilo runs on. A
17
+ * hook reading `/etc/resolv.conf` reads that same file, one process further
18
+ * out, for no benefit.
19
+ *
20
+ * Pure parsers are separated from the IO wrapper so they test without a host
21
+ * (Rule 2.3).
22
+ */
23
+
24
+ import { existsSync, readFileSync } from 'node:fs';
25
+
26
+ /**
27
+ * Where a host publishes its real upstream resolvers, most trustworthy first.
28
+ *
29
+ * systemd-resolved's file comes first deliberately: on such a host
30
+ * `/etc/resolv.conf` names the local 127.0.0.53 stub rather than the upstreams
31
+ * behind it, so reading only the second file discovers a loopback address and
32
+ * writes it into system config as the fleet's DNS.
33
+ */
34
+ const RESOLVER_FILES = ['/run/systemd/resolve/resolv.conf', '/etc/resolv.conf'] as const;
35
+
36
+ /** Where we land when the host offers no usable upstream of its own. */
37
+ const PUBLIC_FALLBACK = '1.1.1.1';
38
+
39
+ export interface DnsServers {
40
+ primary: string;
41
+ fallback: string;
42
+ }
43
+
44
+ /** Extract `nameserver` IPs from resolv.conf-format text, in file order. */
45
+ export function parseNameservers(resolvConf: string): string[] {
46
+ return resolvConf
47
+ .split('\n')
48
+ .map((l) => l.trim())
49
+ .filter((l) => l.startsWith('nameserver '))
50
+ .map((l) => l.split(/\s+/)[1])
51
+ .filter(Boolean);
52
+ }
53
+
54
+ /**
55
+ * Choose primary and fallback from discovered nameservers.
56
+ *
57
+ * Loopback entries are dropped rather than used. A stub resolver's address is
58
+ * a real answer to "what does this host query" and the wrong answer to "what
59
+ * should the fleet query", because nothing else on the network can reach it.
60
+ */
61
+ export function chooseDns(nameservers: string[]): DnsServers {
62
+ const upstream = nameservers.filter((ns) => !ns.startsWith('127.') && ns !== '::1');
63
+ return {
64
+ primary: upstream[0] ?? PUBLIC_FALLBACK,
65
+ fallback: upstream[1] ?? PUBLIC_FALLBACK,
66
+ };
67
+ }
68
+
69
+ /** Reads a resolver file, or returns null when it is not present. */
70
+ export type ResolverFileReader = (path: string) => string | null;
71
+
72
+ const readResolverFile: ResolverFileReader = (path) =>
73
+ existsSync(path) ? readFileSync(path, 'utf-8') : null;
74
+
75
+ /**
76
+ * Read this box's upstream resolvers.
77
+ *
78
+ * Takes the systemd file only when it yielded a real server. `/etc/resolv.conf`
79
+ * is the last resort and whatever it gives is accepted, including the fallback,
80
+ * because there is nothing further to consult.
81
+ */
82
+ export function discoverDns(read: ResolverFileReader = readResolverFile): DnsServers {
83
+ for (const path of RESOLVER_FILES) {
84
+ const contents = read(path);
85
+ if (contents === null) continue;
86
+ const servers = chooseDns(parseNameservers(contents));
87
+ if (servers.primary !== PUBLIC_FALLBACK || path === '/etc/resolv.conf') {
88
+ return servers;
89
+ }
90
+ }
91
+ return { primary: PUBLIC_FALLBACK, fallback: PUBLIC_FALLBACK };
92
+ }
@@ -29,7 +29,11 @@ import { loadControlPlaneSubnet, resolveFirewallNatIp } from '../hooks/capabilit
29
29
  import type { ModuleManifest } from '../manifest/schema';
30
30
 
31
31
  import { getServiceCredentials, listContainerServices } from './container-service';
32
- import { getModuleSystems, listAllModuleSystems } from './deployed-systems';
32
+ import {
33
+ CONTROL_PLANE_MODULE_ID,
34
+ getModuleSystems,
35
+ listAllModuleSystems,
36
+ } from './deployed-systems';
33
37
  import { listDnsInternalRecords } from './dns-internal-records';
34
38
  import {
35
39
  SUPERVISOR_SCOPES,
@@ -43,7 +47,7 @@ import { describePausedModule, listPausedModules } from './module-pause';
43
47
  import { resolveSubscription } from './module-subscriptions';
44
48
 
45
49
  /** The module that IS celilo's control plane. */
46
- const CONTROL_PLANE_MODULE = 'celilo-mgmt';
50
+ const CONTROL_PLANE_MODULE = CONTROL_PLANE_MODULE_ID;
47
51
 
48
52
  /**
49
53
  * Zones reachable from the operator's LAN. A celilo placement zone other
@@ -17,7 +17,8 @@ import type { HookLogger, HookResult } from '../hooks/types';
17
17
  import type { ModuleManifest } from '../manifest/schema';
18
18
  import { decryptSecret } from '../secrets/encryption';
19
19
  import { getOrCreateMasterKey } from '../secrets/master-key';
20
- import { getModuleSystems } from './deployed-systems';
20
+ import { controlPlaneHealthChecks } from './control-plane-health';
21
+ import { CONTROL_PLANE_MODULE_ID, getModuleSystems } from './deployed-systems';
21
22
  import { remoteAccessPolicy } from './remote-access';
22
23
 
23
24
  export interface HealthCheckItem {
@@ -152,6 +153,19 @@ export async function runModuleHealthCheck(
152
153
  return { moduleId, status: 'error', checks: [], error: 'Module not found' };
153
154
  }
154
155
 
156
+ // The control plane checks itself, in celilo's own process (celilo#1225).
157
+ //
158
+ // This was a health_check hook and it failed under the jail in the worst
159
+ // possible way: it asked `existsSync(db_path)`, the jail deliberately does
160
+ // not bind celilo's data directory, and so it reported a healthy fleet's
161
+ // database MISSING and failed the deploy over a file that was right there.
162
+ // `runFleetChecks` is what `celilo system doctor` already runs and asks
163
+ // eight questions where the hook asked two.
164
+ if (moduleId === CONTROL_PLANE_MODULE_ID) {
165
+ const checks = await controlPlaneHealthChecks(db);
166
+ return finishHealthCheck(moduleId, module.state, checks, db, options);
167
+ }
168
+
155
169
  const manifest = module.manifestData as ModuleManifest;
156
170
  const hookDef = manifest.hooks?.health_check;
157
171
 
@@ -222,6 +236,23 @@ export async function runModuleHealthCheck(
222
236
  };
223
237
 
224
238
  const checks = outputs.checks || [];
239
+ return finishHealthCheck(moduleId, module.state, checks, db, options, hookResult.artifactPaths);
240
+ }
241
+
242
+ /**
243
+ * Derive the verdict from the named checks and move the module's state.
244
+ *
245
+ * Shared by the hook path and the control plane's own path so the two cannot
246
+ * drift on what `degraded` means or on when a state transition is suppressed.
247
+ */
248
+ function finishHealthCheck(
249
+ moduleId: string,
250
+ moduleState: ModuleState,
251
+ checks: HealthCheckItem[],
252
+ db: DbClient,
253
+ options: HealthCheckOptions,
254
+ artifactPaths?: string[],
255
+ ): HealthCheckResult {
225
256
  const hasFail = checks.some((c) => c.status === 'fail');
226
257
  const hasWarn = checks.some((c) => c.status === 'warn');
227
258
 
@@ -231,12 +262,12 @@ export async function runModuleHealthCheck(
231
262
  ? 'degraded'
232
263
  : 'healthy';
233
264
 
234
- const nextState = nextModuleState(module.state, status, options.unattended ?? false);
265
+ const nextState = nextModuleState(moduleState, status, options.unattended ?? false);
235
266
  if (nextState) {
236
267
  db.update(modules).set({ state: nextState }).where(eq(modules.id, moduleId)).run();
237
268
  }
238
269
 
239
- return { moduleId, status, checks, artifactPaths: hookResult.artifactPaths };
270
+ return { moduleId, status, checks, ...(artifactPaths ? { artifactPaths } : {}) };
240
271
  }
241
272
 
242
273
  /**
@@ -30,11 +30,13 @@ import {
30
30
  interviewForMissingSecrets,
31
31
  } from './config-interview';
32
32
  import { getContainerService } from './container-service';
33
+ import { bootstrapControlPlane } from './control-plane-bootstrap';
33
34
  import { executeAnsible } from './deploy-ansible';
34
35
  import { planDeployment } from './deploy-planner';
35
36
  import { waitForSSH } from './deploy-ssh';
36
37
  import { executeTerraform, parseTerraformOutputs } from './deploy-terraform';
37
38
  import { validateAndPrepareDeployment } from './deploy-validation';
39
+ import { CONTROL_PLANE_MODULE_ID } from './deployed-systems';
38
40
  import { getModuleSystems } from './deployed-systems';
39
41
  import { E2E_CONFLICT_MESSAGE, runningE2eContainers } from './e2e-guard';
40
42
  import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
@@ -1351,6 +1353,55 @@ async function deployModuleImpl(
1351
1353
  // container ID/IP got assigned per module in the DB.
1352
1354
  }
1353
1355
 
1356
+ // celilo initialises its own box, in its own process.
1357
+ //
1358
+ // This was `celilo-mgmt`'s `on_install` hook until celilo#1225. The hook
1359
+ // reached every one of these operations by spawning the `celilo` CLI,
1360
+ // which a jailed hook cannot do — the mount set binds no `/usr/bin`, no
1361
+ // `/usr/local/bin` and no shell — so the deploy ran Ansible to completion
1362
+ // and then died in its own install hook on any host with a jail backend.
1363
+ //
1364
+ // It runs at the point the hook used to, for the reason the hook ran
1365
+ // there: the Ansible role has just installed celilo and started the
1366
+ // dispatcher, and none of this can be read before that.
1367
+ if (moduleId === CONTROL_PLANE_MODULE_ID) {
1368
+ log.success('Initializing celilo management state');
1369
+ try {
1370
+ const bootstrap = await bootstrapControlPlane({ db });
1371
+
1372
+ log.info(` DNS: primary ${bootstrap.dns.primary}, fallback ${bootstrap.dns.fallback}`);
1373
+ for (const applied of bootstrap.network.applied) {
1374
+ log.info(` Network: ${applied}`);
1375
+ }
1376
+ if (bootstrap.network.skipped) {
1377
+ log.warn(` Network not recorded: ${bootstrap.network.skipped}`);
1378
+ }
1379
+ // The operator authorises this key on every machine celilo manages,
1380
+ // so it is printed rather than merely stored. It was the loudest line
1381
+ // the hook produced and it stays the loudest line here.
1382
+ log.success(
1383
+ `celilo fleet SSH key — add this public key to machines celilo will manage:\n${bootstrap.fleetKey.publicKey}`,
1384
+ );
1385
+
1386
+ if (bootstrap.dispatcher.status === 'fail') {
1387
+ // A management plane with no dispatcher is broken: event-driven
1388
+ // reconciles never deliver. Fail the install, do not whisper.
1389
+ log.error(`Dispatcher check failed: ${bootstrap.dispatcher.summary}`);
1390
+ return {
1391
+ success: false,
1392
+ phases,
1393
+ error:
1394
+ `celilo-mgmt: ${bootstrap.dispatcher.summary}. ${bootstrap.dispatcher.remediation ?? ''}`.trim(),
1395
+ };
1396
+ }
1397
+ log.success(`Event dispatcher is live (${bootstrap.dispatcher.summary})`);
1398
+ } catch (err) {
1399
+ const detail = err instanceof Error ? err.message : String(err);
1400
+ log.error(`Initializing celilo management state failed: ${detail}`);
1401
+ return { success: false, phases, error: detail };
1402
+ }
1403
+ }
1404
+
1354
1405
  // Run on_install hook (post-deploy actions like port forwarding, DNS registration)
1355
1406
  if (manifest.hooks?.on_install) {
1356
1407
  const onInstallDef = manifest.hooks.on_install;
@@ -7,16 +7,27 @@
7
7
  * 2. an explicit non-root user is allowed — the fleet key's authority is
8
8
  * root on fleet systems, and a non-root account trusts it only where an
9
9
  * operator installed it (the cPanel bootstrap);
10
- * 3. otherwise the target must be a system the module or one of its
11
- * instances provisioned, and a refusal names the module, the target, and
12
- * the capability route.
10
+ * 3. the target is a system the module or one of its instances provisioned;
11
+ * 4. or it is a machine in celilo's pool that this module's own config names
12
+ * — the case a config-only manager module needs, since it provisions
13
+ * nothing and so can never satisfy rule 3 (celilo#1225);
14
+ *
15
+ * and otherwise a refusal names the module, the target, and the capability
16
+ * route.
13
17
  */
14
18
 
15
19
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
16
20
  import { existsSync } from 'node:fs';
17
21
  import { rm } from 'node:fs/promises';
18
22
  import { type DbClient, createDbClient } from '../db/client';
19
- import { capabilities, moduleInstances, moduleSystems, modules } from '../db/schema';
23
+ import {
24
+ capabilities,
25
+ machines,
26
+ moduleConfigs,
27
+ moduleInstances,
28
+ moduleSystems,
29
+ modules,
30
+ } from '../db/schema';
20
31
  import { remoteAccessPolicy } from './remote-access';
21
32
 
22
33
  const TEST_DB_PATH = './test-remote-access.db';
@@ -48,6 +59,26 @@ function seedSystem(db: DbClient, moduleId: string, ipv4Address: string, hostnam
48
59
  .run();
49
60
  }
50
61
 
62
+ /** A box the operator admitted to celilo's pool. */
63
+ function seedPoolMachine(db: DbClient, ipAddress: string, hostname: string): void {
64
+ db.insert(machines)
65
+ .values({
66
+ id: `machine-${hostname}`,
67
+ hostname,
68
+ zone: 'internal',
69
+ ipAddress,
70
+ sshUser: 'root',
71
+ sshKeyEncrypted: 'x',
72
+ hardware: { cpu_cores: 1, memory_mb: 512, disk_gb: 8 },
73
+ })
74
+ .run();
75
+ }
76
+
77
+ /** A config value the operator set on a module. */
78
+ function seedConfig(db: DbClient, moduleId: string, key: string, value: string): void {
79
+ db.insert(moduleConfigs).values({ moduleId, key, value }).run();
80
+ }
81
+
51
82
  describe('remoteAccessPolicy', () => {
52
83
  let db: DbClient;
53
84
 
@@ -136,4 +167,57 @@ describe('remoteAccessPolicy', () => {
136
167
  );
137
168
  expect(verdict.allowed).toBe(false);
138
169
  });
170
+
171
+ describe('a config-only module managing a pool machine (celilo#1225)', () => {
172
+ // `iptables` is the archetype: it configures a pre-existing firewall and is
173
+ // deployed onto nothing, so `module_systems` holds no row for it EVER. Rule
174
+ // 3 is unsatisfiable for it by construction, not by timing.
175
+ const FIREWALL = '10.226.1.254';
176
+
177
+ beforeEach(() => {
178
+ seedModule(db, 'iptables');
179
+ });
180
+
181
+ test("allows a pool machine this module's config names", () => {
182
+ seedPoolMachine(db, FIREWALL, 'fw-main');
183
+ seedConfig(db, 'iptables', 'firewall_ip', FIREWALL);
184
+
185
+ expect(
186
+ remoteAccessPolicy('iptables', db).checkTarget({ ipv4_address: FIREWALL }, false),
187
+ ).toEqual({ allowed: true });
188
+ });
189
+
190
+ test('pool membership ALONE is not authorization', () => {
191
+ // Otherwise any module could reach every box celilo knows about, which is
192
+ // far wider than the rule this policy states.
193
+ seedPoolMachine(db, FIREWALL, 'fw-main');
194
+
195
+ const result = remoteAccessPolicy('iptables', db).checkTarget(
196
+ { ipv4_address: FIREWALL },
197
+ false,
198
+ );
199
+ expect(result.allowed).toBe(false);
200
+ });
201
+
202
+ test('config ALONE is not authorization', () => {
203
+ // A module names its own config keys. If naming an address were enough, a
204
+ // module could point itself at any box in the world.
205
+ seedConfig(db, 'iptables', 'firewall_ip', FIREWALL);
206
+
207
+ const result = remoteAccessPolicy('iptables', db).checkTarget(
208
+ { ipv4_address: FIREWALL },
209
+ false,
210
+ );
211
+ expect(result.allowed).toBe(false);
212
+ });
213
+
214
+ test("one module's config does not unlock another module's box", () => {
215
+ seedPoolMachine(db, FIREWALL, 'fw-main');
216
+ seedConfig(db, 'iptables', 'firewall_ip', FIREWALL);
217
+
218
+ // caddy has its own systems and no config naming the firewall.
219
+ const result = remoteAccessPolicy('caddy', db).checkTarget({ ipv4_address: FIREWALL }, false);
220
+ expect(result.allowed).toBe(false);
221
+ });
222
+ });
139
223
  });
@@ -40,7 +40,7 @@
40
40
 
41
41
  import { eq } from 'drizzle-orm';
42
42
  import type { DbClient } from '../db/client';
43
- import { capabilities, moduleSystems } from '../db/schema';
43
+ import { capabilities, machines, moduleConfigs, moduleSystems } from '../db/schema';
44
44
  import type { RemoteAccessPolicy } from '../hooks/remote-broker';
45
45
  import { getModuleSystems } from './deployed-systems';
46
46
  import { ownedSystemModuleIds } from './module-instances';
@@ -60,11 +60,62 @@ export function remoteAccessPolicy(moduleId: string, db: DbClient): RemoteAccess
60
60
  return { allowed: true };
61
61
  }
62
62
 
63
+ if (managesPoolMachine(moduleId, target.ipv4_address, db)) {
64
+ return { allowed: true };
65
+ }
66
+
63
67
  return { allowed: false, message: refusalMessage(moduleId, target.ipv4_address, db) };
64
68
  },
65
69
  };
66
70
  }
67
71
 
72
+ /**
73
+ * A machine in celilo's pool that this module's own config names.
74
+ *
75
+ * ## The case this exists for
76
+ *
77
+ * A module that MANAGES a pre-existing box rather than being deployed onto one
78
+ * — `iptables` configuring the firewall is the archetype — provisions no system
79
+ * and therefore never owns one. `module_systems` records nothing for a
80
+ * config-only module at any point in its lifecycle, so the owned-systems branch
81
+ * above cannot ever pass for it. Without this, such a module has no sanctioned
82
+ * path to the one box it exists to configure, and no amount of waiting fixes it
83
+ * (celilo#1225).
84
+ *
85
+ * ## Why this is not a module granting itself reach
86
+ *
87
+ * Both halves are the OPERATOR's acts, and a module can supply neither:
88
+ *
89
+ * - The address is in the machine pool. `celilo machine add <ip> --ssh-key…`
90
+ * is the operator saying celilo may use its credential on that box. The
91
+ * fleet key's authority is exactly the set of boxes celilo was given, and
92
+ * this is that set — the same reasoning as the non-root carve-out above.
93
+ * - The module's own config names that address. `firewall_ip` is
94
+ * `source: user, required: true`; an operator typed it.
95
+ *
96
+ * A module declares which config KEYS exist, never their values, so it cannot
97
+ * point itself at a box the operator did not both admit to the pool and assign
98
+ * to it. Two independent operator decisions have to agree.
99
+ *
100
+ * ## What it deliberately does not do
101
+ *
102
+ * It does not allow every pool machine. That would let any module reach any box
103
+ * celilo knows, which is far wider than the rule this file states. The
104
+ * intersection is the point: pool membership alone is not authorization, and
105
+ * config alone is not either.
106
+ */
107
+ function managesPoolMachine(moduleId: string, ipv4Address: string, db: DbClient): boolean {
108
+ const inPool = db.select().from(machines).where(eq(machines.ipAddress, ipv4Address)).get();
109
+ if (!inPool) return false;
110
+
111
+ return db
112
+ .select({ value: moduleConfigs.value })
113
+ .from(moduleConfigs)
114
+ .where(eq(moduleConfigs.moduleId, moduleId))
115
+ .all()
116
+ .some((row) => row.value === ipv4Address);
117
+ }
118
+
68
119
  /**
69
120
  * Name the module, the target, and the capability route (task 5.2). The owner
70
121
  * lookup is best-effort colour: the refusal stands whether or not celilo can