@celilo/cli 0.3.30 → 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 +5 -4
  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
@@ -6,6 +6,8 @@
6
6
  * (e.g., container_created triggers web automation for API key creation).
7
7
  */
8
8
 
9
+ import type { DeployedSystem } from '@celilo/capabilities';
10
+
9
11
  /**
10
12
  * Hook definition from module manifest.
11
13
  *
@@ -52,6 +54,11 @@ export interface HookContext {
52
54
  config: Record<string, unknown>;
53
55
  /** Module secret values (decrypted) */
54
56
  secrets: Record<string, string>;
57
+ /**
58
+ * The 0..N systems this module has deployed onto (v2/MODULE_SYSTEMS_ADDRESSING.md).
59
+ * Always an array — no singular convenience, so the 0/1/N reality stays visible.
60
+ */
61
+ systems: DeployedSystem[];
55
62
  /** Logger for reporting progress */
56
63
  logger: HookLogger;
57
64
  /** Run in debug mode (e.g., Playwright headless: false) */
@@ -111,7 +118,8 @@ export type HookName =
111
118
  | 'validate_config'
112
119
  | 'on_backup'
113
120
  | 'on_backup_analyze'
114
- | 'on_restore';
121
+ | 'on_restore'
122
+ | 'on_system_event';
115
123
 
116
124
  /**
117
125
  * Hook manifest section - maps hook names to definitions
@@ -81,6 +81,30 @@ export const V1_HOOKS: ContractHooks = {
81
81
  on_backup: {
82
82
  inputs: {
83
83
  backup_dir: { required: true },
84
+ /**
85
+ * Path to a directory containing read-only mirrors of OTHER modules'
86
+ * `generated/terraform/` trees, plus an `index.json` enumerating
87
+ * deployed modules. Populated by the framework ONLY when the
88
+ * declaring module has `cross_module_read` in its
89
+ * `requires.capabilities` AND is on the privilege allow-list (see
90
+ * `validatePrivilegedCapabilities` in manifest/validate.ts). Today
91
+ * only celilo-mgmt is allow-listed.
92
+ *
93
+ * Hooks that don't require the privilege won't see this input.
94
+ * Hooks that do require it should treat the directory as
95
+ * read-only — modifying it has no effect (the framework discards
96
+ * changes after the hook returns).
97
+ *
98
+ * Layout:
99
+ * <cross_module_root>/
100
+ * index.json # { modules: [{ id, version, terraformStateDir }] }
101
+ * modules/
102
+ * <module-id>/
103
+ * terraform/
104
+ * terraform.tfstate
105
+ * terraform.tfstate.backup
106
+ */
107
+ cross_module_root: { required: false },
84
108
  },
85
109
  outputs: {
86
110
  artifact_count: { required: true },
@@ -102,11 +126,57 @@ export const V1_HOOKS: ContractHooks = {
102
126
  inputs: {
103
127
  restore_dir: { required: true },
104
128
  schema_version: { required: true },
129
+ /**
130
+ * Path to a writable staging directory the framework atomically
131
+ * applies back onto OTHER modules' on-disk state after the hook
132
+ * returns successfully. Same allow-list as `cross_module_root`
133
+ * on on_backup; only celilo-mgmt receives it today.
134
+ *
135
+ * Hooks write files matching the on_backup layout
136
+ * (`modules/<module-id>/terraform/terraform.tfstate` etc.). The
137
+ * framework moves each subdir into the live storage path with
138
+ * a single rename + cleanup of any stale files, so a partial
139
+ * write (hook crashed mid-restore) leaves the live state intact.
140
+ */
141
+ cross_module_write_root: { required: false },
105
142
  },
106
143
  outputs: {
107
144
  restored_items: { required: true },
108
145
  },
109
146
  },
147
+ /**
148
+ * Per-system DNS lifecycle hook (v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md D5).
149
+ *
150
+ * A dns_internal provider declares this hook; celilo's internal-dns bridge
151
+ * invokes it once per host when a system.created/destroyed event fires,
152
+ * supplying that host's identity. The bridge owns the cross-module concerns
153
+ * (which module is the provider, the full host inventory for backfill); the
154
+ * hook owns only the DNS mechanics (register/deregister across the zones the
155
+ * provider is authoritative for). No structured outputs — the hook throws on
156
+ * failure (no-surprises; v2/issues/ISS-0004).
157
+ */
158
+ on_system_event: {
159
+ inputs: {
160
+ /** Short hostname of the system being (de)registered, e.g. "www". */
161
+ hostname: { required: true },
162
+ /** The system's A-record target IP (no CIDR suffix), e.g. "10.0.10.10". */
163
+ target_ip: { required: true },
164
+ /** "register" on system.created, "deregister" on system.destroyed. */
165
+ op: { required: true },
166
+ },
167
+ outputs: {},
168
+ },
169
+ /**
170
+ * Build-bus upstream publish hook. The executor passes the
171
+ * PublishEvent fields as env vars (CELILO_EVENT_PAYLOAD,
172
+ * CELILO_EVENT_PACKAGE_NAME, etc.) rather than as named inputs
173
+ * here — see v2/BUILD_BUS.md Phase 4 + the hook-dispatch executor.
174
+ * No structured outputs; hook reports success via exit code.
175
+ */
176
+ on_upstream_publish: {
177
+ inputs: {},
178
+ outputs: {},
179
+ },
110
180
  };
111
181
 
112
182
  /**
@@ -209,6 +209,23 @@ export const EnsureSchema = z.object({
209
209
 
210
210
  export type Ensure = z.infer<typeof EnsureSchema>;
211
211
 
212
+ /**
213
+ * A computed capability field (v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md D1).
214
+ *
215
+ * Declared alongside `data`, but derived ON ACCESS from other values via the
216
+ * `value:` DSL (see src/variables/computed/) and never persisted. The result
217
+ * TYPE is inferred from the expression — deliberately not declared, which is
218
+ * why `type` is the fixed literal `computed` rather than a real type.
219
+ */
220
+ export const ComputedFieldSchema = z.object({
221
+ name: z.string().min(1),
222
+ type: z.literal('computed'),
223
+ value: z.string().min(1),
224
+ description: z.string().optional(),
225
+ });
226
+
227
+ export type ComputedField = z.infer<typeof ComputedFieldSchema>;
228
+
212
229
  /**
213
230
  * Capability provider
214
231
  * Module provides this capability to other modules
@@ -217,6 +234,13 @@ export const CapabilityProviderSchema = z.object({
217
234
  name: z.string().min(1),
218
235
  version: z.string().min(1),
219
236
  data: z.record(z.unknown()),
237
+ /**
238
+ * Computed fields, merged into the capability's data namespace at access
239
+ * time. A consumer reads `$capability:<name>.<computed-field>` exactly like
240
+ * a static data field; the resolver evaluates the DSL in the PROVIDER's
241
+ * context. Names must not collide with `data` keys.
242
+ */
243
+ computed: z.array(ComputedFieldSchema).optional(),
220
244
  secrets: z.array(CapabilitySecretSchema).optional(),
221
245
  functions: z.array(z.string()).optional(),
222
246
  /** Zones this capability applies to. Null/undefined = zone-agnostic. */
@@ -242,6 +266,38 @@ export const LifecycleHookSchema = z.object({
242
266
  timeout: z.number().positive().optional(),
243
267
  });
244
268
 
269
+ /**
270
+ * Build-bus upstream-publish hook. Fires when a publish event lands
271
+ * on the local event bus from the receiver daemon
272
+ * ([[v2/BUILD_BUS.md]] Phase 4). Each module can declare multiple
273
+ * entries, each with its own match rule + action script.
274
+ *
275
+ * The action script runs with `CELILO_EVENT_PAYLOAD` in its env (the
276
+ * full PublishEvent as JSON), plus `CELILO_EVENT_PACKAGE_NAME` /
277
+ * `CELILO_EVENT_PACKAGE_VERSION` for the common fields. Exit status
278
+ * non-zero is logged but doesn't propagate to the publisher.
279
+ */
280
+ export const UpstreamPublishHookSchema = z.object({
281
+ /** Optional display label for logs / `celilo subscribers status`. */
282
+ name: z.string().optional(),
283
+ /**
284
+ * Match rule — every non-undefined field must equal the
285
+ * corresponding field on the incoming event. Shape mirrors
286
+ * `SubscriberMatch` in @celilo/event-bus/build-bus.
287
+ */
288
+ match: z
289
+ .object({
290
+ registry: z.string().optional(),
291
+ tag: z.string().optional(),
292
+ package_pattern: z.string().optional(),
293
+ })
294
+ .default({}),
295
+ /** Path to the action script, relative to the module directory. */
296
+ script: z.string().min(1),
297
+ /** Seconds before the script is killed. Default: 600. */
298
+ timeout: z.number().positive().optional(),
299
+ });
300
+
245
301
  /**
246
302
  * Machine resource recommendations
247
303
  * Module declares recommended machine resources (CPU, memory, disk, storage)
@@ -256,6 +312,21 @@ export const MachineResourceSchema = z.object({
256
312
  .describe('Required security zone for this module'),
257
313
  });
258
314
 
315
+ /**
316
+ * One system a module deploys (v2/MODULE_SYSTEMS_ADDRESSING.md). A module
317
+ * declares 0..N of these under `requires.systems`. `name` is the stable
318
+ * authoring-time handle — referenced in templates via `$infra:<name>.…` and the
319
+ * per-system key in `module_systems`. `resources` carries the per-system machine
320
+ * spec + zone (each system can sit in a different zone).
321
+ */
322
+ export const SystemDeclarationSchema = z.object({
323
+ name: z
324
+ .string()
325
+ .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'system name must be kebab-case')
326
+ .describe('Stable handle for this system; used in $infra:<name> and as the per-system key'),
327
+ resources: MachineResourceSchema,
328
+ });
329
+
259
330
  /**
260
331
  * Ansible collection requirement
261
332
  * Declares Ansible collections this module needs
@@ -289,19 +360,45 @@ export const AnsibleCollectionSchema = z.object({
289
360
  * - `$self` in `pattern` → the module's id
290
361
  * - `${MODULE_PATH}` in `handler` → the module's installed path
291
362
  */
292
- export const ModuleSubscriptionSchema = z.object({
293
- name: z
294
- .string()
295
- .min(1)
296
- .regex(
297
- /^[a-z][a-z0-9_-]*$/,
298
- 'Subscription name must be kebab/snake_case (lowercase, alphanumeric, dash/underscore)',
299
- ),
300
- pattern: z.string().min(1),
301
- handler: z.string().min(1),
302
- max_attempts: z.number().int().positive().optional(),
303
- timeout_ms: z.number().int().positive().optional(),
304
- });
363
+ export const ModuleSubscriptionSchema = z
364
+ .object({
365
+ name: z
366
+ .string()
367
+ .min(1)
368
+ .regex(
369
+ /^[a-z][a-z0-9_-]*$/,
370
+ 'Subscription name must be kebab/snake_case (lowercase, alphanumeric, dash/underscore)',
371
+ ),
372
+ pattern: z.string().min(1),
373
+ /**
374
+ * Shell-command handler the dispatcher spawns as a subprocess. Mutually
375
+ * exclusive with `hook` — a subscription declares exactly one.
376
+ */
377
+ handler: z.string().min(1).optional(),
378
+ /**
379
+ * Name of one of THIS module's own hooks to invoke when a matching event
380
+ * fires (v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md). The framework synthesizes
381
+ * a `celilo events run-hook` handler that runs the hook in a
382
+ * fault-isolated subprocess with backend access (DB, capabilities,
383
+ * secrets). Mutually exclusive with `handler`.
384
+ */
385
+ hook: z.string().min(1).optional(),
386
+ /**
387
+ * Static inputs merged into the hook's `inputs` (e.g. `{ op: register }`),
388
+ * so two subscriptions can drive the same hook with different intent.
389
+ * Event payload fields are merged on top. Only valid alongside `hook`.
390
+ */
391
+ hook_inputs: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
392
+ max_attempts: z.number().int().positive().optional(),
393
+ timeout_ms: z.number().int().positive().optional(),
394
+ })
395
+ .strict()
396
+ .refine((s) => (s.handler ? 1 : 0) + (s.hook ? 1 : 0) === 1, {
397
+ message: 'subscription must declare exactly one of `handler` or `hook`',
398
+ })
399
+ .refine((s) => !s.hook_inputs || Boolean(s.hook), {
400
+ message: '`hook_inputs` is only valid together with `hook`',
401
+ });
305
402
 
306
403
  export type ModuleSubscription = z.infer<typeof ModuleSubscriptionSchema>;
307
404
 
@@ -337,9 +434,16 @@ export const ModuleManifestSchema = z
337
434
  .object({
338
435
  capabilities: z.array(CapabilityRequirementSchema).default([]),
339
436
  /**
340
- * Infrastructure this module needs. Was `resources.machine` in v1.
341
- * Optional because some modules (e.g. config-only providers like
342
- * namecheap) don't deploy infrastructure.
437
+ * The 0..N systems this module deploys (v2/MODULE_SYSTEMS_ADDRESSING.md).
438
+ * Preferred over the legacy singular `machine`. A module declaring
439
+ * `systems` gets one host per entry, each addressable as `$infra:<name>`.
440
+ */
441
+ systems: z.array(SystemDeclarationSchema).optional(),
442
+ /**
443
+ * Legacy singular form — sugar for a single unnamed system. Normalized
444
+ * to one `systems` entry (name `main`) by `getDeclaredSystems`. Optional
445
+ * because config-only providers (e.g. namecheap) deploy no infrastructure.
446
+ * New modules should prefer `systems`.
343
447
  */
344
448
  machine: MachineResourceSchema.optional(),
345
449
  })
@@ -399,6 +503,20 @@ export const ModuleManifestSchema = z
399
503
  on_backup: LifecycleHookSchema.optional(),
400
504
  on_backup_analyze: LifecycleHookSchema.optional(),
401
505
  on_restore: LifecycleHookSchema.optional(),
506
+ /**
507
+ * Per-system lifecycle hook. A dns_internal provider declares this
508
+ * to (de)register a single host's A records when celilo's bridge
509
+ * delivers a system.created/destroyed event. Inputs (hostname,
510
+ * target_ip, op) come from the contract — see contracts/v1.ts and
511
+ * [[v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md]] D5.
512
+ */
513
+ on_system_event: LifecycleHookSchema.optional(),
514
+ /**
515
+ * Build-bus upstream publish hooks. Array (a module can react
516
+ * to multiple upstream packages with different actions). See
517
+ * [[v2/BUILD_BUS.md]] Phase 4.
518
+ */
519
+ on_upstream_publish: z.array(UpstreamPublishHookSchema).optional(),
402
520
  })
403
521
  .strict()
404
522
  .optional(),
@@ -477,6 +595,113 @@ export const ModuleManifestSchema = z
477
595
  * use `manifest.subscriptions ?? []`.
478
596
  */
479
597
  subscriptions: z.array(ModuleSubscriptionSchema).optional(),
598
+
599
+ /**
600
+ * Optional base-module aspect — lightweight fleet-configuration
601
+ * code applied to OTHER systems in declared zones, on top of
602
+ * the module's primary deployment. See v2/CELILO_BASE.md.
603
+ *
604
+ * Modules without this block work exactly as today. When
605
+ * declared, the operator approves the aspect's scope at
606
+ * `celilo module import` time (or passes `--accept-aspects` for
607
+ * non-interactive use). The framework then runs the named
608
+ * Ansible role against every non-`api_only` system in
609
+ * `applicable_zones` whenever a declared trigger fires.
610
+ */
611
+ base_module_aspect: z
612
+ .object({
613
+ /**
614
+ * The Ansible role under `base-module-aspect/ansible/roles/`
615
+ * that runs on each target system. Receives module config
616
+ * + capability data as ansible vars plus a `target_zone`
617
+ * fact.
618
+ */
619
+ ansible_role: z.string().min(1),
620
+
621
+ /**
622
+ * Zones this aspect fans out to. There is no operator-level
623
+ * opt-in beyond import-time approval — these zones ARE the
624
+ * target set. At least one zone must be listed.
625
+ */
626
+ applicable_zones: z.array(z.string().min(1)).min(1),
627
+
628
+ /**
629
+ * Events that should cause the aspect to fan out.
630
+ *
631
+ * Phase 1 ships only `on_install`. Later phases add
632
+ * `on_new_system_in_zone`, `on_aspect_change`,
633
+ * `on_module_config_change`, `on_capability_data_change` —
634
+ * the enum is open here so manifests can declare future
635
+ * triggers ahead of framework support landing, but only the
636
+ * triggers the framework knows about will actually fire.
637
+ */
638
+ triggers: z
639
+ .array(
640
+ z.enum([
641
+ 'on_install',
642
+ 'on_new_system_in_zone',
643
+ 'on_aspect_change',
644
+ 'on_module_config_change',
645
+ 'on_capability_data_change',
646
+ ]),
647
+ )
648
+ .min(1)
649
+ .default(['on_install']),
650
+
651
+ /**
652
+ * Optional Ansible variables to inject into the aspect's
653
+ * inventory before the role runs. Each entry maps a
654
+ * variable name (consumed by the role as `{{ name }}`) to
655
+ * a value template using the same `$self:` / `$capability:`
656
+ * / `$system:` substitution celilo uses elsewhere. Values
657
+ * resolve against the PROVIDING module's context at fan-out
658
+ * time, then land in `group_vars/all/aspect_vars.yml` so
659
+ * every targeted system's role can read them.
660
+ *
661
+ * Example (knot-unbound-internal):
662
+ * ansible_vars:
663
+ * knot_server_ip: $self:target_ip
664
+ *
665
+ * Without this block the role only receives `target_zone`
666
+ * — useful but rarely sufficient.
667
+ */
668
+ ansible_vars: z.record(z.string(), z.string()).optional(),
669
+
670
+ /**
671
+ * Optional Proxmox reconciliation (v2/CELILO_BASE.md D5).
672
+ *
673
+ * When an aspect manages a setting that Proxmox also owns
674
+ * authoritatively (DNS resolver via `proxmox_lxc.nameserver`
675
+ * is the canonical example), declaring `proxmox_reconcile.tfvars`
676
+ * tells the framework to ALSO update the LXC's owning
677
+ * module's terraform config — not just the running
678
+ * /etc/resolv.conf via Ansible. Without this, a re-provision
679
+ * would revert to the stale Proxmox-baked value.
680
+ *
681
+ * Each entry maps a `terraform.tfvars` variable name to a
682
+ * value-template. Templates support the same `$capability:`
683
+ * and `$self:` substitutions celilo uses elsewhere; the
684
+ * value is resolved against the providing module's context
685
+ * (capability data + module config) at fan-out time.
686
+ *
687
+ * Only applies to systems whose `module_infrastructure`
688
+ * row is `container_service` AND the service's provider is
689
+ * Proxmox. Machine-pool systems silently skip this block
690
+ * because they aren't terraform-managed at the celilo
691
+ * layer.
692
+ */
693
+ proxmox_reconcile: z
694
+ .object({
695
+ tfvars: z.record(z.string(), z.string()),
696
+ })
697
+ .strict()
698
+ .optional(),
699
+ })
700
+ // strict on the inner object: operators approve this exact
701
+ // block at import time (D2), so silently-ignored unknown
702
+ // fields would let a manifest broaden scope without consent.
703
+ .strict()
704
+ .optional(),
480
705
  })
481
706
  .strict();
482
707
 
@@ -494,3 +719,24 @@ export type VariableSource = z.infer<typeof VariableSourceSchema>;
494
719
  export type VariableType = z.infer<typeof VariableTypeSchema>;
495
720
  export type AnsibleCollection = z.infer<typeof AnsibleCollectionSchema>;
496
721
  export type MachineResource = z.infer<typeof MachineResourceSchema>;
722
+ export type SystemDeclaration = z.infer<typeof SystemDeclarationSchema>;
723
+ export type BaseModuleAspect = NonNullable<ModuleManifest['base_module_aspect']>;
724
+ export type BaseModuleAspectTrigger = BaseModuleAspect['triggers'][number];
725
+
726
+ /**
727
+ * Normalize a manifest's declared systems (v2/MODULE_SYSTEMS_ADDRESSING.md).
728
+ * Returns `requires.systems` if present; else the legacy singular
729
+ * `requires.machine` as one entry named `main`; else `[]` (config-only module
730
+ * like namecheap). This is the single place the machine→systems sugar lives, so
731
+ * the rest of the codebase only ever reasons about the 0..N collection.
732
+ */
733
+ export function getDeclaredSystems(manifest: ModuleManifest): SystemDeclaration[] {
734
+ const requires = manifest.requires;
735
+ if (requires?.systems && requires.systems.length > 0) {
736
+ return requires.systems;
737
+ }
738
+ if (requires?.machine) {
739
+ return [{ name: 'main', resources: requires.machine }];
740
+ }
741
+ return [];
742
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Tests for validatePrivilegedCapabilities — the allow-list gate that
3
+ * rejects modules requiring framework-granted privileges (today: only
4
+ * cross_module_read, allow-listed to celilo-mgmt).
5
+ */
6
+
7
+ import { describe, expect, it } from 'bun:test';
8
+ import type { ModuleManifest } from './schema';
9
+ import { validatePrivilegedCapabilities } from './validate';
10
+
11
+ function buildManifest(overrides: Partial<ModuleManifest>): ModuleManifest {
12
+ return {
13
+ celilo_contract: '1.0',
14
+ id: 'some-module',
15
+ name: 'some module',
16
+ version: '0.0.1',
17
+ requires: { capabilities: [] },
18
+ ...overrides,
19
+ } as ModuleManifest;
20
+ }
21
+
22
+ describe('validatePrivilegedCapabilities', () => {
23
+ it('accepts manifests with no privileged capabilities', () => {
24
+ const result = validatePrivilegedCapabilities(
25
+ buildManifest({
26
+ id: 'homebridge',
27
+ requires: { capabilities: [{ name: 'public_web', version: '^3.0' }] },
28
+ }),
29
+ );
30
+ expect(result).toBeNull();
31
+ });
32
+
33
+ it('accepts celilo-mgmt requiring cross_module_read', () => {
34
+ const result = validatePrivilegedCapabilities(
35
+ buildManifest({
36
+ id: 'celilo-mgmt',
37
+ requires: { capabilities: [{ name: 'cross_module_read', version: '^1.0' }] },
38
+ }),
39
+ );
40
+ expect(result).toBeNull();
41
+ });
42
+
43
+ it('rejects a non-allow-listed module requiring cross_module_read', () => {
44
+ const result = validatePrivilegedCapabilities(
45
+ buildManifest({
46
+ id: 'homebridge',
47
+ requires: { capabilities: [{ name: 'cross_module_read', version: '^1.0' }] },
48
+ }),
49
+ );
50
+ expect(result).not.toBeNull();
51
+ expect(result?.errors).toHaveLength(1);
52
+ expect(result?.errors[0].path).toBe('requires.capabilities.cross_module_read');
53
+ expect(result?.errors[0].message).toContain('framework-granted privilege');
54
+ expect(result?.errors[0].message).toContain('celilo-mgmt');
55
+ expect(result?.errors[0].message).toContain("'homebridge' is not on the allow-list");
56
+ });
57
+
58
+ it('also rejects when declared under optional.capabilities', () => {
59
+ const result = validatePrivilegedCapabilities(
60
+ buildManifest({
61
+ id: 'sneaky-module',
62
+ requires: { capabilities: [] },
63
+ optional: {
64
+ capabilities: [{ name: 'cross_module_read', version: '^1.0' }],
65
+ },
66
+ } as Partial<ModuleManifest>),
67
+ );
68
+ expect(result).not.toBeNull();
69
+ expect(result?.errors[0].path).toBe('optional.capabilities.cross_module_read');
70
+ });
71
+
72
+ it('aggregates errors when a module both requires AND optionally declares the privilege', () => {
73
+ const result = validatePrivilegedCapabilities(
74
+ buildManifest({
75
+ id: 'sneaky-module',
76
+ requires: { capabilities: [{ name: 'cross_module_read', version: '^1.0' }] },
77
+ optional: {
78
+ capabilities: [{ name: 'cross_module_read', version: '^1.0' }],
79
+ },
80
+ } as Partial<ModuleManifest>),
81
+ );
82
+ expect(result?.errors).toHaveLength(2);
83
+ });
84
+ });
@@ -1321,3 +1321,159 @@ subscriptions:
1321
1321
  expect(result.success).toBe(false);
1322
1322
  });
1323
1323
  });
1324
+
1325
+ describe('base_module_aspect field', () => {
1326
+ test('accepts a minimal valid aspect block', () => {
1327
+ const yaml = `
1328
+ ${CONTRACT_LINE}
1329
+ id: knot-unbound-internal
1330
+ name: Internal DNS
1331
+ version: 1.0.0
1332
+
1333
+ base_module_aspect:
1334
+ ansible_role: dns-client-config
1335
+ applicable_zones: [app]
1336
+ triggers: [on_install]
1337
+ `;
1338
+ const result = validateManifest(yaml);
1339
+ expect(result.success).toBe(true);
1340
+ if (result.success) {
1341
+ expect(result.data.base_module_aspect?.ansible_role).toBe('dns-client-config');
1342
+ expect(result.data.base_module_aspect?.applicable_zones).toEqual(['app']);
1343
+ expect(result.data.base_module_aspect?.triggers).toEqual(['on_install']);
1344
+ }
1345
+ });
1346
+
1347
+ test('defaults triggers to [on_install] when omitted', () => {
1348
+ const yaml = `
1349
+ ${CONTRACT_LINE}
1350
+ id: knot-unbound-internal
1351
+ name: Internal DNS
1352
+ version: 1.0.0
1353
+
1354
+ base_module_aspect:
1355
+ ansible_role: dns-client-config
1356
+ applicable_zones: [dmz, app, secure, internal]
1357
+ `;
1358
+ const result = validateManifest(yaml);
1359
+ expect(result.success).toBe(true);
1360
+ if (result.success) {
1361
+ expect(result.data.base_module_aspect?.triggers).toEqual(['on_install']);
1362
+ }
1363
+ });
1364
+
1365
+ test('accepts the full trigger set', () => {
1366
+ const yaml = `
1367
+ ${CONTRACT_LINE}
1368
+ id: knot-unbound-internal
1369
+ name: Internal DNS
1370
+ version: 1.0.0
1371
+
1372
+ base_module_aspect:
1373
+ ansible_role: dns-client-config
1374
+ applicable_zones: [app]
1375
+ triggers:
1376
+ - on_install
1377
+ - on_new_system_in_zone
1378
+ - on_aspect_change
1379
+ - on_module_config_change
1380
+ - on_capability_data_change
1381
+ `;
1382
+ const result = validateManifest(yaml);
1383
+ expect(result.success).toBe(true);
1384
+ });
1385
+
1386
+ test('rejects empty applicable_zones', () => {
1387
+ const yaml = `
1388
+ ${CONTRACT_LINE}
1389
+ id: knot-unbound-internal
1390
+ name: Internal DNS
1391
+ version: 1.0.0
1392
+
1393
+ base_module_aspect:
1394
+ ansible_role: dns-client-config
1395
+ applicable_zones: []
1396
+ triggers: [on_install]
1397
+ `;
1398
+ const result = validateManifest(yaml);
1399
+ expect(result.success).toBe(false);
1400
+ });
1401
+
1402
+ test('rejects missing ansible_role', () => {
1403
+ const yaml = `
1404
+ ${CONTRACT_LINE}
1405
+ id: knot-unbound-internal
1406
+ name: Internal DNS
1407
+ version: 1.0.0
1408
+
1409
+ base_module_aspect:
1410
+ applicable_zones: [app]
1411
+ triggers: [on_install]
1412
+ `;
1413
+ const result = validateManifest(yaml);
1414
+ expect(result.success).toBe(false);
1415
+ });
1416
+
1417
+ test('rejects unknown trigger names', () => {
1418
+ const yaml = `
1419
+ ${CONTRACT_LINE}
1420
+ id: knot-unbound-internal
1421
+ name: Internal DNS
1422
+ version: 1.0.0
1423
+
1424
+ base_module_aspect:
1425
+ ansible_role: dns-client-config
1426
+ applicable_zones: [app]
1427
+ triggers: [on_install, on_nonsense]
1428
+ `;
1429
+ const result = validateManifest(yaml);
1430
+ expect(result.success).toBe(false);
1431
+ });
1432
+
1433
+ test('rejects empty triggers when explicitly set', () => {
1434
+ const yaml = `
1435
+ ${CONTRACT_LINE}
1436
+ id: knot-unbound-internal
1437
+ name: Internal DNS
1438
+ version: 1.0.0
1439
+
1440
+ base_module_aspect:
1441
+ ansible_role: dns-client-config
1442
+ applicable_zones: [app]
1443
+ triggers: []
1444
+ `;
1445
+ const result = validateManifest(yaml);
1446
+ expect(result.success).toBe(false);
1447
+ });
1448
+
1449
+ test('omitting the block entirely is valid (most modules)', () => {
1450
+ const yaml = `
1451
+ ${CONTRACT_LINE}
1452
+ id: homebridge
1453
+ name: Homebridge
1454
+ version: 1.0.0
1455
+ `;
1456
+ const result = validateManifest(yaml);
1457
+ expect(result.success).toBe(true);
1458
+ if (result.success) {
1459
+ expect(result.data.base_module_aspect).toBeUndefined();
1460
+ }
1461
+ });
1462
+
1463
+ test('rejects unknown fields inside the block (strict)', () => {
1464
+ const yaml = `
1465
+ ${CONTRACT_LINE}
1466
+ id: knot-unbound-internal
1467
+ name: Internal DNS
1468
+ version: 1.0.0
1469
+
1470
+ base_module_aspect:
1471
+ ansible_role: dns-client-config
1472
+ applicable_zones: [app]
1473
+ triggers: [on_install]
1474
+ unknown_extra_field: foo
1475
+ `;
1476
+ const result = validateManifest(yaml);
1477
+ expect(result.success).toBe(false);
1478
+ });
1479
+ });