@celilo/cli 0.4.0-alpha.1 → 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.
- package/drizzle/0008_aspect_consent.sql +1 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -6
- package/src/cli/command-registry.ts +38 -0
- package/src/cli/commands/backup-pull.test.ts +48 -0
- package/src/cli/commands/backup-pull.ts +116 -0
- package/src/cli/commands/events.test.ts +108 -0
- package/src/cli/commands/events.ts +243 -0
- package/src/cli/commands/module-generate.ts +5 -4
- package/src/cli/commands/module-import-aspect.test.ts +116 -0
- package/src/cli/commands/module-import.ts +12 -1
- package/src/cli/commands/storage-add-s3.ts +91 -46
- package/src/cli/completion.ts +2 -1
- package/src/cli/index.ts +11 -0
- package/src/db/client.ts +4 -0
- package/src/db/schema.ts +9 -1
- package/src/hooks/capability-loader.test.ts +31 -1
- package/src/hooks/capability-loader.ts +65 -16
- package/src/manifest/contracts/v1.ts +12 -0
- package/src/manifest/schema.ts +13 -1
- package/src/manifest/template-validator.ts +1 -0
- package/src/module/packaging/build.test.ts +75 -0
- package/src/module/packaging/build.ts +9 -20
- package/src/module/packaging/package-rules.ts +44 -0
- package/src/secrets/generators.test.ts +14 -1
- package/src/secrets/generators.ts +63 -1
- package/src/services/aspect-approvals.test.ts +30 -10
- package/src/services/aspect-approvals.ts +61 -31
- package/src/services/aspect-runner.test.ts +161 -8
- package/src/services/aspect-runner.ts +156 -34
- package/src/services/backup-create.ts +11 -2
- package/src/services/bus-ensure-flow.test.ts +19 -1
- package/src/services/bus-interview.ts +56 -0
- package/src/services/bus-secret-flow.test.ts +19 -1
- package/src/services/celilo-events.test.ts +122 -0
- package/src/services/celilo-events.ts +144 -0
- package/src/services/celilo-mgmt-hooks.test.ts +9 -1
- package/src/services/config-interview.ts +38 -19
- package/src/services/deploy-planner.test.ts +66 -0
- package/src/services/deploy-planner.ts +16 -2
- package/src/services/deploy-preflight.ts +18 -1
- package/src/services/deployed-systems.ts +30 -1
- package/src/services/dns-provider-backfill.test.ts +150 -0
- package/src/services/dns-provider-backfill.ts +72 -2
- package/src/services/e2e-guard.test.ts +38 -0
- package/src/services/e2e-guard.ts +43 -0
- package/src/services/module-deploy.ts +12 -26
- package/src/services/responder-probe.test.ts +87 -0
- package/src/services/responder-probe.ts +29 -0
- package/src/services/restore-from-file.ts +16 -6
- package/src/services/storage-providers/s3.test.ts +101 -0
- package/src/templates/generator.test.ts +77 -0
- package/src/templates/generator.ts +69 -2
- package/src/variables/context.ts +34 -0
- 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 {
|
|
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('
|
|
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
|
-
|
|
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('
|
|
551
|
+
expect(result.reason).toBe('denied');
|
|
409
552
|
expect(calls).toHaveLength(0);
|
|
410
553
|
});
|
|
411
554
|
|
|
412
|
-
it('
|
|
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(
|
|
436
|
-
expect(result.
|
|
437
|
-
expect(calls).toHaveLength(
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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 {
|
|
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:
|
|
47
|
-
/**
|
|
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
|
-
//
|
|
90
|
-
//
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
169
|
-
const keyPath = await writeTemporarySshKey(
|
|
227
|
+
for (const t of targetSystems) {
|
|
228
|
+
const keyPath = t.machineId ? await writeTemporarySshKey(t.machineId) : undefined;
|
|
170
229
|
inventoryHosts.push({
|
|
171
|
-
hostname:
|
|
172
|
-
ansibleHost:
|
|
173
|
-
ansibleUser:
|
|
174
|
-
groups: ['aspect_targets',
|
|
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 ${
|
|
182
|
-
await writeFile(join(hostVarsDir, `${
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
)
|
|
423
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|