@celilo/cli 0.4.0-alpha.0 → 0.4.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 (55) hide show
  1. package/drizzle/0008_aspect_consent.sql +1 -0
  2. package/drizzle/meta/_journal.json +7 -0
  3. package/package.json +5 -6
  4. package/src/cli/command-registry.ts +38 -0
  5. package/src/cli/commands/backup-pull.test.ts +48 -0
  6. package/src/cli/commands/backup-pull.ts +116 -0
  7. package/src/cli/commands/events.test.ts +108 -0
  8. package/src/cli/commands/events.ts +243 -0
  9. package/src/cli/commands/module-generate.ts +5 -4
  10. package/src/cli/commands/module-import-aspect.test.ts +116 -0
  11. package/src/cli/commands/module-import.ts +12 -1
  12. package/src/cli/commands/storage-add-s3.ts +91 -46
  13. package/src/cli/completion.ts +2 -1
  14. package/src/cli/index.ts +11 -0
  15. package/src/db/client.ts +4 -0
  16. package/src/db/schema.ts +9 -1
  17. package/src/hooks/capability-loader.test.ts +31 -1
  18. package/src/hooks/capability-loader.ts +65 -16
  19. package/src/manifest/contracts/v1.ts +12 -0
  20. package/src/manifest/schema.ts +13 -1
  21. package/src/manifest/template-validator.ts +1 -0
  22. package/src/module/packaging/build.test.ts +75 -0
  23. package/src/module/packaging/build.ts +9 -20
  24. package/src/module/packaging/package-rules.ts +44 -0
  25. package/src/secrets/generators.test.ts +14 -1
  26. package/src/secrets/generators.ts +63 -1
  27. package/src/services/aspect-approvals.test.ts +30 -10
  28. package/src/services/aspect-approvals.ts +61 -31
  29. package/src/services/aspect-runner.test.ts +161 -8
  30. package/src/services/aspect-runner.ts +156 -34
  31. package/src/services/backup-create.ts +11 -2
  32. package/src/services/bus-ensure-flow.test.ts +19 -1
  33. package/src/services/bus-interview.ts +56 -0
  34. package/src/services/bus-secret-flow.test.ts +19 -1
  35. package/src/services/celilo-events.test.ts +122 -0
  36. package/src/services/celilo-events.ts +144 -0
  37. package/src/services/celilo-mgmt-hooks.test.ts +9 -1
  38. package/src/services/config-interview.ts +38 -19
  39. package/src/services/deploy-planner.test.ts +66 -0
  40. package/src/services/deploy-planner.ts +16 -2
  41. package/src/services/deploy-preflight.ts +18 -1
  42. package/src/services/deployed-systems.ts +30 -1
  43. package/src/services/dns-provider-backfill.test.ts +150 -0
  44. package/src/services/dns-provider-backfill.ts +72 -2
  45. package/src/services/e2e-guard.test.ts +38 -0
  46. package/src/services/e2e-guard.ts +43 -0
  47. package/src/services/module-deploy.ts +12 -26
  48. package/src/services/responder-probe.test.ts +87 -0
  49. package/src/services/responder-probe.ts +29 -0
  50. package/src/services/restore-from-file.ts +16 -6
  51. package/src/services/storage-providers/s3.test.ts +101 -0
  52. package/src/templates/generator.test.ts +77 -0
  53. package/src/templates/generator.ts +69 -2
  54. package/src/variables/context.ts +34 -0
  55. package/src/variables/lxc-nameserver.test.ts +86 -0
@@ -20,13 +20,19 @@ import { closeDb, getDb } from '../db/client';
20
20
  import { runMigrations } from '../db/migrate';
21
21
  import { modules } from '../db/schema';
22
22
  import type { BaseModuleAspect, ModuleManifest } from '../manifest/schema';
23
- import { computeAspectScopeHash, recordAspectApproval } from './aspect-approvals';
23
+ import {
24
+ computeAspectScopeHash,
25
+ findAspectApproval,
26
+ recordAspectApproval,
27
+ recordAspectConsent,
28
+ } from './aspect-approvals';
24
29
  import {
25
30
  type AspectRunResult,
26
31
  materializeAspectAnsible,
27
32
  maybeRunAspectForTrigger,
28
33
  planAspectFanOut,
29
34
  } from './aspect-runner';
35
+ import { upsertDeployedSystem } from './deployed-systems';
30
36
  import { addMachine } from './machine-pool';
31
37
 
32
38
  const baseAspect: BaseModuleAspect = {
@@ -62,6 +68,37 @@ async function seedMachine(opts: {
62
68
  }
63
69
  }
64
70
 
71
+ /** Seed a container_service LXC (a module + its module_systems row). */
72
+ function seedLxc(opts: {
73
+ moduleId: string;
74
+ hostname: string;
75
+ zone: 'dmz' | 'app' | 'secure' | 'internal';
76
+ ip: string;
77
+ }) {
78
+ const db = getDb();
79
+ db.insert(modules)
80
+ .values({
81
+ id: opts.moduleId,
82
+ name: opts.moduleId,
83
+ version: '1.0.0',
84
+ manifestData: {
85
+ id: opts.moduleId,
86
+ name: opts.moduleId,
87
+ version: '1.0.0',
88
+ celilo_contract: '1.0',
89
+ },
90
+ sourcePath: `/tmp/${opts.moduleId}`,
91
+ })
92
+ .run();
93
+ upsertDeployedSystem(db, opts.moduleId, {
94
+ name: 'main',
95
+ hostname: opts.hostname,
96
+ ipv4Address: opts.ip,
97
+ zone: opts.zone,
98
+ infraType: 'container_service',
99
+ });
100
+ }
101
+
65
102
  describe('aspect-runner', () => {
66
103
  let dir: string;
67
104
 
@@ -131,6 +168,38 @@ describe('aspect-runner', () => {
131
168
  const plan = await planAspectFanOut({ ...baseAspect, applicable_zones: ['secure'] });
132
169
  expect(plan.targetSystems.map((m) => m.hostname)).toEqual(['celilo-mgmt']);
133
170
  });
171
+
172
+ it('includes container_service LXCs in the zones, not just machines (ISS-0028)', async () => {
173
+ // The original machines-only fan-out skipped LXCs — caddy/celilo-registry
174
+ // never got the aspect. A zone with both a machine and an LXC must yield
175
+ // both targets.
176
+ await seedMachine({ hostname: 'knot', zone: 'internal', ip: '192.168.0.10' });
177
+ seedLxc({ moduleId: 'caddy', hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' });
178
+ seedLxc({
179
+ moduleId: 'celilo-registry',
180
+ hostname: 'celilo-registry',
181
+ zone: 'app',
182
+ ip: '10.0.20.12',
183
+ });
184
+
185
+ const plan = await planAspectFanOut(baseAspect);
186
+ expect(plan.targetSystems.map((t) => t.hostname).sort()).toEqual([
187
+ 'caddy',
188
+ 'celilo-registry',
189
+ 'knot',
190
+ ]);
191
+ // The LXC targets carry no machineId (ambient-key auth); the machine does.
192
+ const caddy = plan.targetSystems.find((t) => t.hostname === 'caddy');
193
+ expect(caddy?.machineId).toBeUndefined();
194
+ expect(caddy?.sshUser).toBe('root');
195
+ expect(plan.targetSystems.find((t) => t.hostname === 'knot')?.machineId).toBeDefined();
196
+ });
197
+
198
+ it('respects excludeHostnames for LXC targets too', async () => {
199
+ seedLxc({ moduleId: 'caddy', hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' });
200
+ const plan = await planAspectFanOut(baseAspect, { excludeHostnames: ['caddy'] });
201
+ expect(plan.targetSystems).toEqual([]);
202
+ });
134
203
  });
135
204
 
136
205
  describe('materializeAspectAnsible', () => {
@@ -201,6 +270,28 @@ describe('aspect-runner', () => {
201
270
  rmSync(workDir, { recursive: true, force: true });
202
271
  });
203
272
 
273
+ it('emits an LXC inventory row with no ssh key file (ambient auth, ISS-0028)', async () => {
274
+ await seedMachine({ hostname: 'knot', zone: 'internal', ip: '192.168.0.10' });
275
+ seedLxc({ moduleId: 'caddy', hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' });
276
+ const moduleSourcePath = join(dir, 'module-src-lxc');
277
+ makeFakeRole(moduleSourcePath, 'dns-client-config');
278
+
279
+ const plan = await planAspectFanOut(baseAspect);
280
+ const workDir = await materializeAspectAnsible({
281
+ aspect: baseAspect,
282
+ moduleSourcePath,
283
+ targetSystems: plan.targetSystems,
284
+ });
285
+ const hostsIni = readFileSync(join(workDir, 'ansible', 'inventory', 'hosts.ini'), 'utf-8');
286
+ // The machine row pins a key file; the LXC row does not (ambient key).
287
+ const knotLine = hostsIni.split('\n').find((l) => l.startsWith('knot '));
288
+ const caddyLine = hostsIni.split('\n').find((l) => l.startsWith('caddy '));
289
+ expect(knotLine).toContain('ansible_ssh_private_key_file=');
290
+ expect(caddyLine).toContain('caddy ansible_host=10.0.10.10 ansible_user=root');
291
+ expect(caddyLine).not.toContain('ansible_ssh_private_key_file=');
292
+ rmSync(workDir, { recursive: true, force: true });
293
+ });
294
+
204
295
  it('throws a clear error when the aspect role directory is missing', async () => {
205
296
  await seedMachine({ hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' });
206
297
  const moduleSourcePath = join(dir, 'module-src-without-role');
@@ -393,9 +484,58 @@ describe('aspect-runner', () => {
393
484
  expect(calls).toHaveLength(0);
394
485
  });
395
486
 
396
- it('skips with "no_approval" when the operator never approved', async () => {
487
+ it('interviews when undecided and runs on consent (persists the approval)', async () => {
488
+ seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' });
489
+ const { fake, calls } = makeFakeRunner();
490
+ const asked: string[] = [];
491
+ const result = await maybeRunAspectForTrigger({
492
+ moduleId: 'knot-unbound-internal',
493
+ manifest: manifestWithAspect,
494
+ trigger: 'on_install',
495
+ db: getDb(),
496
+ runner: fake,
497
+ requestConsent: async (a) => {
498
+ asked.push(a.reason);
499
+ return true;
500
+ },
501
+ });
502
+ expect(asked).toEqual(['no_approval']); // it interviewed, didn't silently skip
503
+ expect(result.ran).toBe(true);
504
+ expect(calls).toHaveLength(1);
505
+ const row = findAspectApproval('knot-unbound-internal', '1.0.0', getDb());
506
+ expect(row?.consented).toBe(true); // approval recorded
507
+ });
508
+
509
+ it('interviews when undecided and skips on refusal (persists the denial)', async () => {
510
+ seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' });
511
+ const { fake, calls } = makeFakeRunner();
512
+ const result = await maybeRunAspectForTrigger({
513
+ moduleId: 'knot-unbound-internal',
514
+ manifest: manifestWithAspect,
515
+ trigger: 'on_install',
516
+ db: getDb(),
517
+ runner: fake,
518
+ requestConsent: async () => false,
519
+ });
520
+ expect(result.ran).toBe(false);
521
+ expect(result.reason).toBe('denied');
522
+ expect(calls).toHaveLength(0);
523
+ const row = findAspectApproval('knot-unbound-internal', '1.0.0', getDb());
524
+ expect(row?.consented).toBe(false); // refusal recorded so it won't be re-asked
525
+ });
526
+
527
+ it('does NOT re-prompt once consent was refused', async () => {
397
528
  seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' });
398
- // No recordAspectApproval call.
529
+ recordAspectConsent({
530
+ moduleId: 'knot-unbound-internal',
531
+ version: '1.0.0',
532
+ scopeHash: computeAspectScopeHash(
533
+ manifestWithAspect.base_module_aspect as BaseModuleAspect,
534
+ ),
535
+ approver: null,
536
+ consented: false,
537
+ db: getDb(),
538
+ });
399
539
  const { fake, calls } = makeFakeRunner();
400
540
  const result = await maybeRunAspectForTrigger({
401
541
  moduleId: 'knot-unbound-internal',
@@ -403,13 +543,16 @@ describe('aspect-runner', () => {
403
543
  trigger: 'on_install',
404
544
  db: getDb(),
405
545
  runner: fake,
546
+ requestConsent: async () => {
547
+ throw new Error('must not re-prompt an already-refused aspect');
548
+ },
406
549
  });
407
550
  expect(result.ran).toBe(false);
408
- expect(result.reason).toBe('no_approval');
551
+ expect(result.reason).toBe('denied');
409
552
  expect(calls).toHaveLength(0);
410
553
  });
411
554
 
412
- it('skips with "scope_changed" when manifest scope diverged from approval', async () => {
555
+ it('re-interviews when the approved scope diverged, then runs on consent', async () => {
413
556
  seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' });
414
557
  // Approve under a narrower scope than the manifest now declares.
415
558
  const oldScope: BaseModuleAspect = {
@@ -425,16 +568,26 @@ describe('aspect-runner', () => {
425
568
  db: getDb(),
426
569
  });
427
570
  const { fake, calls } = makeFakeRunner();
571
+ const asked: string[] = [];
428
572
  const result = await maybeRunAspectForTrigger({
429
573
  moduleId: 'knot-unbound-internal',
430
574
  manifest: manifestWithAspect,
431
575
  trigger: 'on_install',
432
576
  db: getDb(),
433
577
  runner: fake,
578
+ requestConsent: async (a) => {
579
+ asked.push(a.reason);
580
+ return true;
581
+ },
434
582
  });
435
- expect(result.ran).toBe(false);
436
- expect(result.reason).toBe('scope_changed');
437
- expect(calls).toHaveLength(0);
583
+ expect(asked).toEqual(['scope_changed']);
584
+ expect(result.ran).toBe(true);
585
+ expect(calls).toHaveLength(1);
586
+ // The single (module, version) row now reflects the new, wider scope.
587
+ const row = findAspectApproval('knot-unbound-internal', '1.0.0', getDb());
588
+ expect(row?.scopeHash).toBe(
589
+ computeAspectScopeHash(manifestWithAspect.base_module_aspect as BaseModuleAspect),
590
+ );
438
591
  });
439
592
 
440
593
  it('dispatches to the runner when everything checks out', async () => {
@@ -11,9 +11,12 @@
11
11
  * machinery does the actual SSH + playbook execution; the runner
12
12
  * only sets up the per-aspect Ansible workspace.
13
13
  *
14
- * Scope NOT covered in SC3:
15
- * - Container_service (Proxmox LXC) systems are deferred to a
16
- * follow-up `getSystemsByZone` covers the machines table only.
14
+ * Fan-out covers BOTH machine-pool systems and container_service
15
+ * LXCs in the aspect's zones (ISS-0028): machines come from
16
+ * `getSystemsByZone`, LXCs from `getContainerSystemsInZones`. LXCs
17
+ * authenticate via the ambient operator key (no per-host key).
18
+ *
19
+ * Scope NOT covered here:
17
20
  * - Proxmox `nameserver` reconciliation is SC5.
18
21
  * - Trigger wiring (when `on_install` / `on_new_system_in_zone`
19
22
  * etc. fire) is SC4. SC3 ships the pure execution surface so
@@ -28,23 +31,53 @@ import { join } from 'node:path';
28
31
  import { eq } from 'drizzle-orm';
29
32
  import { type InventoryHost, generateHostsIni } from '../ansible/inventory';
30
33
  import { log } from '../cli/prompts';
31
- import type { getDb } from '../db/client';
32
- import { modules } from '../db/schema';
34
+ import { getDb } from '../db/client';
35
+ import { type NetworkZone, modules } from '../db/schema';
33
36
  import type { BaseModuleAspect, BaseModuleAspectTrigger, ModuleManifest } from '../manifest/schema';
34
37
  import type { Machine } from '../types/infrastructure';
35
- import { checkAspectApproval } from './aspect-approvals';
38
+ import {
39
+ checkAspectApproval,
40
+ computeAspectScopeHash,
41
+ recordAspectConsent,
42
+ } from './aspect-approvals';
36
43
  import { resolveAspectTemplateRecord } from './aspect-template-resolver';
44
+ import {
45
+ type AspectConsentReply,
46
+ type AspectRequiredPayload,
47
+ EVENT_TYPES,
48
+ busInterviewGuarded,
49
+ } from './bus-interview';
37
50
  import { executeAnsible } from './deploy-ansible';
51
+ import { getContainerSystemsInZones } from './deployed-systems';
38
52
  import { getSystemsByZone } from './machine-pool';
39
53
  import { executeProxmoxReconcile, planProxmoxReconcile } from './proxmox-reconcile';
40
54
  import { writeTemporarySshKey } from './ssh-key-manager';
41
55
 
42
56
  type DbClient = ReturnType<typeof getDb>;
43
57
 
58
+ /**
59
+ * A single host an aspect fans out to — a machine-pool box OR a
60
+ * container_service LXC (ISS-0028). The two differ only in SSH auth:
61
+ *
62
+ * - machine: `machineId` is set; its per-machine key is materialized via
63
+ * `writeTemporarySshKey(machineId)` and pinned in the inventory row.
64
+ * - LXC: `machineId` is undefined; it authenticates via the ambient operator
65
+ * key (the one terraform injected at provision time), exactly as a normal
66
+ * LXC module deploy does — so the inventory row carries no key file.
67
+ */
68
+ export interface AspectTarget {
69
+ hostname: string;
70
+ ipAddress: string;
71
+ sshUser: string;
72
+ zone: NetworkZone;
73
+ /** Set for machine-pool targets only. Drives per-machine SSH key staging. */
74
+ machineId?: string;
75
+ }
76
+
44
77
  export interface AspectFanOutPlan {
45
- /** Systems the aspect will run on. */
46
- targetSystems: Machine[];
47
- /** Systems that matched the zones but were excluded (api_only, etc.). */
78
+ /** Systems the aspect will run on (machines + container_service LXCs). */
79
+ targetSystems: AspectTarget[];
80
+ /** Machines that matched the zones but were excluded (api_only, etc.). */
48
81
  skipped: Array<{ machine: Machine; reason: string }>;
49
82
  }
50
83
 
@@ -86,21 +119,45 @@ export async function planAspectFanOut(
86
119
  aspect: BaseModuleAspect,
87
120
  options: Pick<AspectRunOptions, 'excludeHostnames'> = {},
88
121
  ): Promise<AspectFanOutPlan> {
89
- // Pull the candidate set with api_only already filtered out so
90
- // we can record the skips separately for observability.
122
+ // Machine-pool systems in the zones (api_only recorded as skips for
123
+ // observability). excludeHostnames is applied by getSystemsByZone.
91
124
  const allInZones = await getSystemsByZone(aspect.applicable_zones, {
92
125
  excludeApiOnly: false,
93
126
  excludeHostnames: options.excludeHostnames,
94
127
  });
95
128
 
96
- const targetSystems: Machine[] = [];
129
+ const targetSystems: AspectTarget[] = [];
97
130
  const skipped: AspectFanOutPlan['skipped'] = [];
131
+ const seen = new Set<string>();
98
132
  for (const m of allInZones) {
99
133
  if (m.apiOnly) {
100
134
  skipped.push({ machine: m, reason: 'api_only' });
101
- } else {
102
- targetSystems.push(m);
135
+ continue;
103
136
  }
137
+ targetSystems.push({
138
+ hostname: m.hostname,
139
+ ipAddress: m.ipAddress,
140
+ sshUser: m.sshUser,
141
+ zone: m.zone,
142
+ machineId: m.id,
143
+ });
144
+ seen.add(m.hostname);
145
+ }
146
+
147
+ // Container_service LXCs in the same zones (ISS-0028). The machines-only
148
+ // fan-out skipped these; without them an aspect like dns-client-config can't
149
+ // reach caddy/celilo-registry. They auth via the ambient operator key, so no
150
+ // machineId / per-host key.
151
+ const exclude = new Set(options.excludeHostnames ?? []);
152
+ for (const sys of getContainerSystemsInZones(aspect.applicable_zones, getDb())) {
153
+ if (exclude.has(sys.hostname) || seen.has(sys.hostname)) continue;
154
+ targetSystems.push({
155
+ hostname: sys.hostname,
156
+ ipAddress: sys.ipv4_address,
157
+ sshUser: 'root',
158
+ zone: sys.zone as NetworkZone,
159
+ });
160
+ seen.add(sys.hostname);
104
161
  }
105
162
 
106
163
  return { targetSystems, skipped };
@@ -133,7 +190,7 @@ export async function materializeAspectAnsible(args: {
133
190
  /** Absolute path to the module's imported source dir. The aspect
134
191
  * role is expected at `<moduleSourcePath>/base-module-aspect/ansible/roles/<aspect.ansible_role>`. */
135
192
  moduleSourcePath: string;
136
- targetSystems: Machine[];
193
+ targetSystems: AspectTarget[];
137
194
  /** Provider module ID — used to resolve $self / $capability /
138
195
  * $system references in ansible_vars. */
139
196
  providerModuleId?: string;
@@ -163,23 +220,25 @@ export async function materializeAspectAnsible(args: {
163
220
  await mkdir(hostVarsDir, { recursive: true });
164
221
  await mkdir(rolesDir, { recursive: true });
165
222
 
166
- // Stage each system's SSH key and build inventory rows.
223
+ // Stage each target's SSH access and build inventory rows. Machines pin
224
+ // their per-machine key; LXCs (machineId undefined) leave it off and use the
225
+ // ambient operator key — same as a normal LXC deploy (ISS-0028).
167
226
  const inventoryHosts: InventoryHost[] = [];
168
- for (const m of targetSystems) {
169
- const keyPath = await writeTemporarySshKey(m.id);
227
+ for (const t of targetSystems) {
228
+ const keyPath = t.machineId ? await writeTemporarySshKey(t.machineId) : undefined;
170
229
  inventoryHosts.push({
171
- hostname: m.hostname,
172
- ansibleHost: m.ipAddress,
173
- ansibleUser: m.sshUser,
174
- groups: ['aspect_targets', m.zone],
230
+ hostname: t.hostname,
231
+ ansibleHost: t.ipAddress,
232
+ ansibleUser: t.sshUser,
233
+ groups: ['aspect_targets', t.zone],
175
234
  ansibleSshPrivateKeyFile: keyPath,
176
235
  });
177
236
 
178
237
  // Per-host vars: target_zone is the only fact the framework
179
238
  // guarantees today (per D3). Capability-data / module-config
180
239
  // injection is Phase 2+.
181
- const hostVars = `---\n# Aspect host vars for ${m.hostname}\ntarget_zone: ${m.zone}\n`;
182
- await writeFile(join(hostVarsDir, `${m.hostname}.yml`), hostVars, 'utf-8');
240
+ const hostVars = `---\n# Aspect host vars for ${t.hostname}\ntarget_zone: ${t.zone}\n`;
241
+ await writeFile(join(hostVarsDir, `${t.hostname}.yml`), hostVars, 'utf-8');
183
242
  }
184
243
 
185
244
  // hosts.ini groups every target under 'aspect_targets' and the
@@ -340,8 +399,47 @@ export type AspectSkipReason =
340
399
  | 'no_aspect' // module didn't declare base_module_aspect
341
400
  | 'trigger_not_declared' // aspect.triggers doesn't include this trigger
342
401
  | 'no_approval' // operator hasn't approved (D2)
402
+ | 'denied' // operator explicitly refused consent (ISS-0027)
343
403
  | 'scope_changed'; // approval exists but applicable_zones/triggers diverged (D7)
344
404
 
405
+ /**
406
+ * Asks the operator to approve (true) or refuse (false) a module's
407
+ * base-module aspect. Injectable so unit tests don't drive the bus;
408
+ * the default (`requestAspectConsentViaBus`) emits an
409
+ * `aspect.required.<module>.<role>` interview question and waits for
410
+ * a responder (terminal, `celilo events reply`, the celilo-deploy
411
+ * skill). See ISS-0027.
412
+ */
413
+ export type AspectConsentRequest = (args: {
414
+ moduleId: string;
415
+ version: string;
416
+ aspect: BaseModuleAspect;
417
+ trigger: BaseModuleAspectTrigger;
418
+ reason: 'no_approval' | 'scope_changed';
419
+ }) => Promise<boolean>;
420
+
421
+ async function requestAspectConsentViaBus(args: {
422
+ moduleId: string;
423
+ version: string;
424
+ aspect: BaseModuleAspect;
425
+ trigger: BaseModuleAspectTrigger;
426
+ reason: 'no_approval' | 'scope_changed';
427
+ }): Promise<boolean> {
428
+ const payload: AspectRequiredPayload = {
429
+ module: args.moduleId,
430
+ role: args.aspect.ansible_role,
431
+ zones: args.aspect.applicable_zones,
432
+ triggers: args.aspect.triggers,
433
+ trigger: args.trigger,
434
+ reason: args.reason,
435
+ };
436
+ const reply = await busInterviewGuarded<AspectConsentReply>(
437
+ EVENT_TYPES.aspectRequired(args.moduleId, args.aspect.ansible_role),
438
+ payload,
439
+ );
440
+ return reply.consented === true;
441
+ }
442
+
345
443
  export interface AspectGlueResult {
346
444
  ran: boolean;
347
445
  /** Populated when `ran === true`. */
@@ -388,6 +486,8 @@ export async function maybeRunAspectForTrigger(args: {
388
486
  db: DbClient;
389
487
  runner?: typeof runAspectFanOut;
390
488
  excludeHostnames?: string[];
489
+ /** Injectable consent prompt (default: bus interview). See ISS-0027. */
490
+ requestConsent?: AspectConsentRequest;
391
491
  }): Promise<AspectGlueResult> {
392
492
  const { moduleId, manifest, trigger, db } = args;
393
493
  const aspect = manifest.base_module_aspect;
@@ -410,17 +510,39 @@ export async function maybeRunAspectForTrigger(args: {
410
510
  }
411
511
 
412
512
  const approvalStatus = checkAspectApproval(moduleId, moduleRow.version, aspect, db);
413
- if (approvalStatus === 'no_approval') {
414
- log.warn(
415
- `Aspect for '${moduleId}' is declared but not approved. Re-import the module to grant consent; aspect skipped.`,
416
- );
417
- return { ran: false, reason: 'no_approval' };
513
+
514
+ // Already-approved → run. Already-DENIED → skip silently: the operator
515
+ // made a durable decision; re-prompting every deploy would be nagging
516
+ // (ISS-0027). Only the undecided ('no_approval') and stale ('scope_changed')
517
+ // states warrant an interview.
518
+ if (approvalStatus === 'denied') {
519
+ return { ran: false, reason: 'denied' };
418
520
  }
419
- if (approvalStatus === 'scope_changed') {
420
- log.warn(
421
- `Aspect for '${moduleId}@${moduleRow.version}' has scope changes from the prior approval. Re-import to re-approve; aspect skipped.`,
422
- );
423
- return { ran: false, reason: 'scope_changed' };
521
+ if (approvalStatus === 'no_approval' || approvalStatus === 'scope_changed') {
522
+ // ISS-0027: don't silently skip a declared aspect. Interview the operator
523
+ // for consent on the bus and WAIT. A responder (terminal, `events reply`,
524
+ // the celilo-deploy skill) approves or denies. We persist the decision —
525
+ // either way so a denial isn't re-asked next deploy.
526
+ const requestConsent = args.requestConsent ?? requestAspectConsentViaBus;
527
+ const consented = await requestConsent({
528
+ moduleId,
529
+ version: moduleRow.version,
530
+ aspect,
531
+ trigger,
532
+ reason: approvalStatus,
533
+ });
534
+ recordAspectConsent({
535
+ moduleId,
536
+ version: moduleRow.version,
537
+ scopeHash: computeAspectScopeHash(aspect),
538
+ approver: process.env.USER ?? null,
539
+ consented,
540
+ db,
541
+ });
542
+ if (!consented) {
543
+ log.warn(`Aspect for '${moduleId}' consent refused; aspect skipped (will not re-prompt).`);
544
+ return { ran: false, reason: 'denied' };
545
+ }
424
546
  }
425
547
 
426
548
  const runner = args.runner ?? runAspectFanOut;
@@ -26,7 +26,12 @@ import { getOrCreateMasterKey } from '../secrets/master-key';
26
26
  import { shellEscape } from '../utils/shell';
27
27
  import { buildManifest } from './backup-manifest';
28
28
  import { completeBackup, createBackupRecord, failBackup, listBackups } from './backup-metadata';
29
- import { createStorageProvider, getBackupStorage, getDefaultBackupStorage } from './backup-storage';
29
+ import {
30
+ createStorageProvider,
31
+ getBackupStorage,
32
+ getBackupStorageByStorageId,
33
+ getDefaultBackupStorage,
34
+ } from './backup-storage';
30
35
  import { materializeCrossModuleRoot, moduleHasCrossModuleRead } from './cross-module-read';
31
36
  import { getModuleSystems } from './deployed-systems';
32
37
  import {
@@ -57,7 +62,11 @@ export type BackupSchedule = 'hourly' | 'daily' | 'weekly' | 'monthly' | 'manual
57
62
  */
58
63
  export function resolveStorage(storageId?: string) {
59
64
  if (storageId) {
60
- const storage = getBackupStorage(storageId);
65
+ // Accept the human storageId NAME (what `storage list` shows and what the
66
+ // operator passes to --storage, e.g. "aws-backups") first, falling back to
67
+ // the internal UUID. Users never see UUIDs (CLAUDE.md), so name-first is
68
+ // the correct resolution order.
69
+ const storage = getBackupStorageByStorageId(storageId) ?? getBackupStorage(storageId);
61
70
  if (!storage) {
62
71
  throw new Error(`Storage not found: ${storageId}`);
63
72
  }
@@ -119,7 +119,25 @@ function startEnsureResponder(
119
119
  });
120
120
  });
121
121
 
122
- return { seen, close: () => handle.close() };
122
+ // Real responders answer `responder.probe` so the deploy's fail-fast guard
123
+ // (ISS-0025) knows a responder is listening. A bespoke fixture must too, or
124
+ // busInterviewGuarded throws before the ensure prompt is ever emitted.
125
+ const probeHandle = bus.watch('responder.probe', (event) => {
126
+ if (event.replyFor !== null) return;
127
+ bus.emitRaw(
128
+ `${event.type}.reply`,
129
+ { kind: 'programmatic', emittedBy: 'test-responder' },
130
+ { replyFor: event.id, emittedBy: 'test-responder' },
131
+ );
132
+ });
133
+
134
+ return {
135
+ seen,
136
+ close: () => {
137
+ handle.close();
138
+ probeHandle.close();
139
+ },
140
+ };
123
141
  }
124
142
 
125
143
  describe('bus-mediated interviewForEnsureInputs', () => {
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
16
16
  import { getEventBusPath } from '../config/paths';
17
+ import { ensureResponderForInterview } from './responder-probe';
17
18
 
18
19
  const NO_SCHEMAS = defineEvents({});
19
20
 
@@ -21,8 +22,42 @@ export const EVENT_TYPES = {
21
22
  configRequired: (module: string, key: string) => `config.required.${module}.${key}`,
22
23
  secretRequired: (module: string, key: string) => `secret.required.${module}.${key}`,
23
24
  ensureRequired: (provider: string, ensureId: string) => `ensure.required.${provider}.${ensureId}`,
25
+ aspectRequired: (module: string, role: string) => `aspect.required.${module}.${role}`,
24
26
  } as const;
25
27
 
28
+ /**
29
+ * Payload for `aspect.required.<module>.<role>`. Emitted when a deploy is
30
+ * about to fan out a module's `base_module_aspect` but the operator has not
31
+ * granted consent (no approval recorded, or the approved scope diverged). The
32
+ * deploy WAITS for a responder to approve/deny instead of silently skipping —
33
+ * see ISS-0027. The reply (`AspectConsentReply`) carries only the decision; the
34
+ * deploy process records the approval (it holds module/version/scope), exactly
35
+ * as `module import --accept-aspects` would.
36
+ *
37
+ * The scope (`ansible_role`, `zones`, `triggers`) is included so the responder
38
+ * can show the operator what running the aspect means — informed consent,
39
+ * mirroring the interactive import prompt.
40
+ */
41
+ export interface AspectRequiredPayload {
42
+ module: string;
43
+ /** The aspect's `ansible_role` — doubles as the role segment of the type. */
44
+ role: string;
45
+ zones: string[];
46
+ triggers: string[];
47
+ /** The trigger currently firing (e.g. `on_install`). */
48
+ trigger: string;
49
+ /**
50
+ * Why consent is needed: `no_approval` (never approved) or `scope_changed`
51
+ * (approved before, but `applicable_zones`/`triggers` diverged → re-consent).
52
+ */
53
+ reason: 'no_approval' | 'scope_changed';
54
+ description?: string;
55
+ }
56
+
57
+ export interface AspectConsentReply {
58
+ consented: boolean;
59
+ }
60
+
26
61
  /**
27
62
  * Payload for `config.required.<module>.<key>`. The deploy emits this
28
63
  * when a non-secret variable is missing AND has no default to fall
@@ -181,3 +216,24 @@ export async function busInterview<TReply>(
181
216
  if (ownsBus) bus.close();
182
217
  }
183
218
  }
219
+
220
+ /**
221
+ * `busInterview` with a fail-fast guard for headless deploys (ISS-0025).
222
+ *
223
+ * Same contract as `busInterview`, but first calls
224
+ * `ensureResponderForInterview` — so a non-TTY deploy with no responder
225
+ * listening throws an actionable error instead of waiting forever. This is the
226
+ * variant deploy-time interview sites (config / secret / ensure / aspect) use;
227
+ * raw `busInterview` stays for callers that guarantee a responder (tests,
228
+ * internal probes). The guard runs per prompt, so it also catches a responder
229
+ * that dies mid-deploy, and it never fires for auto-derived / auto-generated
230
+ * values (those never reach an interview query).
231
+ */
232
+ export async function busInterviewGuarded<TReply>(
233
+ type: string,
234
+ payload: object,
235
+ ownerBus?: Bus,
236
+ ): Promise<TReply> {
237
+ await ensureResponderForInterview(type);
238
+ return busInterview<TReply>(type, payload, ownerBus);
239
+ }
@@ -79,7 +79,25 @@ function startTestResponder(
79
79
  );
80
80
  });
81
81
 
82
- return { seen, close: () => handle.close() };
82
+ // Real responders answer `responder.probe` so the deploy's fail-fast guard
83
+ // (ISS-0025) knows a responder is listening. A bespoke fixture must too, or
84
+ // busInterviewGuarded throws before the secret prompt is ever emitted.
85
+ const probeHandle = bus.watch('responder.probe', (event) => {
86
+ if (event.replyFor !== null) return;
87
+ bus.emitRaw(
88
+ `${event.type}.reply`,
89
+ { kind: 'programmatic', emittedBy: 'test-responder' },
90
+ { replyFor: event.id, emittedBy: 'test-responder' },
91
+ );
92
+ });
93
+
94
+ return {
95
+ seen,
96
+ close: () => {
97
+ handle.close();
98
+ probeHandle.close();
99
+ },
100
+ };
83
101
  }
84
102
 
85
103
  function writeSecretsSchema(