@celilo/cli 0.3.30-alpha.0 → 0.4.0-alpha.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 (155) hide show
  1. package/drizzle/0005_module_operations.sql +12 -0
  2. package/drizzle/0006_base_module_aspects.sql +15 -0
  3. package/drizzle/0007_module_systems.sql +17 -0
  4. package/drizzle/meta/_journal.json +21 -0
  5. package/package.json +3 -3
  6. package/schemas/system_config.json +14 -28
  7. package/src/ansible/inventory.test.ts +46 -62
  8. package/src/ansible/inventory.ts +48 -25
  9. package/src/capabilities/registration.ts +25 -7
  10. package/src/capabilities/validation.test.ts +30 -0
  11. package/src/capabilities/validation.ts +8 -0
  12. package/src/cli/backup-rename.test.ts +95 -0
  13. package/src/cli/cli.test.ts +17 -23
  14. package/src/cli/command-registry.ts +199 -0
  15. package/src/cli/commands/backup-list.ts +1 -1
  16. package/src/cli/commands/events.ts +96 -0
  17. package/src/cli/commands/machine-add.ts +103 -59
  18. package/src/cli/commands/module-import.ts +153 -4
  19. package/src/cli/commands/module-remove.ts +86 -17
  20. package/src/cli/commands/module-status.ts +6 -2
  21. package/src/cli/commands/publish/alpha.test.ts +185 -0
  22. package/src/cli/commands/publish/alpha.ts +226 -0
  23. package/src/cli/commands/publish/changesets.test.ts +89 -0
  24. package/src/cli/commands/publish/changesets.ts +144 -0
  25. package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
  26. package/src/cli/commands/publish/consumer-pins.ts +149 -0
  27. package/src/cli/commands/publish/execute.ts +131 -0
  28. package/src/cli/commands/publish/global-install.test.ts +154 -0
  29. package/src/cli/commands/publish/global-install.ts +171 -0
  30. package/src/cli/commands/publish/helpers.ts +227 -0
  31. package/src/cli/commands/publish/index.ts +365 -0
  32. package/src/cli/commands/publish/module-registry.test.ts +40 -0
  33. package/src/cli/commands/publish/module-registry.ts +64 -0
  34. package/src/cli/commands/publish/plan.ts +107 -0
  35. package/src/cli/commands/publish/preflight.ts +238 -0
  36. package/src/cli/commands/publish/types.ts +264 -0
  37. package/src/cli/commands/publish/workspace.test.ts +323 -0
  38. package/src/cli/commands/publish/workspace.ts +596 -0
  39. package/src/cli/commands/restore.ts +126 -0
  40. package/src/cli/commands/storage-add-local.ts +1 -1
  41. package/src/cli/commands/storage-add-s3.ts +1 -1
  42. package/src/cli/commands/subscribers-add.ts +68 -0
  43. package/src/cli/commands/subscribers-list.ts +48 -0
  44. package/src/cli/commands/subscribers-remove.ts +38 -0
  45. package/src/cli/commands/subscribers-serve.ts +77 -0
  46. package/src/cli/commands/subscribers-status.ts +33 -0
  47. package/src/cli/commands/subscribers-test.ts +71 -0
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
  49. package/src/cli/commands/system-apply-config.test.ts +70 -0
  50. package/src/cli/commands/system-apply-config.ts +130 -0
  51. package/src/cli/commands/system-audit.ts +2 -1
  52. package/src/cli/commands/system-init-deprecation.test.ts +90 -0
  53. package/src/cli/commands/system-init.ts +36 -70
  54. package/src/cli/commands/system-update.ts +3 -2
  55. package/src/cli/completion.ts +22 -1
  56. package/src/cli/index.ts +214 -6
  57. package/src/cli/interactive-config.test.ts +19 -0
  58. package/src/cli/restore-command.test.ts +131 -0
  59. package/src/db/client.ts +42 -0
  60. package/src/db/schema.test.ts +13 -16
  61. package/src/db/schema.ts +161 -9
  62. package/src/hooks/capability-loader-firewall.test.ts +6 -15
  63. package/src/hooks/capability-loader.test.ts +2 -3
  64. package/src/hooks/capability-loader.ts +36 -2
  65. package/src/hooks/define-hook.test.ts +4 -0
  66. package/src/hooks/executor.test.ts +18 -0
  67. package/src/hooks/executor.ts +21 -2
  68. package/src/hooks/load-hook-config.test.ts +26 -24
  69. package/src/hooks/load-hook-config.ts +11 -2
  70. package/src/hooks/run-named-hook.ts +16 -0
  71. package/src/hooks/types.ts +9 -1
  72. package/src/manifest/contracts/v1.ts +70 -0
  73. package/src/manifest/schema.ts +262 -16
  74. package/src/manifest/validate-privileged.test.ts +84 -0
  75. package/src/manifest/validate.test.ts +156 -0
  76. package/src/manifest/validate.ts +69 -0
  77. package/src/module/import.ts +12 -0
  78. package/src/services/aspect-approvals.test.ts +231 -0
  79. package/src/services/aspect-approvals.ts +120 -0
  80. package/src/services/aspect-runner.test.ts +493 -0
  81. package/src/services/aspect-runner.ts +438 -0
  82. package/src/services/aspect-template-resolver.test.ts +101 -0
  83. package/src/services/aspect-template-resolver.ts +122 -0
  84. package/src/services/backup-create.ts +104 -25
  85. package/src/services/backup-envelope-roundtrip.test.ts +199 -0
  86. package/src/services/backup-in-flight-refusal.test.ts +163 -0
  87. package/src/services/backup-manifest.test.ts +115 -0
  88. package/src/services/backup-manifest.ts +163 -0
  89. package/src/services/backup-restore.ts +154 -19
  90. package/src/services/build-bus/delivery-events.ts +92 -0
  91. package/src/services/build-bus/event-factory.ts +54 -0
  92. package/src/services/build-bus/fan-out.test.ts +279 -0
  93. package/src/services/build-bus/fan-out.ts +161 -0
  94. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
  95. package/src/services/build-bus/hook-dispatch.test.ts +207 -0
  96. package/src/services/build-bus/hook-dispatch.ts +198 -0
  97. package/src/services/build-bus/hook-dispatcher.ts +115 -0
  98. package/src/services/build-bus/index.ts +41 -0
  99. package/src/services/build-bus/receiver-server.test.ts +179 -0
  100. package/src/services/build-bus/receiver-server.ts +159 -0
  101. package/src/services/build-bus/status.test.ts +212 -0
  102. package/src/services/build-bus/status.ts +213 -0
  103. package/src/services/build-bus/subscriber-store.ts +113 -0
  104. package/src/services/celilo-events.test.ts +70 -0
  105. package/src/services/celilo-events.ts +92 -0
  106. package/src/services/celilo-mgmt-hooks.test.ts +296 -0
  107. package/src/services/config-interview.ts +13 -95
  108. package/src/services/cross-module-data-manager.ts +2 -31
  109. package/src/services/cross-module-read.test.ts +250 -0
  110. package/src/services/cross-module-read.ts +232 -0
  111. package/src/services/deploy-validation.ts +7 -0
  112. package/src/services/deployed-systems.test.ts +235 -0
  113. package/src/services/deployed-systems.ts +308 -0
  114. package/src/services/dns-provider-backfill.ts +75 -0
  115. package/src/services/health-runner.ts +19 -3
  116. package/src/services/infrastructure-variable-resolver.test.ts +6 -32
  117. package/src/services/infrastructure-variable-resolver.ts +3 -13
  118. package/src/services/machine-detector.ts +104 -48
  119. package/src/services/machine-pool.ts +145 -2
  120. package/src/services/module-config.ts +78 -120
  121. package/src/services/module-deploy.ts +113 -40
  122. package/src/services/module-operations.test.ts +154 -0
  123. package/src/services/module-operations.ts +154 -0
  124. package/src/services/module-subscriptions.test.ts +58 -0
  125. package/src/services/module-subscriptions.ts +24 -1
  126. package/src/services/module-types-generator.test.ts +3 -3
  127. package/src/services/module-types-generator.ts +7 -2
  128. package/src/services/proxmox-reconcile.test.ts +333 -0
  129. package/src/services/proxmox-reconcile.ts +156 -0
  130. package/src/services/proxmox-state-recovery.ts +3 -24
  131. package/src/services/restore-from-file.test.ts +177 -0
  132. package/src/services/restore-from-file.ts +355 -0
  133. package/src/services/restore-preflight.test.ts +127 -0
  134. package/src/services/restore-preflight.ts +118 -0
  135. package/src/services/storage-providers/s3.ts +10 -2
  136. package/src/services/system-identity.ts +30 -0
  137. package/src/services/system-init.test.ts +64 -21
  138. package/src/services/system-init.ts +28 -26
  139. package/src/templates/generator.test.ts +7 -16
  140. package/src/templates/generator.ts +28 -115
  141. package/src/test-utils/integration.ts +5 -2
  142. package/src/types/infrastructure.ts +8 -0
  143. package/src/variables/computed/computed-integration.test.ts +191 -0
  144. package/src/variables/computed/computed.test.ts +177 -0
  145. package/src/variables/computed/evaluate.ts +271 -0
  146. package/src/variables/computed/marker.ts +53 -0
  147. package/src/variables/computed/parse.ts +262 -0
  148. package/src/variables/computed/provider-lookup.ts +130 -0
  149. package/src/variables/context.test.ts +89 -28
  150. package/src/variables/context.ts +196 -191
  151. package/src/variables/parser.ts +3 -3
  152. package/src/variables/resolver.test.ts +61 -0
  153. package/src/variables/resolver.ts +81 -0
  154. package/src/variables/types.ts +23 -1
  155. package/src/services/dns-auto-register.ts +0 -211
@@ -0,0 +1,493 @@
1
+ /**
2
+ * SC3 unit tests — covers the parts of aspect-runner that don't
3
+ * require live Ansible execution:
4
+ *
5
+ * - planAspectFanOut: zone filtering, api_only exclusion,
6
+ * excludeHostnames respect, mgmt-system inclusion.
7
+ * - materializeAspectAnsible: inventory + playbook generation,
8
+ * role-copy, error path when the role is missing.
9
+ *
10
+ * runAspectFanOut (end-to-end with Ansible) gets covered by the
11
+ * SC3 follow-up e2e test (modules/knot-unbound-internal/e2e/
12
+ * aspect-fanout.test.ts).
13
+ */
14
+
15
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
16
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { closeDb, getDb } from '../db/client';
20
+ import { runMigrations } from '../db/migrate';
21
+ import { modules } from '../db/schema';
22
+ import type { BaseModuleAspect, ModuleManifest } from '../manifest/schema';
23
+ import { computeAspectScopeHash, recordAspectApproval } from './aspect-approvals';
24
+ import {
25
+ type AspectRunResult,
26
+ materializeAspectAnsible,
27
+ maybeRunAspectForTrigger,
28
+ planAspectFanOut,
29
+ } from './aspect-runner';
30
+ import { addMachine } from './machine-pool';
31
+
32
+ const baseAspect: BaseModuleAspect = {
33
+ ansible_role: 'dns-client-config',
34
+ applicable_zones: ['dmz', 'app', 'secure', 'internal'],
35
+ triggers: ['on_install'],
36
+ };
37
+
38
+ async function seedMachine(opts: {
39
+ hostname: string;
40
+ zone: 'dmz' | 'app' | 'secure' | 'internal';
41
+ ip: string;
42
+ apiOnly?: boolean;
43
+ }) {
44
+ const machine = await addMachine({
45
+ hostname: opts.hostname,
46
+ zone: opts.zone,
47
+ ipAddress: opts.ip,
48
+ sshUser: 'root',
49
+ sshKey: 'ssh-key-placeholder',
50
+ hardware: { cpu_cores: 1, memory_mb: 512, disk_gb: 5, arch: 'amd64' },
51
+ assignedModuleIds: [],
52
+ earmarkedModule: null,
53
+ });
54
+ if (opts.apiOnly) {
55
+ // The DB column defaults to false; flip directly since there's
56
+ // no setter API yet (D8 escape valve is set via the row, not
57
+ // via a public function in Phase 1).
58
+ const { getDb } = await import('../db/client');
59
+ const { machines } = await import('../db/schema');
60
+ const { eq } = await import('drizzle-orm');
61
+ await getDb().update(machines).set({ apiOnly: true }).where(eq(machines.id, machine.id));
62
+ }
63
+ }
64
+
65
+ describe('aspect-runner', () => {
66
+ let dir: string;
67
+
68
+ beforeEach(async () => {
69
+ dir = mkdtempSync(join(tmpdir(), 'celilo-aspect-runner-test-'));
70
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
71
+ await runMigrations(process.env.CELILO_DB_PATH);
72
+ });
73
+
74
+ afterEach(() => {
75
+ closeDb();
76
+ process.env.CELILO_DB_PATH = undefined;
77
+ try {
78
+ rmSync(dir, { recursive: true, force: true });
79
+ } catch {
80
+ /* ignore */
81
+ }
82
+ });
83
+
84
+ describe('planAspectFanOut', () => {
85
+ it('returns systems matching the aspect zones, skipping api_only', async () => {
86
+ await seedMachine({ hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' });
87
+ await seedMachine({ hostname: 'authentik', zone: 'app', ip: '10.0.20.30' });
88
+ await seedMachine({ hostname: 'celilo-mgmt', zone: 'secure', ip: '10.0.30.5' });
89
+ await seedMachine({ hostname: 'knot', zone: 'internal', ip: '192.168.0.10' });
90
+ // greenwave: appliance, must be skipped
91
+ await seedMachine({
92
+ hostname: 'greenwave',
93
+ zone: 'internal',
94
+ ip: '192.168.0.1',
95
+ apiOnly: true,
96
+ });
97
+
98
+ const plan = await planAspectFanOut(baseAspect);
99
+ const targetHostnames = plan.targetSystems.map((m) => m.hostname).sort();
100
+ expect(targetHostnames).toEqual(['authentik', 'caddy', 'celilo-mgmt', 'knot']);
101
+ expect(plan.skipped.map((s) => s.machine.hostname)).toEqual(['greenwave']);
102
+ expect(plan.skipped[0]?.reason).toBe('api_only');
103
+ });
104
+
105
+ it('excludes hostnames the caller asks to skip', async () => {
106
+ await seedMachine({ hostname: 'knot', zone: 'internal', ip: '192.168.0.10' });
107
+ await seedMachine({ hostname: 'other', zone: 'internal', ip: '192.168.0.11' });
108
+
109
+ const plan = await planAspectFanOut(baseAspect, { excludeHostnames: ['knot'] });
110
+ expect(plan.targetSystems.map((m) => m.hostname)).toEqual(['other']);
111
+ // excludeHostnames-removed systems aren't in `skipped` — the
112
+ // caller's expressing a deliberate "don't include this", not
113
+ // an exclusion the framework chose.
114
+ expect(plan.skipped).toEqual([]);
115
+ });
116
+
117
+ it('returns an empty plan when no machines match the zones', async () => {
118
+ // Only a dmz machine present; aspect targets nothing else.
119
+ await seedMachine({ hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' });
120
+ const onlySecure: BaseModuleAspect = { ...baseAspect, applicable_zones: ['secure'] };
121
+ const plan = await planAspectFanOut(onlySecure);
122
+ expect(plan.targetSystems).toEqual([]);
123
+ expect(plan.skipped).toEqual([]);
124
+ });
125
+
126
+ it('includes the management system (secure zone, no special casing)', async () => {
127
+ // Per CELILO_BASE.md D1 sub-section: mgmt is part of the fleet,
128
+ // not a special-case exclusion. As long as it's in a zone the
129
+ // aspect targets and isn't api_only, it gets the aspect.
130
+ await seedMachine({ hostname: 'celilo-mgmt', zone: 'secure', ip: '10.0.30.5' });
131
+ const plan = await planAspectFanOut({ ...baseAspect, applicable_zones: ['secure'] });
132
+ expect(plan.targetSystems.map((m) => m.hostname)).toEqual(['celilo-mgmt']);
133
+ });
134
+ });
135
+
136
+ describe('materializeAspectAnsible', () => {
137
+ function makeFakeRole(moduleSourcePath: string, roleName: string) {
138
+ const taskFile = join(
139
+ moduleSourcePath,
140
+ 'base-module-aspect',
141
+ 'ansible',
142
+ 'roles',
143
+ roleName,
144
+ 'tasks',
145
+ 'main.yml',
146
+ );
147
+ mkdirSync(join(taskFile, '..'), { recursive: true });
148
+ writeFileSync(taskFile, "---\n- name: noop\n ansible.builtin.debug:\n msg: 'ok'\n");
149
+ }
150
+
151
+ it('builds an inventory + playbook + role copy in a temp workspace', async () => {
152
+ await seedMachine({ hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' });
153
+ await seedMachine({ hostname: 'authentik', zone: 'app', ip: '10.0.20.30' });
154
+
155
+ const moduleSourcePath = join(dir, 'module-src');
156
+ makeFakeRole(moduleSourcePath, 'dns-client-config');
157
+
158
+ const plan = await planAspectFanOut(baseAspect);
159
+ const workDir = await materializeAspectAnsible({
160
+ aspect: baseAspect,
161
+ moduleSourcePath,
162
+ targetSystems: plan.targetSystems,
163
+ });
164
+
165
+ // Inventory file lists both systems under the 'aspect_targets'
166
+ // group plus their per-zone groups.
167
+ const hostsIni = readFileSync(join(workDir, 'ansible', 'inventory', 'hosts.ini'), 'utf-8');
168
+ expect(hostsIni).toContain('[aspect_targets]');
169
+ expect(hostsIni).toContain('caddy ansible_host=10.0.10.10');
170
+ expect(hostsIni).toContain('authentik ansible_host=10.0.20.30');
171
+ expect(hostsIni).toContain('[dmz]');
172
+ expect(hostsIni).toContain('[app]');
173
+
174
+ // Per-host vars carry target_zone.
175
+ const caddyVars = readFileSync(
176
+ join(workDir, 'ansible', 'inventory', 'host_vars', 'caddy.yml'),
177
+ 'utf-8',
178
+ );
179
+ expect(caddyVars).toContain('target_zone: dmz');
180
+ const authentikVars = readFileSync(
181
+ join(workDir, 'ansible', 'inventory', 'host_vars', 'authentik.yml'),
182
+ 'utf-8',
183
+ );
184
+ expect(authentikVars).toContain('target_zone: app');
185
+
186
+ // Synthesized playbook invokes the named role against
187
+ // 'aspect_targets'.
188
+ const playbook = readFileSync(join(workDir, 'ansible', 'playbook.yml'), 'utf-8');
189
+ expect(playbook).toContain('hosts: aspect_targets');
190
+ expect(playbook).toContain('role: dns-client-config');
191
+ expect(playbook).toContain('become: true');
192
+
193
+ // Role files were copied across.
194
+ const copiedTask = readFileSync(
195
+ join(workDir, 'ansible', 'roles', 'dns-client-config', 'tasks', 'main.yml'),
196
+ 'utf-8',
197
+ );
198
+ expect(copiedTask).toContain('name: noop');
199
+
200
+ // Cleanup (caller's responsibility per the contract).
201
+ rmSync(workDir, { recursive: true, force: true });
202
+ });
203
+
204
+ it('throws a clear error when the aspect role directory is missing', async () => {
205
+ await seedMachine({ hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' });
206
+ const moduleSourcePath = join(dir, 'module-src-without-role');
207
+ mkdirSync(moduleSourcePath, { recursive: true });
208
+ const plan = await planAspectFanOut(baseAspect);
209
+ await expect(
210
+ materializeAspectAnsible({
211
+ aspect: baseAspect,
212
+ moduleSourcePath,
213
+ targetSystems: plan.targetSystems,
214
+ }),
215
+ ).rejects.toThrow(/Aspect role not found/);
216
+ });
217
+
218
+ it('writes resolved ansible_vars to group_vars/all/aspect_vars.yml', async () => {
219
+ // Seed the providing module + its target_ip so the
220
+ // $self:target_ip template resolves.
221
+ const { moduleConfigs, modules } = await import('../db/schema');
222
+ getDb()
223
+ .insert(modules)
224
+ .values({
225
+ id: 'knot-unbound-internal',
226
+ name: 'knot-unbound-internal',
227
+ version: '1.0.0',
228
+ manifestData: {
229
+ id: 'knot-unbound-internal',
230
+ name: 'knot-unbound-internal',
231
+ version: '1.0.0',
232
+ celilo_contract: '1.0',
233
+ },
234
+ sourcePath: '/tmp/knot',
235
+ })
236
+ .run();
237
+ getDb()
238
+ .insert(moduleConfigs)
239
+ .values({ moduleId: 'knot-unbound-internal', key: 'target_ip', value: '192.168.0.10' })
240
+ .run();
241
+
242
+ await seedMachine({ hostname: 'app-host', zone: 'app', ip: '10.0.20.20' });
243
+ const moduleSourcePath = join(dir, 'module-src-with-vars');
244
+ makeFakeRole(moduleSourcePath, 'dns-client-config');
245
+
246
+ const aspectWithVars: BaseModuleAspect = {
247
+ ...baseAspect,
248
+ ansible_vars: { knot_server_ip: '$self:target_ip' },
249
+ };
250
+
251
+ const plan = await planAspectFanOut(aspectWithVars);
252
+ const workDir = await materializeAspectAnsible({
253
+ aspect: aspectWithVars,
254
+ moduleSourcePath,
255
+ targetSystems: plan.targetSystems,
256
+ providerModuleId: 'knot-unbound-internal',
257
+ db: getDb(),
258
+ });
259
+
260
+ const aspectVars = readFileSync(
261
+ join(workDir, 'ansible', 'inventory', 'group_vars', 'all', 'aspect_vars.yml'),
262
+ 'utf-8',
263
+ );
264
+ expect(aspectVars).toContain('knot_server_ip: "192.168.0.10"');
265
+
266
+ rmSync(workDir, { recursive: true, force: true });
267
+ });
268
+
269
+ it('skips group_vars file when the aspect has no ansible_vars', async () => {
270
+ await seedMachine({ hostname: 'app-host', zone: 'app', ip: '10.0.20.20' });
271
+ const moduleSourcePath = join(dir, 'module-src-no-vars');
272
+ makeFakeRole(moduleSourcePath, 'dns-client-config');
273
+
274
+ const plan = await planAspectFanOut(baseAspect);
275
+ const workDir = await materializeAspectAnsible({
276
+ aspect: baseAspect, // no ansible_vars
277
+ moduleSourcePath,
278
+ targetSystems: plan.targetSystems,
279
+ });
280
+
281
+ const groupVarsPath = join(
282
+ workDir,
283
+ 'ansible',
284
+ 'inventory',
285
+ 'group_vars',
286
+ 'all',
287
+ 'aspect_vars.yml',
288
+ );
289
+ const { existsSync } = await import('node:fs');
290
+ expect(existsSync(groupVarsPath)).toBe(false);
291
+
292
+ rmSync(workDir, { recursive: true, force: true });
293
+ });
294
+ });
295
+
296
+ describe('maybeRunAspectForTrigger', () => {
297
+ const manifestWithAspect: ModuleManifest = {
298
+ celilo_contract: '1.0',
299
+ id: 'knot-unbound-internal',
300
+ name: 'Knot Unbound Internal',
301
+ version: '1.0.0',
302
+ base_module_aspect: {
303
+ ansible_role: 'dns-client-config',
304
+ applicable_zones: ['dmz', 'app', 'secure', 'internal'],
305
+ triggers: ['on_install'],
306
+ },
307
+ requires: { capabilities: [] },
308
+ provides: { capabilities: [] },
309
+ variables: { owns: [], imports: [] },
310
+ secrets: { declares: [] },
311
+ hooks: {},
312
+ } as unknown as ModuleManifest;
313
+
314
+ function seedModuleRow(opts: { id: string; version: string }) {
315
+ getDb()
316
+ .insert(modules)
317
+ .values({
318
+ id: opts.id,
319
+ name: opts.id,
320
+ version: opts.version,
321
+ manifestData: {
322
+ id: opts.id,
323
+ name: opts.id,
324
+ version: opts.version,
325
+ celilo_contract: '1.0',
326
+ },
327
+ sourcePath: `/tmp/${opts.id}`,
328
+ })
329
+ .run();
330
+ }
331
+
332
+ /** Recording fake runner — captures the args, returns the canned result. */
333
+ function makeFakeRunner(success = true) {
334
+ const calls: Array<{ moduleId: string; trigger: string; zones: string[] }> = [];
335
+ const fake = async (args: {
336
+ moduleId: string;
337
+ aspect: BaseModuleAspect;
338
+ moduleSourcePath: string;
339
+ options: { trigger: string };
340
+ db: unknown;
341
+ }): Promise<AspectRunResult> => {
342
+ calls.push({
343
+ moduleId: args.moduleId,
344
+ trigger: args.options.trigger,
345
+ zones: [...args.aspect.applicable_zones],
346
+ });
347
+ return {
348
+ success,
349
+ output: success ? 'fake-output' : '',
350
+ error: success ? undefined : 'fake-error',
351
+ plan: { targetSystems: [], skipped: [] },
352
+ };
353
+ };
354
+ return { fake, calls };
355
+ }
356
+
357
+ it('skips when the manifest has no base_module_aspect', async () => {
358
+ const manifest = { ...manifestWithAspect, base_module_aspect: undefined };
359
+ const { fake, calls } = makeFakeRunner();
360
+ const result = await maybeRunAspectForTrigger({
361
+ moduleId: 'knot-unbound-internal',
362
+ manifest: manifest as ModuleManifest,
363
+ trigger: 'on_install',
364
+ db: getDb(),
365
+ runner: fake,
366
+ });
367
+ expect(result.ran).toBe(false);
368
+ expect(result.reason).toBe('no_aspect');
369
+ expect(calls).toHaveLength(0);
370
+ });
371
+
372
+ it("skips when the trigger isn't declared on the aspect", async () => {
373
+ seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' });
374
+ recordAspectApproval({
375
+ moduleId: 'knot-unbound-internal',
376
+ version: '1.0.0',
377
+ scopeHash: computeAspectScopeHash(
378
+ manifestWithAspect.base_module_aspect as BaseModuleAspect,
379
+ ),
380
+ approver: null,
381
+ db: getDb(),
382
+ });
383
+ const { fake, calls } = makeFakeRunner();
384
+ const result = await maybeRunAspectForTrigger({
385
+ moduleId: 'knot-unbound-internal',
386
+ manifest: manifestWithAspect,
387
+ trigger: 'on_new_system_in_zone', // not in the declared triggers
388
+ db: getDb(),
389
+ runner: fake,
390
+ });
391
+ expect(result.ran).toBe(false);
392
+ expect(result.reason).toBe('trigger_not_declared');
393
+ expect(calls).toHaveLength(0);
394
+ });
395
+
396
+ it('skips with "no_approval" when the operator never approved', async () => {
397
+ seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' });
398
+ // No recordAspectApproval call.
399
+ const { fake, calls } = makeFakeRunner();
400
+ const result = await maybeRunAspectForTrigger({
401
+ moduleId: 'knot-unbound-internal',
402
+ manifest: manifestWithAspect,
403
+ trigger: 'on_install',
404
+ db: getDb(),
405
+ runner: fake,
406
+ });
407
+ expect(result.ran).toBe(false);
408
+ expect(result.reason).toBe('no_approval');
409
+ expect(calls).toHaveLength(0);
410
+ });
411
+
412
+ it('skips with "scope_changed" when manifest scope diverged from approval', async () => {
413
+ seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' });
414
+ // Approve under a narrower scope than the manifest now declares.
415
+ const oldScope: BaseModuleAspect = {
416
+ ansible_role: 'dns-client-config',
417
+ applicable_zones: ['app'],
418
+ triggers: ['on_install'],
419
+ };
420
+ recordAspectApproval({
421
+ moduleId: 'knot-unbound-internal',
422
+ version: '1.0.0',
423
+ scopeHash: computeAspectScopeHash(oldScope),
424
+ approver: null,
425
+ db: getDb(),
426
+ });
427
+ const { fake, calls } = makeFakeRunner();
428
+ const result = await maybeRunAspectForTrigger({
429
+ moduleId: 'knot-unbound-internal',
430
+ manifest: manifestWithAspect,
431
+ trigger: 'on_install',
432
+ db: getDb(),
433
+ runner: fake,
434
+ });
435
+ expect(result.ran).toBe(false);
436
+ expect(result.reason).toBe('scope_changed');
437
+ expect(calls).toHaveLength(0);
438
+ });
439
+
440
+ it('dispatches to the runner when everything checks out', async () => {
441
+ seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' });
442
+ recordAspectApproval({
443
+ moduleId: 'knot-unbound-internal',
444
+ version: '1.0.0',
445
+ scopeHash: computeAspectScopeHash(
446
+ manifestWithAspect.base_module_aspect as BaseModuleAspect,
447
+ ),
448
+ approver: null,
449
+ db: getDb(),
450
+ });
451
+ const { fake, calls } = makeFakeRunner();
452
+ const result = await maybeRunAspectForTrigger({
453
+ moduleId: 'knot-unbound-internal',
454
+ manifest: manifestWithAspect,
455
+ trigger: 'on_install',
456
+ db: getDb(),
457
+ runner: fake,
458
+ });
459
+ expect(result.ran).toBe(true);
460
+ expect(result.success).toBe(true);
461
+ expect(calls).toHaveLength(1);
462
+ expect(calls[0]).toEqual({
463
+ moduleId: 'knot-unbound-internal',
464
+ trigger: 'on_install',
465
+ zones: ['dmz', 'app', 'secure', 'internal'],
466
+ });
467
+ });
468
+
469
+ it('returns {ran:true, success:false} when the runner fails (no rollback)', async () => {
470
+ seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' });
471
+ recordAspectApproval({
472
+ moduleId: 'knot-unbound-internal',
473
+ version: '1.0.0',
474
+ scopeHash: computeAspectScopeHash(
475
+ manifestWithAspect.base_module_aspect as BaseModuleAspect,
476
+ ),
477
+ approver: null,
478
+ db: getDb(),
479
+ });
480
+ const { fake } = makeFakeRunner(false);
481
+ const result = await maybeRunAspectForTrigger({
482
+ moduleId: 'knot-unbound-internal',
483
+ manifest: manifestWithAspect,
484
+ trigger: 'on_install',
485
+ db: getDb(),
486
+ runner: fake,
487
+ });
488
+ expect(result.ran).toBe(true);
489
+ expect(result.success).toBe(false);
490
+ expect(result.runResult?.error).toBe('fake-error');
491
+ });
492
+ });
493
+ });