@celilo/cli 1.13.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 (34) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +6 -2
  3. package/package.json +2 -2
  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/console-get-chain.test.ts +96 -0
  8. package/src/cli/commands/console.ts +13 -5
  9. package/src/cli/commands/notify-config.test.ts +79 -0
  10. package/src/cli/commands/notify-config.ts +13 -2
  11. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  12. package/src/cli/completion.ts +1 -0
  13. package/src/cli/index.ts +6 -0
  14. package/src/console/closure.test.ts +76 -0
  15. package/src/console/closure.ts +87 -1
  16. package/src/console/projection.test.ts +63 -1
  17. package/src/console/projection.ts +39 -2
  18. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  19. package/src/hooks/capability-loader.ts +67 -10
  20. package/src/manifest/contracts/v1.ts +22 -1
  21. package/src/manifest/validate.ts +25 -4
  22. package/src/module/web-root.ts +35 -0
  23. package/src/policy/module-business-baseline.ts +14 -2
  24. package/src/policy/module-script-scan.test.ts +22 -0
  25. package/src/policy/module-script-scan.ts +32 -0
  26. package/src/services/api-principal-enrolment.test.ts +73 -0
  27. package/src/services/api-principal-enrolment.ts +55 -0
  28. package/src/services/backup-create.ts +33 -7
  29. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  30. package/src/services/fleet-key.test.ts +47 -0
  31. package/src/services/fleet-key.ts +75 -0
  32. package/src/services/restore-from-file.ts +6 -5
  33. package/src/services/system-state-stage.test.ts +165 -0
  34. package/src/services/system-state-stage.ts +196 -0
@@ -45,7 +45,8 @@ export interface ContractField {
45
45
  * A field carrying a path-shaped value with no annotation here is a defect.
46
46
  * `db_path` was one for months — passed by `backup-create.ts` and declared
47
47
  * nowhere, so anything reasoning from this table was wrong about what a
48
- * backup hook receives.
48
+ * backup hook receives. It is gone: design D9b replaced it with the staged
49
+ * `system_state_root` below, which is declared and annotated.
49
50
  */
50
51
  path?: { access: PathAccess };
51
52
  }
@@ -152,6 +153,26 @@ export const V1_HOOKS: ContractHooks = {
152
153
  * terraform.tfstate.backup
153
154
  */
154
155
  cross_module_root: { required: false, path: { access: 'read' } },
156
+ /**
157
+ * Path to a directory holding copies of celilo's OWN state: a
158
+ * WAL-correct `celilo.db` snapshot, `master.key`, the fleet `ssh/`
159
+ * keypair, and `module_src/<id>/` for every module's lean source.
160
+ * Populated by the framework under the same `cross_module_read`
161
+ * allow-list as `cross_module_root`; only celilo-mgmt receives it.
162
+ *
163
+ * This input is what makes celilo's own backup possible WITHOUT
164
+ * exempting celilo-mgmt from the hook jail (design D9b). The hook
165
+ * never read those bytes — it copied them into `backup_dir` — so the
166
+ * framework copies them into a directory it creates and hands over,
167
+ * and the data directory itself stays out of every mount set.
168
+ *
169
+ * It replaces `db_path`, which `backup-create.ts` passed for months
170
+ * while the contract declared nothing about it. That is the omission
171
+ * the docblock on `ContractField.path` names: anything reasoning from
172
+ * this table was wrong about what a backup hook receives, and a
173
+ * mount-set derivation walking the declared inputs could not see it.
174
+ */
175
+ system_state_root: { required: false, path: { access: 'read' } },
155
176
  },
156
177
  outputs: {
157
178
  artifact_count: { required: true },
@@ -202,14 +202,35 @@ const PRIVILEGED_CAPABILITY_ALLOW_LIST: Record<string, readonly string[]> = {
202
202
  cross_module_read: ['celilo-mgmt'],
203
203
  };
204
204
 
205
+ /**
206
+ * Framework-granted capabilities that any module may declare.
207
+ *
208
+ * Same satisfaction path as the allow-list above — celilo supplies them, so no
209
+ * module provides them and the resolver must not go looking for one — but a
210
+ * different authorization story, and the two were welded together while
211
+ * `cross_module_read` was the only entry.
212
+ *
213
+ * `cross_module_read` hands a module every OTHER module's terraform state, so
214
+ * who may hold it is a per-module trust decision and the list is the gate.
215
+ * `control_plane_api` mints a principal whose grants are derived from
216
+ * `readOnlyGrants(COMMANDS)` and cannot be widened by the caller, so the worst
217
+ * a wrongly-declared consumer obtains is celilo's own read verbs. The gate is
218
+ * the `requires` line, which a reviewer reads before the module is ever
219
+ * imported (web-ui-console D7b).
220
+ *
221
+ * An allow-list here was considered and rejected: it would put consumer module
222
+ * ids in core, so every new console-shaped consumer would need a core change
223
+ * and a `.deb` release before it could be imported at all.
224
+ */
225
+ const FRAMEWORK_GRANTED_CAPABILITIES: ReadonlySet<string> = new Set(['control_plane_api']);
226
+
205
227
  /**
206
228
  * Whether `name` is a framework-granted privilege rather than a normal
207
- * provider-backed capability. Privileges are satisfied by the framework
208
- * (gated by the allow-list above), so the capability-provider resolver
209
- * must NOT expect a module to "provide" them.
229
+ * provider-backed capability. Privileges are satisfied by the framework, so
230
+ * the capability-provider resolver must NOT expect a module to "provide" them.
210
231
  */
211
232
  export function isPrivilegedCapability(name: string): boolean {
212
- return name in PRIVILEGED_CAPABILITY_ALLOW_LIST;
233
+ return name in PRIVILEGED_CAPABILITY_ALLOW_LIST || FRAMEWORK_GRANTED_CAPABILITIES.has(name);
213
234
  }
214
235
 
215
236
  /**
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Where a module's built web site lives on this host.
3
+ *
4
+ * ONE application of the `site/dist` convention. It is deliberately not applied
5
+ * inside the providers: `public_web` is framework-provided, but `private_web`
6
+ * and `external_web` are implemented by modules (`caddy-internal`,
7
+ * `generic-cpanel-hosting-provider`), and three copies of one convention is how
8
+ * the copies drift.
9
+ *
10
+ * Core is also the only party that CAN resolve it in the case that matters.
11
+ * When the `public_web` provider converges a rebuilt host, no consumer is
12
+ * running to be asked where its bytes are (design D10). So the path has to come
13
+ * from the module id, and `modules.source_path` is what core already holds.
14
+ */
15
+
16
+ import { join } from 'node:path';
17
+ import { MODULE_WEB_ROOT } from '@celilo/capabilities';
18
+ import { eq } from 'drizzle-orm';
19
+ import type { DbClient } from '../db/client';
20
+ import { modules } from '../db/schema';
21
+
22
+ /**
23
+ * Absolute path to `<module root>/site/dist`, or undefined when no such module
24
+ * is installed.
25
+ *
26
+ * Does NOT check that the directory exists. Resolving a path and deciding
27
+ * whether it is usable are different jobs, and the second one belongs where the
28
+ * error can name the caller (Rule 10.1). `createPublicWeb`'s `requireWebRoot`
29
+ * does the existence check and says what to ship.
30
+ */
31
+ export function resolveModuleWebRoot(moduleId: string, db: DbClient): string | undefined {
32
+ const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
33
+ if (!module) return undefined;
34
+ return join(module.sourcePath, MODULE_WEB_ROOT);
35
+ }
@@ -255,6 +255,18 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
255
255
  count: 1,
256
256
  why: "PERMANENT — validating a manifest's declared capability names against the registry is core's job",
257
257
  },
258
+ {
259
+ file: 'apps/celilo/src/manifest/validate.ts',
260
+ capability: 'control_plane_api',
261
+ count: 1,
262
+ why: 'PERMANENT — framework-granted, so celilo satisfies it and no module provides it; the resolver has to be told that by name (web-ui-console D7b)',
263
+ },
264
+ {
265
+ file: 'apps/celilo/src/hooks/capability-loader.ts',
266
+ capability: 'control_plane_api',
267
+ count: 4,
268
+ why: "PERMANENT — framework-granted like web_routes above; core builds the method table because enrolment writes celilo's own api_principals row, which no module script can reach (web-ui-console D7b)",
269
+ },
258
270
  {
259
271
  file: 'apps/celilo/src/services/alerting/inbound-poller.ts',
260
272
  capability: 'notification',
@@ -420,8 +432,8 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
420
432
  {
421
433
  file: 'packages/capabilities/src/firewall.ts',
422
434
  capability: 'firewall',
423
- count: 2,
424
- why: 'PERMANENT — X10, the firewall capability contract',
435
+ count: 3,
436
+ why: 'PERMANENT — X10, the firewall capability contract. The third is FIREWALL_CAPABILITY_NAME, exported so core queries the provider registry without spelling the literal; it moved a name OUT of apps/celilo/src/console/projection.ts rather than adding one',
425
437
  },
426
438
  {
427
439
  file: 'packages/capabilities/src/public-web.ts',
@@ -38,6 +38,28 @@ describe('module script scan — SSH rules', () => {
38
38
  });
39
39
  });
40
40
 
41
+ describe('module script scan — namespace tools (hygiene, not a boundary)', () => {
42
+ const NS = 'namespace escape (bwrap/unshare/nsenter)';
43
+
44
+ // Each token asserted separately and by name. An alternation that silently
45
+ // loses one leg still passes a test that only ever exercises the first.
46
+ it.each([
47
+ ['bwrap', 'run(`bwrap --dev-bind / / ${cmd}`);'],
48
+ ['unshare', "run('unshare --user --map-root-user id');"],
49
+ ['nsenter', "run('nsenter -t 1 -m -- ls /');"],
50
+ ['CLONE_NEWUSER', 'const flags = CLONE_NEWUSER | CLONE_NEWNS;'],
51
+ ])('catches %s', (_token, src) => {
52
+ expect(rules(src)).toContain(NS);
53
+ });
54
+
55
+ // The word boundaries are the whole of this rule's false-positive defence, so
56
+ // the negative case has to be a word that CONTAINS a token. `buildSharedConfig`
57
+ // would pass whether or not the `\b`s are there, which makes it no test at all.
58
+ it('does not fire on an identifier that merely contains a token', () => {
59
+ expect(rules('const unshared = pending.filter((p) => !p.shared);')).toEqual([]);
60
+ });
61
+ });
62
+
41
63
  describe('module script scan — raw-exec escape hatch', () => {
42
64
  it('flags a runAppCommand call with no justification', () => {
43
65
  expect(rules('const r = runAppCommand(system, "rm -f /tmp/x", run);')).toContain(
@@ -3,6 +3,11 @@
3
3
  * **modules never hand-build SSH, and the one sanctioned raw-exec path is
4
4
  * always justified in writing.**
5
5
  *
6
+ * It carries a second, weaker rule class as well — the namespace-tool rule
7
+ * below. Read that rule's own comment before touching it: it is hygiene and it
8
+ * is **not** a boundary, and the two classes in this file are not the same kind
9
+ * of thing.
10
+ *
6
11
  * ONE definition of the rules, used by both enforcement points:
7
12
  *
8
13
  * - `apps/celilo/src/policy/no-hand-built-ssh.test.ts` — every in-repo module,
@@ -63,6 +68,33 @@ const PATTERN_RULES: Array<{ rule: string; re: RegExp; hint: string }> = [
63
68
  re: /(?:from|require\()\s*['"]ssh2['"]/,
64
69
  hint: 'Modules never open their own SSH connection — use the primitives. See MODULE_PRIMITIVES.md.',
65
70
  },
71
+ /**
72
+ * HYGIENE, NOT A MITIGATION (openspec/changes/hook-process-boundary, task 4.2e,
73
+ * design D9). The boundary is the jail; this line is a courtesy to an honest
74
+ * author, and describing it as a security control would be a lie in three
75
+ * separate ways:
76
+ *
77
+ * - a hook runs arbitrary code, so anything this pattern catches can be
78
+ * spelled another way by anyone who wants to;
79
+ * - the scan walks in-repo `scripts/` and staged `.netapp` payloads, so it
80
+ * never sees a registry module or an out-of-repo one at all;
81
+ * - celilo#1014 is a live example of an exemption in this very file reading
82
+ * wider than its author intended.
83
+ *
84
+ * What it is for: a module that shells out to `bwrap`/`unshare`/`nsenter`
85
+ * gets a namespace it is uid 0 in, and under the jail that either fails or
86
+ * nests badly. Telling the author at package time beats a confusing runtime
87
+ * failure on the fleet. Task 4.2d is the related-but-different guarantee, and
88
+ * it IS load-bearing: `bwrap` is never in the derived mount set.
89
+ */
90
+ {
91
+ rule: 'namespace escape (bwrap/unshare/nsenter)',
92
+ re: /\b(?:bwrap|unshare|nsenter)\b|CLONE_NEWUSER/,
93
+ hint:
94
+ 'Hooks already run inside a namespace celilo builds; a module creating its own is ' +
95
+ 'not supported and will not survive the jail. This is a hygiene check, not a security ' +
96
+ 'boundary. If you need isolation the framework does not give you, say what for.',
97
+ },
66
98
  ];
67
99
 
68
100
  /** A `runAppCommand(` / `runAppCommandWithSecret(` CALL — not the import. */
@@ -18,6 +18,7 @@ import { COMMANDS, readOnlyGrants } from '@celilo/core';
18
18
  import { closeDb } from '../db/client';
19
19
  import { getPrincipalByName, isAuthorized } from './api-access';
20
20
  import {
21
+ buildControlPlaneApi,
21
22
  controlPlaneReadGrants,
22
23
  enrolControlPlanePrincipal,
23
24
  revokeControlPlanePrincipal,
@@ -177,3 +178,75 @@ describe('revocation', () => {
177
178
  expect(await revokeControlPlanePrincipal('never-existed')).toBe(false);
178
179
  });
179
180
  });
181
+
182
+ describe('the capability handed to a consuming module', () => {
183
+ let dir: string;
184
+
185
+ beforeEach(async () => {
186
+ dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-'));
187
+ process.env.CELILO_DB_PATH = join(dir, 'test.db');
188
+ closeDb();
189
+ });
190
+
191
+ afterEach(() => {
192
+ closeDb();
193
+ rmSync(dir, { recursive: true, force: true });
194
+ });
195
+
196
+ test('enrols the caller and reports the grants it may use', async () => {
197
+ const api = buildControlPlaneApi('celilo-web-console');
198
+ const result = await api.enrol_principal({
199
+ name: 'celilo-web-console',
200
+ publicKey: PUBLIC_KEY,
201
+ });
202
+
203
+ expect(result.created).toBe(true);
204
+ expect(result.grants).toEqual(controlPlaneReadGrants());
205
+ expect(await getPrincipalByName('celilo-web-console')).not.toBeNull();
206
+ });
207
+
208
+ test('a module may NOT enrol under a neighbour’s name', async () => {
209
+ // Otherwise any module holding this capability rotates the console's key by
210
+ // presenting its own public half under the console's name — the console then
211
+ // holds a principal whose authorized_keys entry it cannot log in with, and
212
+ // the attacker holds one that works.
213
+ const api = buildControlPlaneApi('hello-foo');
214
+
215
+ await expect(
216
+ api.enrol_principal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY }),
217
+ ).rejects.toThrow(/only enrol its own principal/);
218
+ expect(await getPrincipalByName('celilo-web-console')).toBeNull();
219
+ });
220
+
221
+ test('a module may NOT revoke a neighbour’s principal', async () => {
222
+ // The denial-of-service half of the same hole: revocation is idempotent and
223
+ // returns false for a name that never existed, so an unguarded revoke gives
224
+ // no signal at all that a module just deleted someone else's access.
225
+ await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
226
+ const api = buildControlPlaneApi('hello-foo');
227
+
228
+ await expect(api.revoke_principal({ name: 'celilo-web-console' })).rejects.toThrow(
229
+ /only revoke its own principal/,
230
+ );
231
+ expect(await getPrincipalByName('celilo-web-console')).not.toBeNull();
232
+ });
233
+
234
+ test('withdraws its own principal, and finding nothing is not an error', async () => {
235
+ const api = buildControlPlaneApi('celilo-web-console');
236
+ await api.enrol_principal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
237
+
238
+ expect(await api.revoke_principal({ name: 'celilo-web-console' })).toEqual({ revoked: true });
239
+ expect(await api.revoke_principal({ name: 'celilo-web-console' })).toEqual({ revoked: false });
240
+ });
241
+
242
+ test('what it enrols cannot write, whatever the caller asked for', async () => {
243
+ // The grant set is not a parameter, so there is nothing to ask for. This
244
+ // asserts the consequence rather than the absence: the principal the
245
+ // capability produces is refused a deploy.
246
+ const api = buildControlPlaneApi('celilo-web-console');
247
+ await api.enrol_principal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
248
+
249
+ expect(await isAuthorized('celilo-web-console', 'module', 'list')).toBe(true);
250
+ expect(await isAuthorized('celilo-web-console', 'module', 'deploy')).toBe(false);
251
+ });
252
+ });
@@ -40,6 +40,7 @@
40
40
  * into a negotiation with the caller.
41
41
  */
42
42
 
43
+ import type { ControlPlaneApiCapability } from '@celilo/capabilities';
43
44
  import { COMMANDS, readOnlyGrants } from '@celilo/core';
44
45
  import { grantPrincipal, revokePrincipal } from './api-access';
45
46
 
@@ -101,3 +102,57 @@ export async function enrolControlPlanePrincipal(params: {
101
102
  export async function revokeControlPlanePrincipal(name: string): Promise<boolean> {
102
103
  return revokePrincipal(name);
103
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
+ }
@@ -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/)
@@ -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', () => {