@celilo/cli 1.12.0 → 1.14.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.
Files changed (67) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +21 -2
  3. package/package.json +3 -3
  4. package/src/capabilities/public-web-helpers.test.ts +12 -6
  5. package/src/capabilities/public-web-publish.test.ts +24 -13
  6. package/src/capabilities/validation.test.ts +31 -0
  7. package/src/cli/commands/alerts-list.ts +16 -1
  8. package/src/cli/commands/backup-list.test.ts +82 -1
  9. package/src/cli/commands/backup-list.ts +113 -4
  10. package/src/cli/commands/console-get-chain.test.ts +96 -0
  11. package/src/cli/commands/console.ts +130 -0
  12. package/src/cli/commands/module-list.ts +3 -41
  13. package/src/cli/commands/notify-config.test.ts +79 -0
  14. package/src/cli/commands/notify-config.ts +13 -2
  15. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  16. package/src/cli/completion.ts +6 -0
  17. package/src/cli/index.ts +31 -1
  18. package/src/console/closure.test.ts +322 -0
  19. package/src/console/closure.ts +294 -0
  20. package/src/console/control-plane-boundary.test.ts +75 -0
  21. package/src/console/projection.test.ts +293 -0
  22. package/src/console/projection.ts +364 -0
  23. package/src/db/schema.ts +19 -14
  24. package/src/hooks/broker.test.ts +4 -6
  25. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  26. package/src/hooks/capability-loader.ts +67 -10
  27. package/src/hooks/executor.test.ts +85 -4
  28. package/src/hooks/executor.ts +164 -9
  29. package/src/hooks/hook-jail-unreachability.test.ts +173 -0
  30. package/src/hooks/hook-state-dir.test.ts +14 -2
  31. package/src/hooks/hook-timeout.test.ts +2 -4
  32. package/src/hooks/hook-trespass.test.ts +50 -5
  33. package/src/hooks/jail.test.ts +370 -0
  34. package/src/hooks/jail.ts +491 -0
  35. package/src/hooks/mount-set.ts +24 -0
  36. package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
  37. package/src/manifest/contracts/v1.ts +22 -1
  38. package/src/manifest/schema.ts +35 -0
  39. package/src/manifest/validate.test.ts +142 -0
  40. package/src/manifest/validate.ts +126 -4
  41. package/src/module/import.test.ts +116 -0
  42. package/src/module/import.ts +73 -1
  43. package/src/module/packaging/audit.ts +103 -1
  44. package/src/module/packaging/classify-module-path.test.ts +36 -0
  45. package/src/module/packaging/package-rules.ts +18 -0
  46. package/src/module/web-root.ts +35 -0
  47. package/src/policy/capability-shape-baseline.ts +8 -0
  48. package/src/policy/module-business-baseline.ts +26 -2
  49. package/src/policy/module-script-scan.test.ts +22 -0
  50. package/src/policy/module-script-scan.ts +32 -0
  51. package/src/services/alerting/observed-health.ts +71 -0
  52. package/src/services/api-principal-enrolment.test.ts +252 -0
  53. package/src/services/api-principal-enrolment.ts +158 -0
  54. package/src/services/audit/backups.ts +10 -1
  55. package/src/services/backup-create.ts +33 -7
  56. package/src/services/backup-metadata.ts +19 -11
  57. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  58. package/src/services/consumer-cleanup.ts +31 -5
  59. package/src/services/fleet-key.test.ts +47 -0
  60. package/src/services/fleet-key.ts +75 -0
  61. package/src/services/instance-ops.test.ts +302 -0
  62. package/src/services/instance-ops.ts +292 -0
  63. package/src/services/module-instances.test.ts +428 -42
  64. package/src/services/module-instances.ts +219 -26
  65. package/src/services/restore-from-file.ts +6 -5
  66. package/src/services/system-state-stage.test.ts +165 -0
  67. package/src/services/system-state-stage.ts +196 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Enrol a module's read-only principal on the remote API.
3
+ *
4
+ * Named for the MECHANISM, not for the capability it backs. A core service
5
+ * named after one capability is core doing that provider's work, which is the
6
+ * pattern `no-module-business-in-core` Scan C exists to stop — and it caught
7
+ * this file under its first name. Enrolling an API principal is celilo's own
8
+ * job: the row lands in celilo's `api_principals` table and the grants come
9
+ * from celilo's command registry. `control_plane_api` is one caller of it.
10
+ *
11
+ * The consuming module generates an ed25519 keypair on its own system and
12
+ * presents the public half here. The private half never crosses this boundary,
13
+ * which is what makes "the console holds no credential celilo issued it"
14
+ * checkable rather than merely intended.
15
+ *
16
+ * ## The grant set is derived, never passed in
17
+ *
18
+ * `readOnlyGrants(COMMANDS)` is the same classifier that decides whether a CLI
19
+ * leaf is a read. Two consequences, both wanted:
20
+ *
21
+ * A new read verb is picked up WITHOUT anyone editing a list — but only at the
22
+ * next enrolment. The grants are MATERIALISED into the principal row here, so a
23
+ * celilo-mgr that is upgraded without the consumer being redeployed keeps
24
+ * serving the grant set that existed when the consumer last installed. Measured:
25
+ * 27 literal ops in the row, and a read verb celilo gains afterwards is denied.
26
+ *
27
+ * That is a limitation rather than a hole. It fails CLOSED, and it heals on the
28
+ * next redeploy. Making it dynamic means a `ro:*` token resolved at check time,
29
+ * which is a change to the authorization core and a real trade — a principal's
30
+ * authority would then move whenever celilo reclassifies a verb, with nobody
31
+ * re-granting anything. celilo#1153 carries that decision.
32
+ *
33
+ * A new WRITE verb is never granted, however it is named, because the
34
+ * classifier reads the verb token rather than a list somebody maintains. A
35
+ * hand-written list would drift in exactly one direction — wider — because
36
+ * widening it is what unblocks whoever is stuck.
37
+ *
38
+ * There is deliberately no `grants` parameter. The widest thing this can issue
39
+ * is the widest thing it should ever issue, and an argument would turn that
40
+ * into a negotiation with the caller.
41
+ */
42
+
43
+ import type { ControlPlaneApiCapability } from '@celilo/capabilities';
44
+ import { COMMANDS, readOnlyGrants } from '@celilo/core';
45
+ import { grantPrincipal, revokePrincipal } from './api-access';
46
+
47
+ export interface EnrolResult {
48
+ name: string;
49
+ grants: string[];
50
+ created: boolean;
51
+ }
52
+
53
+ /**
54
+ * Every read-only op, sorted.
55
+ *
56
+ * Computed on each call rather than cached at module load. It is derived from a
57
+ * static registry so the answer does not change within a process, but a cached
58
+ * copy is one more thing that can be stale in a long-lived server and the
59
+ * computation is a filter over a few hundred leaves.
60
+ */
61
+ export function controlPlaneReadGrants(): string[] {
62
+ return readOnlyGrants(COMMANDS);
63
+ }
64
+
65
+ /**
66
+ * Enrol a consuming module's principal. Idempotent on `name`.
67
+ *
68
+ * Re-presenting the same name and key is a no-op reporting `created: false`,
69
+ * which is what a redeploy does. Presenting a NEW key for an existing name
70
+ * rotates it — the case a module that regenerated its keypair needs, and one a
71
+ * module that did not will never hit.
72
+ *
73
+ * `grantPrincipal` validates the name and the key, so a malformed public key is
74
+ * refused here rather than written into `authorized_keys` and failing at login
75
+ * with a message about the wrong thing.
76
+ */
77
+ export async function enrolControlPlanePrincipal(params: {
78
+ name: string;
79
+ publicKey: string;
80
+ }): Promise<EnrolResult> {
81
+ const grants = controlPlaneReadGrants();
82
+ const { principal, created } = await grantPrincipal({
83
+ name: params.name,
84
+ publicKey: params.publicKey,
85
+ grants,
86
+ });
87
+
88
+ // `grantPrincipal` already reports false for a principal that existed, and a
89
+ // re-grant that rotates a key is correctly not-a-creation. Re-deriving that
90
+ // here with a second lookup would be two sources for one answer.
91
+ return { name: principal.name, grants, created };
92
+ }
93
+
94
+ /**
95
+ * Remove a principal. Idempotent.
96
+ *
97
+ * Called from a consumer's `on_uninstall` so an uninstalled module does not
98
+ * leave a working key behind. Revoking one that is already gone returns false
99
+ * rather than throwing: an uninstall that fails on tidy-up is worse than one
100
+ * that finds nothing to tidy.
101
+ */
102
+ export async function revokeControlPlanePrincipal(name: string): Promise<boolean> {
103
+ return revokePrincipal(name);
104
+ }
105
+
106
+ /**
107
+ * The `control_plane_api` method table handed to one consuming module.
108
+ *
109
+ * ## Why celilo builds this and no module provides it
110
+ *
111
+ * Every other function capability is implemented by a provider module's script,
112
+ * which the loader imports and calls. That is structurally impossible here.
113
+ * Enrolment writes celilo's own `api_principals` row and derives its grants from
114
+ * celilo's own command registry, and a module script may import nothing but
115
+ * `@celilo/capabilities` — its scripts are copied to a data directory at deploy,
116
+ * so a relative import into the backend breaks. celilo-mgmt has exactly the same
117
+ * limit as any other module here; being the management server's module does not
118
+ * give its SCRIPTS a database handle.
119
+ *
120
+ * The only shape that would let celilo-mgmt "provide" it is core injecting
121
+ * enrol/revoke into a factory context so the module's script forwards them
122
+ * unchanged. Core would still do all the work, and the enrolment would newly
123
+ * depend on the deployed copy of that script being current — a way to fail that
124
+ * does not exist today. So this is framework-granted, like `cross_module_read`.
125
+ *
126
+ * ## Why the caller cannot name someone else's principal
127
+ *
128
+ * `name` is on the request because the contract puts it there, and the only
129
+ * value accepted is the calling module's id. Without that check any module with
130
+ * the capability could rotate another module's key by enrolling under its name,
131
+ * or delete it outright by revoking it — the console's read access removed by a
132
+ * module that has nothing to do with the console.
133
+ *
134
+ * The parameter should come off the contract entirely, the same argument that
135
+ * kept `grants` off it: a caller that can negotiate WHOSE authority this is has
136
+ * the same problem as one that can negotiate how much. That is a breaking change
137
+ * to `@celilo/capabilities`' exported types, so it waits for the next major
138
+ * rather than forcing one (celilo#1196).
139
+ */
140
+ export function buildControlPlaneApi(consumerModuleId: string): ControlPlaneApiCapability {
141
+ function requireOwnPrincipal(name: string, verb: string): void {
142
+ if (name === consumerModuleId) return;
143
+ throw new Error(
144
+ `Module '${consumerModuleId}' tried to ${verb} the API principal '${name}'. A module may only ${verb} its own principal, named for its module id.`,
145
+ );
146
+ }
147
+
148
+ return {
149
+ async enrol_principal(request) {
150
+ requireOwnPrincipal(request.name, 'enrol');
151
+ return enrolControlPlanePrincipal(request);
152
+ },
153
+ async revoke_principal(request) {
154
+ requireOwnPrincipal(request.name, 'revoke');
155
+ return { revoked: await revokeControlPlanePrincipal(request.name) };
156
+ },
157
+ };
158
+ }
@@ -65,7 +65,16 @@ export function backupStaleThresholdMs(cadence: Cadence): number | null {
65
65
  return interval + Math.max(HOUR, interval * 0.1);
66
66
  }
67
67
 
68
- function moduleHasBackupHook(manifest: ModuleManifest): boolean {
68
+ /**
69
+ * Whether celilo can back this module up at all.
70
+ *
71
+ * Exported because it is the gate `backup-create.ts` actually applies, and the
72
+ * console has to draw the same distinction. On the live fleet five of 23
73
+ * deployed modules declare the hook: the other eighteen are not overdue and not
74
+ * failing, there is simply nothing to run (celilo#1131). A second copy of this
75
+ * test elsewhere would eventually disagree about which eighteen.
76
+ */
77
+ export function moduleHasBackupHook(manifest: ModuleManifest): boolean {
69
78
  return Boolean(manifest.hooks?.on_backup);
70
79
  }
71
80
 
@@ -6,7 +6,7 @@
6
6
  import { copyFileSync, existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import { eq } from 'drizzle-orm';
9
- import { getDbPath } from '../config/paths';
9
+ import { getDbPath, getMasterKeyPath } from '../config/paths';
10
10
  import { getDb } from '../db/client';
11
11
  import { moduleConfigs, modules, secrets as secretsTable } from '../db/schema';
12
12
  import { invokeHook } from '../hooks/executor';
@@ -41,6 +41,7 @@ import {
41
41
  refuseIfInFlight,
42
42
  startOperation,
43
43
  } from './module-operations';
44
+ import { stageSystemState } from './system-state-stage';
44
45
 
45
46
  export interface BackupCreateOptions {
46
47
  storageId?: string;
@@ -345,19 +346,44 @@ export async function createModuleBackup(
345
346
  const { configMap, secretMap } = await buildModuleContext(moduleId);
346
347
  const logger = createConsoleLogger(moduleId, 'on_backup');
347
348
 
348
- // Inject the framework-resolved DB path so the hook need not re-derive the
349
- // data dir from an env var the CLI may not export (ISS-0014). getDbPath()
350
- // is celilo's single source of truth for where the DB lives, regardless of
351
- // CELILO_DATA_DIR / XDG / explicit override; the hook derives the data dir
352
- // (master.key, fleet .ssh) as dirname(db_path) from this.
353
349
  const hookInputs: Record<string, unknown> = {
354
350
  backup_dir: dataDir,
355
- db_path: getDbPath(),
356
351
  };
352
+ // Both privileged inputs ride the same allow-list. `cross_module_root`
353
+ // mirrors OTHER modules' terraform state; `system_state_root` stages
354
+ // celilo's own — the DB snapshot, master.key, the fleet key, and every
355
+ // module's lean source.
356
+ //
357
+ // Staging is what lets celilo back ITSELF up without exempting
358
+ // celilo-mgmt from the hook jail (design D9b). The hook used to receive
359
+ // `db_path` and walk out from `dirname(db_path)` into celilo's data
360
+ // directory; it never read those bytes, it copied them, so the framework
361
+ // does the copying and the data directory stays out of the mount set.
357
362
  if (moduleHasCrossModuleRead(manifest)) {
358
363
  const crossModuleRoot = join(tempDir, 'cross-module-read');
359
364
  materializeCrossModuleRoot(crossModuleRoot, moduleId);
360
365
  hookInputs.cross_module_root = crossModuleRoot;
366
+
367
+ const staged = stageSystemState(join(tempDir, 'system-state'));
368
+ hookInputs.system_state_root = staged.root;
369
+ if (!staged.masterKeyStaged) {
370
+ logger.warn(
371
+ `master.key not found at ${getMasterKeyPath()} — secrets in the DB snapshot will be unreadable on restore.`,
372
+ );
373
+ }
374
+ if (!staged.fleetSshStaged) {
375
+ logger.info(
376
+ 'No fleet SSH keypair on this box — none staged (celilo-mgmt may not have been deployed yet).',
377
+ );
378
+ }
379
+ logger.info(`Staged celilo state: ${staged.moduleSourceCount} module source tree(s)`);
380
+ if (staged.skippedLarge.length > 0) {
381
+ // No silent caps: name what was dropped. These are build artifacts the
382
+ // target rebuilds on deploy.
383
+ logger.info(
384
+ `Skipped ${staged.skippedLarge.length} large/non-source file(s) (rebuilt on deploy): ${staged.skippedLarge.join(', ')}`,
385
+ );
386
+ }
361
387
  }
362
388
 
363
389
  // Execute on_backup hook — it writes artifacts to dataDir (envelope/data/)
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { randomUUID } from 'node:crypto';
7
- import { and, desc, eq, gt, isNotNull, like } from 'drizzle-orm';
7
+ import { and, desc, eq, gt, gte, isNotNull, like } from 'drizzle-orm';
8
8
  import { getDb } from '../db/client';
9
9
  import { type Backup, type BackupStatus, type BackupType, backups } from '../db/schema';
10
10
  import type { BackupHistory } from './backup-schedule';
@@ -140,21 +140,29 @@ export function getBackup(id: string): Backup | null {
140
140
  export function listBackups(options?: {
141
141
  moduleId?: string;
142
142
  limit?: number;
143
+ /**
144
+ * Only attempts started at or after this instant (epoch ms).
145
+ *
146
+ * A WINDOW rather than a bigger `limit`, because the two answer different
147
+ * questions and only one of them is stable. "The last 500 attempts" spans a
148
+ * week for a module failing hourly and a year for one succeeding daily, so a
149
+ * caller asking for a fixed period cannot express it as a count. The console
150
+ * needs a period: it draws days, and a row missing from its answer renders as
151
+ * a day on which nothing ran.
152
+ */
153
+ since?: number;
143
154
  }): Backup[] {
144
155
  const db = getDb();
145
156
  const limit = options?.limit ?? 20;
146
157
 
147
- if (options?.moduleId) {
148
- return db
149
- .select()
150
- .from(backups)
151
- .where(eq(backups.moduleId, options.moduleId))
152
- .orderBy(desc(backups.startedAt))
153
- .limit(limit)
154
- .all();
155
- }
158
+ const filters = [
159
+ options?.moduleId ? eq(backups.moduleId, options.moduleId) : undefined,
160
+ options?.since !== undefined ? gte(backups.startedAt, new Date(options.since)) : undefined,
161
+ ].filter((clause) => clause !== undefined);
156
162
 
157
- return db.select().from(backups).orderBy(desc(backups.startedAt)).limit(limit).all();
163
+ const query = db.select().from(backups);
164
+ const filtered = filters.length > 0 ? query.where(and(...filters)) : query;
165
+ return filtered.orderBy(desc(backups.startedAt)).limit(limit).all();
158
166
  }
159
167
 
160
168
  /**
@@ -1,19 +1,3 @@
1
- /**
2
- * Behavior tests for the celilo-mgmt module's on_backup / on_restore
3
- * hooks (Phase 3 of openspec/specs/management-server-backup/spec.md).
4
- *
5
- * We import the hook scripts directly and call their handlers with a
6
- * synthesized HookContext, then assert on the side-effects (files
7
- * written to backup_dir / restore staging dirs). This catches
8
- * regressions in the hook bodies without spinning up the full
9
- * deploy/backup pipeline.
10
- *
11
- * What this DOESN'T test: invokeHook validation, end-to-end backup
12
- * envelope round-trip. Those live in backup-envelope-roundtrip.test.ts
13
- * and (eventually) a fresh-box e2e.
14
- */
15
-
16
- import { Database } from 'bun:sqlite';
17
1
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
18
2
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
19
3
  import { tmpdir } from 'node:os';
@@ -82,13 +66,13 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
82
66
  let dir: string;
83
67
  let backupDir: string;
84
68
  let crossModuleRoot: string;
85
- let dbPath: string;
86
- let keyPath: string;
69
+ let systemStateRoot: string;
87
70
 
88
71
  beforeEach(() => {
89
72
  dir = mkdtempSync(join(tmpdir(), 'celilo-mgmt-backup-test-'));
90
73
  backupDir = join(dir, 'backup-dir');
91
74
  mkdirSync(backupDir, { recursive: true });
75
+
92
76
  crossModuleRoot = join(dir, 'cross-module-root');
93
77
  mkdirSync(join(crossModuleRoot, 'modules', 'caddy', 'terraform'), { recursive: true });
94
78
  writeFileSync(
@@ -104,25 +88,20 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
104
88
  }),
105
89
  );
106
90
 
107
- dbPath = join(dir, 'celilo.db');
108
- // A REAL (minimal) SQLite DB. on_backup snapshots it via bun:sqlite
109
- // serialize(), which requires a valid database file — a plain byte blob
110
- // makes the serializer read a garbage page count and OOM. One table with a
111
- // row is enough to exercise the snapshot path.
112
- const seed = new Database(dbPath);
113
- seed.run('CREATE TABLE backup_probe (id INTEGER PRIMARY KEY, v TEXT)');
114
- seed.run("INSERT INTO backup_probe (v) VALUES ('hello')");
115
- seed.close();
116
- process.env.CELILO_DB_PATH = dbPath;
117
-
118
- keyPath = join(dir, 'master.key');
119
- writeFileSync(keyPath, 'fake-master-key-32-bytes-padding!');
120
- process.env.CELILO_MASTER_KEY_PATH = keyPath;
91
+ // celilo's own state, as the framework stages it before invoking the hook
92
+ // (services/system-state-stage.ts). The hook reads nothing outside this
93
+ // directory and cross_module_root — that is what design D9b bought, and
94
+ // it is why there is no CELILO_DB_PATH or master.key on disk here.
95
+ systemStateRoot = join(dir, 'system-state');
96
+ mkdirSync(join(systemStateRoot, 'ssh'), { recursive: true });
97
+ mkdirSync(join(systemStateRoot, 'module_src', 'caddy'), { recursive: true });
98
+ writeFileSync(join(systemStateRoot, 'celilo.db'), 'SQLite format 3\u0000snapshot');
99
+ writeFileSync(join(systemStateRoot, 'master.key'), 'fake-master-key-32-bytes-padding!');
100
+ writeFileSync(join(systemStateRoot, 'ssh', 'id_ed25519'), 'PRIVATE');
101
+ writeFileSync(join(systemStateRoot, 'module_src', 'caddy', 'manifest.yml'), 'id: caddy');
121
102
  });
122
103
 
123
104
  afterEach(() => {
124
- process.env.CELILO_DB_PATH = undefined;
125
- process.env.CELILO_MASTER_KEY_PATH = undefined;
126
105
  try {
127
106
  rmSync(dir, { recursive: true, force: true });
128
107
  } catch {
@@ -130,16 +109,20 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
130
109
  }
131
110
  });
132
111
 
133
- it('writes celilo.db, master.key, machine-pool.json, and cross_module_state into backup_dir', async () => {
112
+ it('lays the staged celilo state into backup_dir alongside the cross-module state', async () => {
134
113
  const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
135
- const ctx = buildContext({
136
- backup_dir: backupDir,
137
- cross_module_root: crossModuleRoot,
138
- });
139
- const result = (await hook(ctx)) as BackupHookOutput;
114
+ const result = (await hook(
115
+ buildContext({
116
+ backup_dir: backupDir,
117
+ cross_module_root: crossModuleRoot,
118
+ system_state_root: systemStateRoot,
119
+ }),
120
+ )) as BackupHookOutput;
140
121
 
141
122
  expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
142
123
  expect(existsSync(join(backupDir, 'master.key'))).toBe(true);
124
+ expect(existsSync(join(backupDir, 'ssh', 'id_ed25519'))).toBe(true);
125
+ expect(existsSync(join(backupDir, 'module_src', 'caddy', 'manifest.yml'))).toBe(true);
143
126
  expect(existsSync(join(backupDir, 'machine-pool.json'))).toBe(true);
144
127
  expect(
145
128
  existsSync(
@@ -153,39 +136,27 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
153
136
  expect(result.size_bytes).toBeGreaterThan(0);
154
137
  });
155
138
 
156
- it('captures LEAN module source (excludes generated/, node_modules/, large build artifacts)', async () => {
157
- // stateDir = dirname(db_path) = dir; on_backup reads dir/modules/<id>.
158
- const modSrc = join(dir, 'modules', 'caddy');
159
- mkdirSync(join(modSrc, 'scripts', 'node_modules', '@celilo'), { recursive: true });
160
- mkdirSync(join(modSrc, 'generated', 'terraform'), { recursive: true });
161
- mkdirSync(join(modSrc, 'ansible', 'files'), { recursive: true });
162
- writeFileSync(join(modSrc, 'manifest.yml'), 'id: caddy');
163
- writeFileSync(join(modSrc, 'scripts', 'hook.ts'), '// hook');
164
- writeFileSync(join(modSrc, 'generated', 'terraform', 'main.tf'), 'resource {}');
165
- writeFileSync(join(modSrc, 'scripts', 'node_modules', '@celilo', 'dep.js'), '// vendored');
166
- // A >2MB "compiled binary" sitting in source — must be skipped by size.
167
- writeFileSync(join(modSrc, 'ansible', 'files', 'server-bin'), Buffer.alloc(3 * 1024 * 1024));
168
-
139
+ it('refuses when the framework did not stage celilo state', async () => {
169
140
  const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
170
- await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
171
141
 
172
- // Source files captured...
173
- expect(existsSync(join(backupDir, 'module_src', 'caddy', 'manifest.yml'))).toBe(true);
174
- expect(existsSync(join(backupDir, 'module_src', 'caddy', 'scripts', 'hook.ts'))).toBe(true);
175
- // ...but build/vendored content excluded (keeps the backup small enough to
176
- // not OOM the in-memory tar+encrypt — turnip's full dirs were ~1.6GB).
177
- expect(existsSync(join(backupDir, 'module_src', 'caddy', 'generated'))).toBe(false);
178
- expect(existsSync(join(backupDir, 'module_src', 'caddy', 'scripts', 'node_modules'))).toBe(
179
- false,
180
- );
142
+ // Silence is the dangerous outcome here. Without the DB and the master
143
+ // key an envelope restores to nothing, and a hook that shrugged and
144
+ // returned success would report artifact_count > 0 for the machine-pool
145
+ // and cross-module files alone. Found at restore, which is too late.
181
146
  expect(
182
- existsSync(join(backupDir, 'module_src', 'caddy', 'ansible', 'files', 'server-bin')),
183
- ).toBe(false);
147
+ hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot })),
148
+ ).rejects.toThrow(/system_state_root was not provided/);
184
149
  });
185
150
 
186
151
  it('machine-pool.json is valid JSON (array)', async () => {
187
152
  const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
188
- await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
153
+ await hook(
154
+ buildContext({
155
+ backup_dir: backupDir,
156
+ cross_module_root: crossModuleRoot,
157
+ system_state_root: systemStateRoot,
158
+ }),
159
+ );
189
160
 
190
161
  const machinePool = JSON.parse(readFileSync(join(backupDir, 'machine-pool.json'), 'utf-8'));
191
162
  expect(Array.isArray(machinePool)).toBe(true);
@@ -194,25 +165,13 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
194
165
  it('proceeds (with a warning) when cross_module_root is missing', async () => {
195
166
  const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
196
167
  const result = (await hook(
197
- buildContext({ backup_dir: backupDir }), // no cross_module_root
168
+ buildContext({ backup_dir: backupDir, system_state_root: systemStateRoot }),
198
169
  )) as BackupHookOutput;
199
170
 
200
171
  expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
201
172
  expect(existsSync(join(backupDir, 'cross_module_state'))).toBe(false);
202
173
  expect(result.schema_version).toBe('1.1');
203
174
  });
204
-
205
- it('proceeds (with a warning) when master.key is missing on disk', async () => {
206
- rmSync(keyPath);
207
- const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
208
- const result = (await hook(
209
- buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }),
210
- )) as BackupHookOutput;
211
-
212
- expect(existsSync(join(backupDir, 'master.key'))).toBe(false);
213
- expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
214
- expect(result.artifact_count).toBeGreaterThan(0);
215
- });
216
175
  });
217
176
 
218
177
  describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_restore', () => {
@@ -48,6 +48,21 @@ export interface CleanupTarget {
48
48
  skip?: CleanupSkipReason;
49
49
  }
50
50
 
51
+ /**
52
+ * The only part of a manifest this plan reads.
53
+ *
54
+ * Narrower than `ModuleManifest` on purpose. A full manifest is a large,
55
+ * strictly-validated shape, and the console reads manifests straight out of the
56
+ * `modules` table where they are stored as opaque JSON. Asking for the whole
57
+ * shape would force either a full re-validation on every poll or a cast, and a
58
+ * cast at a trust boundary is the thing that eventually bites. `ModuleManifest`
59
+ * satisfies this structurally, so existing callers are unaffected.
60
+ */
61
+ export interface ConsumedCapabilities {
62
+ requires?: { capabilities?: readonly { name: string }[] };
63
+ optional?: { capabilities?: readonly { name: string }[] };
64
+ }
65
+
51
66
  export interface ProviderRow {
52
67
  moduleId: string;
53
68
  capabilityName: string;
@@ -76,7 +91,7 @@ export interface ProviderState {
76
91
  */
77
92
  export function planConsumerCleanup(
78
93
  consumer: string,
79
- manifest: ModuleManifest,
94
+ manifest: ConsumedCapabilities,
80
95
  providerRows: ProviderRow[],
81
96
  providerStates: ProviderState[],
82
97
  ): CleanupTarget[] {
@@ -112,16 +127,27 @@ export function planConsumerCleanup(
112
127
  .sort((a, b) => a.providerId.localeCompare(b.providerId));
113
128
  }
114
129
 
130
+ /**
131
+ * Every (module, capability) provider row.
132
+ *
133
+ * Exported because the console's closure walk needs the same edge this plan
134
+ * does, and reads it for many modules rather than one. Two copies of this query
135
+ * would be two definitions of what a provider is.
136
+ */
137
+ export function loadCapabilityProviderRows(db: DbClient): ProviderRow[] {
138
+ return db
139
+ .select({ moduleId: capabilities.moduleId, capabilityName: capabilities.capabilityName })
140
+ .from(capabilities)
141
+ .all();
142
+ }
143
+
115
144
  /** Read the plan's inputs out of the DB. */
116
145
  export function loadConsumerCleanupPlan(
117
146
  consumer: string,
118
147
  manifest: ModuleManifest,
119
148
  db: DbClient,
120
149
  ): CleanupTarget[] {
121
- const providerRows = db
122
- .select({ moduleId: capabilities.moduleId, capabilityName: capabilities.capabilityName })
123
- .from(capabilities)
124
- .all();
150
+ const providerRows = loadCapabilityProviderRows(db);
125
151
  const providerStates = db
126
152
  .select({ moduleId: modules.id, state: modules.state })
127
153
  .from(modules)
@@ -0,0 +1,47 @@
1
+ /**
2
+ * celilo's fleet SSH keypair, minted by the framework rather than by
3
+ * celilo-mgmt's on_install (design D9b of
4
+ * openspec/changes/hook-process-boundary).
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
8
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { ensureFleetKey, getFleetSshDir } from './fleet-key';
12
+
13
+ describe('ensureFleetKey', () => {
14
+ let dataDir: string;
15
+
16
+ beforeEach(() => {
17
+ dataDir = mkdtempSync(join(tmpdir(), 'celilo-fleet-key-'));
18
+ process.env.CELILO_DB_PATH = join(dataDir, 'celilo.db');
19
+ });
20
+
21
+ afterEach(() => {
22
+ process.env.CELILO_DB_PATH = undefined;
23
+ rmSync(dataDir, { recursive: true, force: true });
24
+ });
25
+
26
+ it('mints an ed25519 keypair next to the DB and returns the public half', () => {
27
+ const key = ensureFleetKey();
28
+
29
+ expect(key.created).toBe(true);
30
+ expect(key.publicKey.startsWith('ssh-ed25519 ')).toBe(true);
31
+ expect(existsSync(join(dataDir, '.ssh', 'id_ed25519'))).toBe(true);
32
+ expect(readFileSync(join(dataDir, '.ssh', 'id_ed25519.pub'), 'utf-8').trim()).toBe(
33
+ key.publicKey,
34
+ );
35
+ expect(getFleetSshDir()).toBe(join(dataDir, '.ssh'));
36
+ });
37
+
38
+ it('reuses an existing key instead of re-keying', () => {
39
+ const first = ensureFleetKey();
40
+ const second = ensureFleetKey();
41
+
42
+ // Re-keying would strand every machine whose authorized_keys holds the
43
+ // old public half, and a redeploy calls this every time.
44
+ expect(second.created).toBe(false);
45
+ expect(second.publicKey).toBe(first.publicKey);
46
+ });
47
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * celilo's fleet SSH keypair — the key celilo authenticates to managed
3
+ * machines with. The DB carries only the public half (`ssh.public_key`);
4
+ * the private half lives on disk and never leaves the management box.
5
+ *
6
+ * It used to be minted by celilo-mgmt's `on_install`, which derived
7
+ * `dirname(config.db_path)` and wrote into celilo's data directory from
8
+ * inside a module hook. That is a write into the one directory the hook
9
+ * jail exists to keep out of the mount set
10
+ * (openspec/changes/hook-process-boundary, design D9b): staging covers
11
+ * copies OUT of celilo's state, and nothing covers writes IN.
12
+ *
13
+ * So minting moved here. celilo owns the key's lifecycle, and "create it
14
+ * if absent" wants to be idempotent and tested once rather than in each
15
+ * module that reaches for it.
16
+ */
17
+
18
+ import { execFileSync } from 'node:child_process';
19
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+ import { getDbPath } from '../config/paths';
22
+
23
+ /**
24
+ * Where the fleet keypair lives.
25
+ *
26
+ * Next to the DB, not under `getDataDir()`. Those are the same directory
27
+ * on a deb install (`CELILO_DATA_DIR=/var/celilo`, db at
28
+ * `/var/celilo/celilo.db`) and differ only when `CELILO_DB_PATH` points
29
+ * somewhere custom. Following the DB is what `on_install` did, so it is
30
+ * where every existing box's key already sits, and restore has followed
31
+ * the same rule since it was written (`applyStagedSystemFiles`).
32
+ *
33
+ * One exported helper rather than two hand-derived joins is the point:
34
+ * mint and restore can no longer drift to different directories.
35
+ */
36
+ export function getFleetSshDir(): string {
37
+ return join(dirname(getDbPath()), '.ssh');
38
+ }
39
+
40
+ export interface FleetKey {
41
+ /** The public half, as it goes into `ssh.public_key` and authorized_keys. */
42
+ publicKey: string;
43
+ /** True when this call minted the key; false when it was already there. */
44
+ created: boolean;
45
+ }
46
+
47
+ /**
48
+ * Ensure the fleet keypair exists and return its public half.
49
+ *
50
+ * Idempotent: an existing key is reused, never regenerated. Re-keying
51
+ * would silently strand every machine whose authorized_keys holds the old
52
+ * public half, and a redeploy must not do that.
53
+ *
54
+ * Permissions are left to `mkdirSync`'s mode and to ssh-keygen, which
55
+ * `fchmod`s the private half to 0600 itself. An explicit chmod pass was
56
+ * written here first and then removed: umask only ever REMOVES mode bits, so
57
+ * neither the directory nor the key can come out wider than asked for, and no
58
+ * test could be made to fail without it.
59
+ */
60
+ export function ensureFleetKey(): FleetKey {
61
+ const sshDir = getFleetSshDir();
62
+ const keyPath = join(sshDir, 'id_ed25519');
63
+ const publicKeyPath = `${keyPath}.pub`;
64
+
65
+ if (existsSync(publicKeyPath)) {
66
+ return { publicKey: readFileSync(publicKeyPath, 'utf-8').trim(), created: false };
67
+ }
68
+
69
+ mkdirSync(sshDir, { recursive: true, mode: 0o700 });
70
+ execFileSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', keyPath, '-C', 'celilo-fleet'], {
71
+ stdio: 'pipe',
72
+ });
73
+
74
+ return { publicKey: readFileSync(publicKeyPath, 'utf-8').trim(), created: true };
75
+ }