@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.
- package/drizzle/0005_module_operations.sql +12 -0
- package/drizzle/0006_base_module_aspects.sql +15 -0
- package/drizzle/0007_module_systems.sql +17 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +3 -3
- package/schemas/system_config.json +14 -28
- package/src/ansible/inventory.test.ts +46 -62
- package/src/ansible/inventory.ts +48 -25
- package/src/capabilities/registration.ts +25 -7
- package/src/capabilities/validation.test.ts +30 -0
- package/src/capabilities/validation.ts +8 -0
- package/src/cli/backup-rename.test.ts +95 -0
- package/src/cli/cli.test.ts +17 -23
- package/src/cli/command-registry.ts +199 -0
- package/src/cli/commands/backup-list.ts +1 -1
- package/src/cli/commands/events.ts +96 -0
- package/src/cli/commands/machine-add.ts +103 -59
- package/src/cli/commands/module-import.ts +153 -4
- package/src/cli/commands/module-remove.ts +86 -17
- package/src/cli/commands/module-status.ts +6 -2
- package/src/cli/commands/publish/alpha.test.ts +185 -0
- package/src/cli/commands/publish/alpha.ts +226 -0
- package/src/cli/commands/publish/changesets.test.ts +89 -0
- package/src/cli/commands/publish/changesets.ts +144 -0
- package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
- package/src/cli/commands/publish/consumer-pins.ts +149 -0
- package/src/cli/commands/publish/execute.ts +131 -0
- package/src/cli/commands/publish/global-install.test.ts +154 -0
- package/src/cli/commands/publish/global-install.ts +171 -0
- package/src/cli/commands/publish/helpers.ts +227 -0
- package/src/cli/commands/publish/index.ts +365 -0
- package/src/cli/commands/publish/module-registry.test.ts +40 -0
- package/src/cli/commands/publish/module-registry.ts +64 -0
- package/src/cli/commands/publish/plan.ts +107 -0
- package/src/cli/commands/publish/preflight.ts +238 -0
- package/src/cli/commands/publish/types.ts +264 -0
- package/src/cli/commands/publish/workspace.test.ts +323 -0
- package/src/cli/commands/publish/workspace.ts +596 -0
- package/src/cli/commands/restore.ts +126 -0
- package/src/cli/commands/storage-add-local.ts +1 -1
- package/src/cli/commands/storage-add-s3.ts +1 -1
- package/src/cli/commands/subscribers-add.ts +68 -0
- package/src/cli/commands/subscribers-list.ts +48 -0
- package/src/cli/commands/subscribers-remove.ts +38 -0
- package/src/cli/commands/subscribers-serve.ts +77 -0
- package/src/cli/commands/subscribers-status.ts +33 -0
- package/src/cli/commands/subscribers-test.ts +71 -0
- package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
- package/src/cli/commands/system-apply-config.test.ts +70 -0
- package/src/cli/commands/system-apply-config.ts +130 -0
- package/src/cli/commands/system-audit.ts +2 -1
- package/src/cli/commands/system-init-deprecation.test.ts +90 -0
- package/src/cli/commands/system-init.ts +36 -70
- package/src/cli/commands/system-update.ts +3 -2
- package/src/cli/completion.ts +22 -1
- package/src/cli/index.ts +214 -6
- package/src/cli/interactive-config.test.ts +19 -0
- package/src/cli/restore-command.test.ts +131 -0
- package/src/db/client.ts +42 -0
- package/src/db/schema.test.ts +13 -16
- package/src/db/schema.ts +161 -9
- package/src/hooks/capability-loader-firewall.test.ts +6 -15
- package/src/hooks/capability-loader.test.ts +2 -3
- package/src/hooks/capability-loader.ts +36 -2
- package/src/hooks/define-hook.test.ts +4 -0
- package/src/hooks/executor.test.ts +18 -0
- package/src/hooks/executor.ts +21 -2
- package/src/hooks/load-hook-config.test.ts +26 -24
- package/src/hooks/load-hook-config.ts +11 -2
- package/src/hooks/run-named-hook.ts +16 -0
- package/src/hooks/types.ts +9 -1
- package/src/manifest/contracts/v1.ts +70 -0
- package/src/manifest/schema.ts +262 -16
- package/src/manifest/validate-privileged.test.ts +84 -0
- package/src/manifest/validate.test.ts +156 -0
- package/src/manifest/validate.ts +69 -0
- package/src/module/import.ts +12 -0
- package/src/services/aspect-approvals.test.ts +231 -0
- package/src/services/aspect-approvals.ts +120 -0
- package/src/services/aspect-runner.test.ts +493 -0
- package/src/services/aspect-runner.ts +438 -0
- package/src/services/aspect-template-resolver.test.ts +101 -0
- package/src/services/aspect-template-resolver.ts +122 -0
- package/src/services/backup-create.ts +104 -25
- package/src/services/backup-envelope-roundtrip.test.ts +199 -0
- package/src/services/backup-in-flight-refusal.test.ts +163 -0
- package/src/services/backup-manifest.test.ts +115 -0
- package/src/services/backup-manifest.ts +163 -0
- package/src/services/backup-restore.ts +154 -19
- package/src/services/build-bus/delivery-events.ts +92 -0
- package/src/services/build-bus/event-factory.ts +54 -0
- package/src/services/build-bus/fan-out.test.ts +279 -0
- package/src/services/build-bus/fan-out.ts +161 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
- package/src/services/build-bus/hook-dispatch.test.ts +207 -0
- package/src/services/build-bus/hook-dispatch.ts +198 -0
- package/src/services/build-bus/hook-dispatcher.ts +115 -0
- package/src/services/build-bus/index.ts +41 -0
- package/src/services/build-bus/receiver-server.test.ts +179 -0
- package/src/services/build-bus/receiver-server.ts +159 -0
- package/src/services/build-bus/status.test.ts +212 -0
- package/src/services/build-bus/status.ts +213 -0
- package/src/services/build-bus/subscriber-store.ts +113 -0
- package/src/services/celilo-events.test.ts +70 -0
- package/src/services/celilo-events.ts +92 -0
- package/src/services/celilo-mgmt-hooks.test.ts +296 -0
- package/src/services/config-interview.ts +13 -95
- package/src/services/cross-module-data-manager.ts +2 -31
- package/src/services/cross-module-read.test.ts +250 -0
- package/src/services/cross-module-read.ts +232 -0
- package/src/services/deploy-validation.ts +7 -0
- package/src/services/deployed-systems.test.ts +235 -0
- package/src/services/deployed-systems.ts +308 -0
- package/src/services/dns-provider-backfill.ts +75 -0
- package/src/services/health-runner.ts +19 -3
- package/src/services/infrastructure-variable-resolver.test.ts +6 -32
- package/src/services/infrastructure-variable-resolver.ts +3 -13
- package/src/services/machine-detector.ts +104 -48
- package/src/services/machine-pool.ts +145 -2
- package/src/services/module-config.ts +78 -120
- package/src/services/module-deploy.ts +113 -40
- package/src/services/module-operations.test.ts +154 -0
- package/src/services/module-operations.ts +154 -0
- package/src/services/module-subscriptions.test.ts +58 -0
- package/src/services/module-subscriptions.ts +24 -1
- package/src/services/module-types-generator.test.ts +3 -3
- package/src/services/module-types-generator.ts +7 -2
- package/src/services/proxmox-reconcile.test.ts +333 -0
- package/src/services/proxmox-reconcile.ts +156 -0
- package/src/services/proxmox-state-recovery.ts +3 -24
- package/src/services/restore-from-file.test.ts +177 -0
- package/src/services/restore-from-file.ts +355 -0
- package/src/services/restore-preflight.test.ts +127 -0
- package/src/services/restore-preflight.ts +118 -0
- package/src/services/storage-providers/s3.ts +10 -2
- package/src/services/system-identity.ts +30 -0
- package/src/services/system-init.test.ts +64 -21
- package/src/services/system-init.ts +28 -26
- package/src/templates/generator.test.ts +7 -16
- package/src/templates/generator.ts +28 -115
- package/src/test-utils/integration.ts +5 -2
- package/src/types/infrastructure.ts +8 -0
- package/src/variables/computed/computed-integration.test.ts +191 -0
- package/src/variables/computed/computed.test.ts +177 -0
- package/src/variables/computed/evaluate.ts +271 -0
- package/src/variables/computed/marker.ts +53 -0
- package/src/variables/computed/parse.ts +262 -0
- package/src/variables/computed/provider-lookup.ts +130 -0
- package/src/variables/context.test.ts +89 -28
- package/src/variables/context.ts +196 -191
- package/src/variables/parser.ts +3 -3
- package/src/variables/resolver.test.ts +61 -0
- package/src/variables/resolver.ts +81 -0
- package/src/variables/types.ts +23 -1
- package/src/services/dns-auto-register.ts +0 -211
package/src/hooks/types.ts
CHANGED
|
@@ -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
|
/**
|
package/src/manifest/schema.ts
CHANGED
|
@@ -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
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
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
|
+
});
|