@celilo/cli 0.1.4 → 0.1.6
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/0004_caddy_hostname_list.sql +25 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +9 -2
- package/src/ansible/inventory.test.ts +9 -8
- package/src/ansible/inventory.ts +9 -7
- package/src/capabilities/public-web-helpers.test.ts +2 -2
- package/src/capabilities/public-web-publish.test.ts +45 -12
- package/src/capabilities/registration.test.ts +6 -6
- package/src/capabilities/well-known.test.ts +2 -2
- package/src/capabilities/well-known.ts +5 -5
- package/src/cli/cli.test.ts +2 -2
- package/src/cli/command-registry.ts +146 -3
- package/src/cli/command-tree-parser.test.ts +1 -1
- package/src/cli/command-tree-parser.ts +9 -8
- package/src/cli/commands/hook-run.ts +15 -66
- package/src/cli/commands/module-audit.ts +14 -44
- package/src/cli/commands/module-deploy.ts +4 -1
- package/src/cli/commands/module-import-registry.test.ts +115 -0
- package/src/cli/commands/module-import.ts +106 -22
- package/src/cli/commands/module-publish.test.ts +235 -0
- package/src/cli/commands/module-publish.ts +234 -0
- package/src/cli/commands/module-remove.ts +82 -2
- package/src/cli/commands/module-search.ts +57 -0
- package/src/cli/commands/module-secret-get.ts +59 -0
- package/src/cli/commands/module-show.ts +1 -1
- package/src/cli/commands/module-terraform-unlock.ts +57 -0
- package/src/cli/commands/module-verify.test.ts +59 -0
- package/src/cli/commands/module-verify.ts +53 -0
- package/src/cli/commands/status.ts +30 -20
- package/src/cli/commands/system-audit.test.ts +138 -0
- package/src/cli/commands/system-audit.ts +571 -0
- package/src/cli/commands/system-update.ts +391 -0
- package/src/cli/completion.ts +15 -1
- package/src/cli/fuel-gauge.ts +68 -3
- package/src/cli/generate-zsh-completion.ts +13 -3
- package/src/cli/index.ts +112 -5
- package/src/cli/parser.ts +11 -0
- package/src/cli/prompts.ts +36 -5
- package/src/cli/tui/audit-state.test.ts +246 -0
- package/src/cli/tui/audit-state.ts +525 -0
- package/src/cli/tui/audit-tui.test.tsx +135 -0
- package/src/cli/tui/audit-tui.tsx +624 -0
- package/src/cli/tui/celebration.tsx +29 -0
- package/src/cli/tui/clipboard.test.ts +94 -0
- package/src/cli/tui/clipboard.ts +101 -0
- package/src/cli/tui/icons.ts +22 -0
- package/src/cli/tui/keybar.tsx +65 -0
- package/src/cli/tui/keymap.test.ts +105 -0
- package/src/cli/tui/keymap.ts +70 -0
- package/src/cli/tui/modals/analyzing.tsx +75 -0
- package/src/cli/tui/modals/celebration.tsx +44 -0
- package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
- package/src/cli/tui/modals/remediate.tsx +44 -0
- package/src/cli/tui/modals.test.ts +137 -0
- package/src/cli/tui/mouse.test.ts +78 -0
- package/src/cli/tui/mouse.ts +114 -0
- package/src/cli/tui/panes/categories.tsx +62 -0
- package/src/cli/tui/panes/command-log.tsx +87 -0
- package/src/cli/tui/panes/detail.tsx +175 -0
- package/src/cli/tui/panes/findings.tsx +97 -0
- package/src/cli/tui/panes/summary.tsx +64 -0
- package/src/cli/tui/spawn.ts +130 -0
- package/src/cli/tui/theme.ts +42 -0
- package/src/cli/tui/wrap.test.ts +43 -0
- package/src/cli/tui/wrap.ts +45 -0
- package/src/cli/types.ts +5 -0
- package/src/db/client.ts +55 -2
- package/src/db/schema.test.ts +3 -3
- package/src/db/schema.ts +26 -17
- package/src/hooks/capability-loader.ts +135 -72
- package/src/hooks/define-hook.test.ts +11 -3
- package/src/hooks/executor.ts +22 -1
- package/src/hooks/load-hook-config.test.ts +165 -0
- package/src/hooks/load-hook-config.ts +60 -0
- package/src/hooks/logger.ts +42 -12
- package/src/hooks/run-named-hook.ts +128 -0
- package/src/hooks/types.ts +19 -0
- package/src/manifest/ensure-schema.test.ts +115 -0
- package/src/manifest/schema.ts +76 -0
- package/src/manifest/template-validator.test.ts +1 -1
- package/src/manifest/template-validator.ts +1 -1
- package/src/manifest/validate.test.ts +1 -1
- package/src/module/import.ts +20 -12
- package/src/module/packaging/build.ts +121 -25
- package/src/module/packaging/release-metadata.test.ts +103 -0
- package/src/module/packaging/release-metadata.ts +145 -0
- package/src/registry/client.test.ts +228 -0
- package/src/registry/client.ts +157 -0
- package/src/services/audit/backups.test.ts +233 -0
- package/src/services/audit/backups.ts +128 -0
- package/src/services/audit/capability-abi.test.ts +153 -0
- package/src/services/audit/capability-abi.ts +204 -0
- package/src/services/audit/cli-version.test.ts +60 -0
- package/src/services/audit/cli-version.ts +87 -0
- package/src/services/audit/health.test.ts +84 -0
- package/src/services/audit/health.ts +43 -0
- package/src/services/audit/index.test.ts +99 -0
- package/src/services/audit/index.ts +118 -0
- package/src/services/audit/machines-reachable.test.ts +87 -0
- package/src/services/audit/machines-reachable.ts +87 -0
- package/src/services/audit/module-configs.test.ts +131 -0
- package/src/services/audit/module-configs.ts +80 -0
- package/src/services/audit/module-versions.test.ts +99 -0
- package/src/services/audit/module-versions.ts +154 -0
- package/src/services/audit/schema.test.ts +68 -0
- package/src/services/audit/schema.ts +115 -0
- package/src/services/audit/secrets-decryptable.test.ts +82 -0
- package/src/services/audit/secrets-decryptable.ts +97 -0
- package/src/services/audit/services-credentials.test.ts +54 -0
- package/src/services/audit/services-credentials.ts +64 -0
- package/src/services/audit/services-reachable.test.ts +60 -0
- package/src/services/audit/services-reachable.ts +64 -0
- package/src/services/audit/terraform-plan.test.ts +127 -0
- package/src/services/audit/terraform-plan.ts +153 -0
- package/src/services/audit/types.test.ts +36 -0
- package/src/services/audit/types.ts +90 -0
- package/src/services/audit/unconfigured-modules.test.ts +48 -0
- package/src/services/audit/unconfigured-modules.ts +71 -0
- package/src/services/audit/undeployed-modules.test.ts +66 -0
- package/src/services/audit/undeployed-modules.ts +72 -0
- package/src/services/build-stream.ts +122 -122
- package/src/services/config-interview.ts +407 -2
- package/src/services/deploy-ansible.ts +73 -7
- package/src/services/deploy-planner.ts +5 -5
- package/src/services/deploy-preflight.ts +45 -4
- package/src/services/deploy-terraform.ts +31 -24
- package/src/services/deploy-validation.ts +167 -23
- package/src/services/dns-auto-register.ts +4 -4
- package/src/services/ensure-interview.test.ts +245 -0
- package/src/services/health-runner.ts +110 -38
- package/src/services/infrastructure-variable-resolver.test.ts +1 -1
- package/src/services/infrastructure-variable-resolver.ts +3 -3
- package/src/services/module-build.ts +11 -13
- package/src/services/module-deploy.ts +372 -61
- package/src/services/proxmox-state-recovery.ts +6 -6
- package/src/services/ssh-key-manager.test.ts +1 -1
- package/src/services/ssh-key-manager.ts +3 -2
- package/src/services/terraform-env.ts +62 -0
- package/src/services/update/dep-graph.test.ts +214 -0
- package/src/services/update/dep-graph.ts +215 -0
- package/src/services/update/orchestrator.test.ts +463 -0
- package/src/services/update/orchestrator.ts +359 -0
- package/src/services/update/progress.ts +49 -0
- package/src/services/update/self-update.test.ts +68 -0
- package/src/services/update/self-update.ts +57 -0
- package/src/services/update/types.ts +94 -0
- package/src/templates/generator.test.ts +3 -3
- package/src/templates/generator.ts +43 -2
- package/src/test-utils/completion-harness.test.ts +1 -1
- package/src/test-utils/completion-harness.ts +4 -4
- package/src/variables/capability-self-ref.test.ts +203 -0
- package/src/variables/context.test.ts +31 -31
- package/src/variables/context.ts +65 -17
- package/src/variables/declarative-derivation.test.ts +306 -0
- package/src/variables/declarative-derivation.ts +4 -2
- package/src/variables/parser.test.ts +64 -9
- package/src/variables/parser.ts +47 -6
- package/src/variables/resolver.test.ts +14 -14
- package/src/variables/resolver.ts +27 -9
- package/src/variables/types.ts +1 -1
- package/tsconfig.json +1 -0
package/src/variables/context.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { ModuleManifest } from '../manifest/schema';
|
|
|
15
15
|
import { decryptSecret } from '../secrets/encryption';
|
|
16
16
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
17
17
|
import { applyDeclarativeDerivations } from './declarative-derivation';
|
|
18
|
+
import { applyIndex, parsePath } from './parser';
|
|
18
19
|
import type { ResolutionContext } from './types';
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -92,7 +93,7 @@ async function autoAssignFromWellKnown(
|
|
|
92
93
|
* Policy function - checks manifest and config
|
|
93
94
|
*
|
|
94
95
|
* A module needs IPAM if:
|
|
95
|
-
* 1. It declares vmid and
|
|
96
|
+
* 1. It declares vmid and target_ip variables (container-based), AND
|
|
96
97
|
* 2. These values are not already set in module config
|
|
97
98
|
*
|
|
98
99
|
* @param manifest - Module manifest
|
|
@@ -106,16 +107,16 @@ function needsIpamAllocation(
|
|
|
106
107
|
const variables = manifest.variables?.owns ?? [];
|
|
107
108
|
|
|
108
109
|
const hasVmid = variables.some((v) => v.name === 'vmid');
|
|
109
|
-
const
|
|
110
|
+
const hasTargetIp = variables.some((v) => v.name === 'target_ip');
|
|
110
111
|
|
|
111
|
-
// Module must declare both vmid and
|
|
112
|
-
if (!hasVmid || !
|
|
112
|
+
// Module must declare both vmid and target_ip to be container-based
|
|
113
|
+
if (!hasVmid || !hasTargetIp) {
|
|
113
114
|
return false;
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
// Check if already allocated (both must be present)
|
|
117
118
|
const vmidSet = selfConfig.vmid !== undefined && selfConfig.vmid !== '';
|
|
118
|
-
const ipSet = selfConfig.
|
|
119
|
+
const ipSet = selfConfig.target_ip !== undefined && selfConfig.target_ip !== '';
|
|
119
120
|
|
|
120
121
|
// Need allocation if either is missing
|
|
121
122
|
return !vmidSet || !ipSet;
|
|
@@ -162,7 +163,7 @@ function determineModuleZone(
|
|
|
162
163
|
*
|
|
163
164
|
* These variables are automatically available in Ansible templates:
|
|
164
165
|
* - inventory.hostname: Derived from hostname variable
|
|
165
|
-
* - inventory.ansible_host: Derived from
|
|
166
|
+
* - inventory.ansible_host: Derived from target_ip (strips CIDR) or vps_ip
|
|
166
167
|
* - inventory.ansible_user: Defaults to "root"
|
|
167
168
|
* - inventory.groups: Derived from module ID
|
|
168
169
|
*
|
|
@@ -181,9 +182,9 @@ function autoDeriveInventoryVariables(
|
|
|
181
182
|
derived['inventory.hostname'] = selfConfig.hostname;
|
|
182
183
|
}
|
|
183
184
|
|
|
184
|
-
// Auto-derive ansible_host from
|
|
185
|
-
if (selfConfig.
|
|
186
|
-
derived['inventory.ansible_host'] = stripCidr(selfConfig.
|
|
185
|
+
// Auto-derive ansible_host from target_ip (strips CIDR) or vps_ip
|
|
186
|
+
if (selfConfig.target_ip) {
|
|
187
|
+
derived['inventory.ansible_host'] = stripCidr(selfConfig.target_ip);
|
|
187
188
|
} else if (selfConfig.vps_ip) {
|
|
188
189
|
// VPS-based modules use vps_ip directly (no CIDR to strip)
|
|
189
190
|
derived['inventory.ansible_host'] = selfConfig.vps_ip;
|
|
@@ -202,6 +203,34 @@ function autoDeriveInventoryVariables(
|
|
|
202
203
|
return derived;
|
|
203
204
|
}
|
|
204
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Recursively resolve "$self:key" strings in a capability data object
|
|
208
|
+
* using the provider module's actual config values. Supports the
|
|
209
|
+
* optional `[N]` array-index suffix (e.g. `$self:domains[0]`) so a
|
|
210
|
+
* provider can declare computed aliases — `primary_domain:
|
|
211
|
+
* $self:domains[0]` — without the framework storing the alias as a
|
|
212
|
+
* separate value. Non-string values and strings that don't start
|
|
213
|
+
* with "$self:" are returned unchanged.
|
|
214
|
+
*/
|
|
215
|
+
function resolveSelfRefsInObject(
|
|
216
|
+
obj: Record<string, unknown>,
|
|
217
|
+
providerConfig: Record<string, unknown>,
|
|
218
|
+
): Record<string, unknown> {
|
|
219
|
+
const result: Record<string, unknown> = {};
|
|
220
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
221
|
+
if (typeof value === 'string' && value.startsWith('$self:')) {
|
|
222
|
+
const { name, index } = parsePath(value.slice(6));
|
|
223
|
+
const resolved = applyIndex(providerConfig[name], index);
|
|
224
|
+
result[key] = resolved !== undefined ? resolved : value;
|
|
225
|
+
} else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
226
|
+
result[key] = resolveSelfRefsInObject(value as Record<string, unknown>, providerConfig);
|
|
227
|
+
} else {
|
|
228
|
+
result[key] = value;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
|
|
205
234
|
/**
|
|
206
235
|
* Build resolution context for a module
|
|
207
236
|
*
|
|
@@ -329,7 +358,7 @@ export async function buildResolutionContext(
|
|
|
329
358
|
}
|
|
330
359
|
|
|
331
360
|
// IPAM auto-allocation
|
|
332
|
-
// If module declares vmid/
|
|
361
|
+
// If module declares vmid/target_ip but they're not configured, allocate automatically
|
|
333
362
|
if (module?.manifestData) {
|
|
334
363
|
const manifest = module.manifestData as ModuleManifest;
|
|
335
364
|
|
|
@@ -342,17 +371,17 @@ export async function buildResolutionContext(
|
|
|
342
371
|
const existing = await getAllocation(moduleId, tx);
|
|
343
372
|
|
|
344
373
|
let vmid: number;
|
|
345
|
-
let
|
|
374
|
+
let allocatedIp: string;
|
|
346
375
|
|
|
347
376
|
if (existing) {
|
|
348
377
|
// Use existing allocation
|
|
349
378
|
vmid = existing.vmid;
|
|
350
|
-
|
|
379
|
+
allocatedIp = existing.containerIp;
|
|
351
380
|
} else {
|
|
352
381
|
// Allocate new resources (persists to ipAllocations)
|
|
353
382
|
const allocation = await allocateResources(moduleId, zone, tx);
|
|
354
383
|
vmid = allocation.vmid;
|
|
355
|
-
|
|
384
|
+
allocatedIp = allocation.containerIp;
|
|
356
385
|
}
|
|
357
386
|
|
|
358
387
|
// Ensure values are in module config (upsert to handle existing keys)
|
|
@@ -366,15 +395,15 @@ export async function buildResolutionContext(
|
|
|
366
395
|
|
|
367
396
|
await tx
|
|
368
397
|
.insert(moduleConfigs)
|
|
369
|
-
.values({ moduleId, key: '
|
|
398
|
+
.values({ moduleId, key: 'target_ip', value: allocatedIp })
|
|
370
399
|
.onConflictDoUpdate({
|
|
371
400
|
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
372
|
-
set: { value:
|
|
401
|
+
set: { value: allocatedIp },
|
|
373
402
|
});
|
|
374
403
|
|
|
375
404
|
// Update selfConfig with allocated values
|
|
376
405
|
selfConfig.vmid = String(vmid);
|
|
377
|
-
selfConfig.
|
|
406
|
+
selfConfig.target_ip = allocatedIp;
|
|
378
407
|
});
|
|
379
408
|
}
|
|
380
409
|
}
|
|
@@ -418,7 +447,26 @@ export async function buildResolutionContext(
|
|
|
418
447
|
} else {
|
|
419
448
|
data = row.data;
|
|
420
449
|
}
|
|
421
|
-
|
|
450
|
+
|
|
451
|
+
// Lazily resolve $self: references in capability data using the provider
|
|
452
|
+
// module's actual config values. Capability data is stored with unresolved
|
|
453
|
+
// $self: references (e.g. namecheap stores primary_domain: "$self:primary_domain")
|
|
454
|
+
// because the real value isn't known at import time. We resolve them here so
|
|
455
|
+
// consuming modules see the actual values (e.g. "iamtheinternet.org") rather
|
|
456
|
+
// than the raw template strings.
|
|
457
|
+
const providerConfigs = db
|
|
458
|
+
.select()
|
|
459
|
+
.from(moduleConfigs)
|
|
460
|
+
.where(eq(moduleConfigs.moduleId, row.moduleId))
|
|
461
|
+
.all();
|
|
462
|
+
const providerConfigMap: Record<string, unknown> = {};
|
|
463
|
+
for (const c of providerConfigs) {
|
|
464
|
+
providerConfigMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
|
|
465
|
+
}
|
|
466
|
+
capabilitiesMap[row.capabilityName] = resolveSelfRefsInObject(
|
|
467
|
+
data as Record<string, unknown>,
|
|
468
|
+
providerConfigMap,
|
|
469
|
+
);
|
|
422
470
|
}
|
|
423
471
|
|
|
424
472
|
// Fetch system configuration (for $system: variables)
|
|
@@ -676,6 +676,312 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
676
676
|
});
|
|
677
677
|
});
|
|
678
678
|
|
|
679
|
+
describe('capability data with unresolved $self: refs', () => {
|
|
680
|
+
// This is the real-world scenario: namecheap's dns_registrar capability is stored
|
|
681
|
+
// in the DB with raw {primary_domain: "$self:primary_domain"} by buildCapabilityData.
|
|
682
|
+
// When lunacycle's primary_domain derives from $capability:dns_registrar.primary_domain,
|
|
683
|
+
// it receives the string "$self:primary_domain" — which can't be resolved in lunacycle's
|
|
684
|
+
// context. The key must remain unset rather than being poisoned with the raw template.
|
|
685
|
+
test('does not set selfConfig when capability value still contains $self: ref', () => {
|
|
686
|
+
const manifest: ModuleManifest = {
|
|
687
|
+
celilo_contract: '1.0',
|
|
688
|
+
id: 'lunacycle',
|
|
689
|
+
name: 'Lunacycle',
|
|
690
|
+
version: '1.0.0',
|
|
691
|
+
requires: { capabilities: [] },
|
|
692
|
+
provides: { capabilities: [] },
|
|
693
|
+
variables: {
|
|
694
|
+
owns: [
|
|
695
|
+
{
|
|
696
|
+
name: 'primary_domain',
|
|
697
|
+
type: 'string',
|
|
698
|
+
required: false,
|
|
699
|
+
source: 'capability',
|
|
700
|
+
derive_from: '$capability:dns_registrar.primary_domain',
|
|
701
|
+
},
|
|
702
|
+
],
|
|
703
|
+
imports: [],
|
|
704
|
+
},
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
const context: ResolutionContext = {
|
|
708
|
+
moduleId: 'lunacycle',
|
|
709
|
+
selfConfig: {}, // primary_domain not set yet
|
|
710
|
+
systemConfig: {},
|
|
711
|
+
systemSecrets: {},
|
|
712
|
+
secrets: {},
|
|
713
|
+
capabilities: {
|
|
714
|
+
// Raw capability data as stored by buildCapabilityData — $self: unresolved
|
|
715
|
+
dns_registrar: { primary_domain: '$self:primary_domain' },
|
|
716
|
+
},
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
applyDeclarativeDerivations(manifest, context);
|
|
720
|
+
|
|
721
|
+
// Must NOT be set to '$self:primary_domain' — that poisons downstream code
|
|
722
|
+
expect(context.selfConfig.primary_domain).toBeUndefined();
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test('uses existing selfConfig value when capability returns unresolved $self: ref', () => {
|
|
726
|
+
const manifest: ModuleManifest = {
|
|
727
|
+
celilo_contract: '1.0',
|
|
728
|
+
id: 'lunacycle',
|
|
729
|
+
name: 'Lunacycle',
|
|
730
|
+
version: '1.0.0',
|
|
731
|
+
requires: { capabilities: [] },
|
|
732
|
+
provides: { capabilities: [] },
|
|
733
|
+
variables: {
|
|
734
|
+
owns: [
|
|
735
|
+
{
|
|
736
|
+
name: 'primary_domain',
|
|
737
|
+
type: 'string',
|
|
738
|
+
required: false,
|
|
739
|
+
source: 'capability',
|
|
740
|
+
derive_from: '$capability:dns_registrar.primary_domain',
|
|
741
|
+
},
|
|
742
|
+
],
|
|
743
|
+
imports: [],
|
|
744
|
+
},
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
const context: ResolutionContext = {
|
|
748
|
+
moduleId: 'lunacycle',
|
|
749
|
+
selfConfig: { primary_domain: 'iamtheinternet.org' }, // Previously resolved value
|
|
750
|
+
systemConfig: {},
|
|
751
|
+
systemSecrets: {},
|
|
752
|
+
secrets: {},
|
|
753
|
+
capabilities: {
|
|
754
|
+
dns_registrar: { primary_domain: '$self:primary_domain' }, // Raw, unresolved
|
|
755
|
+
},
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
applyDeclarativeDerivations(manifest, context);
|
|
759
|
+
|
|
760
|
+
// Should keep the existing resolved value
|
|
761
|
+
expect(context.selfConfig.primary_domain).toBe('iamtheinternet.org');
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
describe('capability data with unresolved $secret: refs', () => {
|
|
766
|
+
// $secret: is not handled by substituteVariables (only $system:, {var}, $capability:
|
|
767
|
+
// are resolved). So if a capability stores "$secret:api_key", it passes through
|
|
768
|
+
// all 5 resolution passes unchanged and must be skipped.
|
|
769
|
+
test('does not set selfConfig when capability value contains $secret: ref', () => {
|
|
770
|
+
const manifest: ModuleManifest = {
|
|
771
|
+
celilo_contract: '1.0',
|
|
772
|
+
id: 'consumer',
|
|
773
|
+
name: 'Consumer',
|
|
774
|
+
version: '1.0.0',
|
|
775
|
+
requires: { capabilities: [] },
|
|
776
|
+
provides: { capabilities: [] },
|
|
777
|
+
variables: {
|
|
778
|
+
owns: [
|
|
779
|
+
{
|
|
780
|
+
name: 'api_key',
|
|
781
|
+
type: 'string',
|
|
782
|
+
required: false,
|
|
783
|
+
source: 'capability',
|
|
784
|
+
derive_from: '$capability:provider.api_key',
|
|
785
|
+
},
|
|
786
|
+
],
|
|
787
|
+
imports: [],
|
|
788
|
+
},
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
const context: ResolutionContext = {
|
|
792
|
+
moduleId: 'consumer',
|
|
793
|
+
selfConfig: {},
|
|
794
|
+
systemConfig: {},
|
|
795
|
+
systemSecrets: {},
|
|
796
|
+
secrets: {},
|
|
797
|
+
capabilities: {
|
|
798
|
+
provider: { api_key: '$secret:provider_api_key' },
|
|
799
|
+
},
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
applyDeclarativeDerivations(manifest, context);
|
|
803
|
+
|
|
804
|
+
expect(context.selfConfig.api_key).toBeUndefined();
|
|
805
|
+
});
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
describe('two-level resolution via capability', () => {
|
|
809
|
+
// Capability data contains $system: refs — the multi-pass loop in
|
|
810
|
+
// resolveDeclarativeDerivation resolves them. This tests applyDeclarativeDerivations
|
|
811
|
+
// end-to-end, not just resolveDeclarativeDerivation in isolation.
|
|
812
|
+
test('resolves $system: ref inside capability value', () => {
|
|
813
|
+
const manifest: ModuleManifest = {
|
|
814
|
+
celilo_contract: '1.0',
|
|
815
|
+
id: 'caddy',
|
|
816
|
+
name: 'Caddy',
|
|
817
|
+
version: '1.0.0',
|
|
818
|
+
requires: { capabilities: [] },
|
|
819
|
+
provides: { capabilities: [] },
|
|
820
|
+
variables: {
|
|
821
|
+
owns: [
|
|
822
|
+
{
|
|
823
|
+
name: 'auth_url',
|
|
824
|
+
type: 'string',
|
|
825
|
+
required: false,
|
|
826
|
+
source: 'capability',
|
|
827
|
+
derive_from: '$capability:idp.auth_url',
|
|
828
|
+
},
|
|
829
|
+
],
|
|
830
|
+
imports: [],
|
|
831
|
+
},
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
const context: ResolutionContext = {
|
|
835
|
+
moduleId: 'caddy',
|
|
836
|
+
selfConfig: {},
|
|
837
|
+
systemConfig: { primary_domain: 'example.com' },
|
|
838
|
+
systemSecrets: {},
|
|
839
|
+
secrets: {},
|
|
840
|
+
capabilities: {
|
|
841
|
+
// Capability data uses $system: — this CAN be resolved in the consumer's context
|
|
842
|
+
idp: { auth_url: 'https://auth.$system:primary_domain' },
|
|
843
|
+
},
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
applyDeclarativeDerivations(manifest, context);
|
|
847
|
+
|
|
848
|
+
expect(context.selfConfig.auth_url).toBe('https://auth.example.com');
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
describe('circular variable references', () => {
|
|
853
|
+
// Both optional variables reference each other — each throws "Missing variable"
|
|
854
|
+
// on first pass (the other isn't set yet). Both should remain unset.
|
|
855
|
+
test('silently skips both optional variables in a circular reference', () => {
|
|
856
|
+
const manifest: ModuleManifest = {
|
|
857
|
+
celilo_contract: '1.0',
|
|
858
|
+
id: 'test-module',
|
|
859
|
+
name: 'Test Module',
|
|
860
|
+
version: '1.0.0',
|
|
861
|
+
requires: { capabilities: [] },
|
|
862
|
+
provides: { capabilities: [] },
|
|
863
|
+
variables: {
|
|
864
|
+
owns: [
|
|
865
|
+
{
|
|
866
|
+
name: 'foo',
|
|
867
|
+
type: 'string',
|
|
868
|
+
required: false,
|
|
869
|
+
source: 'user',
|
|
870
|
+
derive_from: '{bar}',
|
|
871
|
+
},
|
|
872
|
+
{
|
|
873
|
+
name: 'bar',
|
|
874
|
+
type: 'string',
|
|
875
|
+
required: false,
|
|
876
|
+
source: 'user',
|
|
877
|
+
derive_from: '{foo}',
|
|
878
|
+
},
|
|
879
|
+
],
|
|
880
|
+
imports: [],
|
|
881
|
+
},
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
const context: ResolutionContext = {
|
|
885
|
+
moduleId: 'test-module',
|
|
886
|
+
selfConfig: {},
|
|
887
|
+
systemConfig: {},
|
|
888
|
+
systemSecrets: {},
|
|
889
|
+
secrets: {},
|
|
890
|
+
capabilities: {},
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
applyDeclarativeDerivations(manifest, context);
|
|
894
|
+
|
|
895
|
+
expect(context.selfConfig.foo).toBeUndefined();
|
|
896
|
+
expect(context.selfConfig.bar).toBeUndefined();
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test('throws when either circular variable is required', () => {
|
|
900
|
+
const manifest: ModuleManifest = {
|
|
901
|
+
celilo_contract: '1.0',
|
|
902
|
+
id: 'test-module',
|
|
903
|
+
name: 'Test Module',
|
|
904
|
+
version: '1.0.0',
|
|
905
|
+
requires: { capabilities: [] },
|
|
906
|
+
provides: { capabilities: [] },
|
|
907
|
+
variables: {
|
|
908
|
+
owns: [
|
|
909
|
+
{
|
|
910
|
+
name: 'foo',
|
|
911
|
+
type: 'string',
|
|
912
|
+
required: true, // Required — must throw
|
|
913
|
+
source: 'user',
|
|
914
|
+
derive_from: '{bar}',
|
|
915
|
+
},
|
|
916
|
+
{
|
|
917
|
+
name: 'bar',
|
|
918
|
+
type: 'string',
|
|
919
|
+
required: false,
|
|
920
|
+
source: 'user',
|
|
921
|
+
derive_from: '{foo}',
|
|
922
|
+
},
|
|
923
|
+
],
|
|
924
|
+
imports: [],
|
|
925
|
+
},
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
const context: ResolutionContext = {
|
|
929
|
+
moduleId: 'test-module',
|
|
930
|
+
selfConfig: {},
|
|
931
|
+
systemConfig: {},
|
|
932
|
+
systemSecrets: {},
|
|
933
|
+
secrets: {},
|
|
934
|
+
capabilities: {},
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
expect(() => applyDeclarativeDerivations(manifest, context)).toThrow(
|
|
938
|
+
"Missing variable: bar (required by variable 'foo')",
|
|
939
|
+
);
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
describe('$self: directly in derive_from', () => {
|
|
944
|
+
// $self: in derive_from is a manifest authoring error — substituteVariables
|
|
945
|
+
// does not handle $self:, so it passes through all resolution passes unchanged.
|
|
946
|
+
// The hasUnresolved check must prevent it from poisoning selfConfig.
|
|
947
|
+
test('does not set selfConfig when derive_from contains $self: directly', () => {
|
|
948
|
+
const manifest: ModuleManifest = {
|
|
949
|
+
celilo_contract: '1.0',
|
|
950
|
+
id: 'test-module',
|
|
951
|
+
name: 'Test Module',
|
|
952
|
+
version: '1.0.0',
|
|
953
|
+
requires: { capabilities: [] },
|
|
954
|
+
provides: { capabilities: [] },
|
|
955
|
+
variables: {
|
|
956
|
+
owns: [
|
|
957
|
+
{
|
|
958
|
+
name: 'hostname_copy',
|
|
959
|
+
type: 'string',
|
|
960
|
+
required: false,
|
|
961
|
+
source: 'user',
|
|
962
|
+
derive_from: '$self:hostname', // Wrong syntax — $self: is not supported in derive_from
|
|
963
|
+
},
|
|
964
|
+
],
|
|
965
|
+
imports: [],
|
|
966
|
+
},
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
const context: ResolutionContext = {
|
|
970
|
+
moduleId: 'test-module',
|
|
971
|
+
selfConfig: { hostname: 'my-host' },
|
|
972
|
+
systemConfig: {},
|
|
973
|
+
systemSecrets: {},
|
|
974
|
+
secrets: {},
|
|
975
|
+
capabilities: {},
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
applyDeclarativeDerivations(manifest, context);
|
|
979
|
+
|
|
980
|
+
// $self: passes through unresolved; must not poison selfConfig
|
|
981
|
+
expect(context.selfConfig.hostname_copy).toBeUndefined();
|
|
982
|
+
});
|
|
983
|
+
});
|
|
984
|
+
|
|
679
985
|
describe('skips', () => {
|
|
680
986
|
test('skips variables without derive_from', () => {
|
|
681
987
|
const manifest: ModuleManifest = {
|
|
@@ -182,8 +182,10 @@ export function applyDeclarativeDerivations(
|
|
|
182
182
|
derived.includes('$system:') ||
|
|
183
183
|
derived.includes('$capability:') ||
|
|
184
184
|
derived.includes('$secret:');
|
|
185
|
-
if (hasUnresolved
|
|
186
|
-
//
|
|
185
|
+
if (hasUnresolved) {
|
|
186
|
+
// Don't store a value that still contains unresolved template refs.
|
|
187
|
+
// This happens when a capability's data contains $self: refs that
|
|
188
|
+
// resolve in the provider's context, not the consumer's.
|
|
187
189
|
continue;
|
|
188
190
|
}
|
|
189
191
|
context.selfConfig[variable.name] = derived;
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
applyIndex,
|
|
4
|
+
hasVariables,
|
|
5
|
+
isValidVariableFormat,
|
|
6
|
+
parsePath,
|
|
7
|
+
parseVariables,
|
|
8
|
+
} from './parser';
|
|
3
9
|
|
|
4
10
|
describe('parseVariables', () => {
|
|
5
11
|
test('should parse self variables', () => {
|
|
6
|
-
const content = 'ip: $self:
|
|
12
|
+
const content = 'ip: $self:target_ip';
|
|
7
13
|
const variables = parseVariables(content);
|
|
8
14
|
|
|
9
15
|
expect(variables).toHaveLength(1);
|
|
10
16
|
expect(variables[0]).toEqual({
|
|
11
17
|
type: 'self',
|
|
12
|
-
path: '
|
|
13
|
-
raw: '$self:
|
|
18
|
+
path: 'target_ip',
|
|
19
|
+
raw: '$self:target_ip',
|
|
14
20
|
});
|
|
15
21
|
});
|
|
16
22
|
|
|
@@ -52,7 +58,7 @@ describe('parseVariables', () => {
|
|
|
52
58
|
|
|
53
59
|
test('should parse multiple variables in same content', () => {
|
|
54
60
|
const content = `
|
|
55
|
-
ip: $self:
|
|
61
|
+
ip: $self:target_ip
|
|
56
62
|
dns: $capability:dns_external.nameserver
|
|
57
63
|
key: $secret:api_key
|
|
58
64
|
`;
|
|
@@ -101,7 +107,7 @@ resource "proxmox_lxc" "homebridge" {
|
|
|
101
107
|
cores = $self:cores
|
|
102
108
|
memory = $self:memory
|
|
103
109
|
network {
|
|
104
|
-
ip = "$self:
|
|
110
|
+
ip = "$self:target_ip/24"
|
|
105
111
|
gw = "$system:management.ip"
|
|
106
112
|
}
|
|
107
113
|
}
|
|
@@ -189,7 +195,7 @@ resource "proxmox_lxc" "homebridge" {
|
|
|
189
195
|
|
|
190
196
|
describe('hasVariables', () => {
|
|
191
197
|
test('should return true for content with variables', () => {
|
|
192
|
-
expect(hasVariables('ip: $self:
|
|
198
|
+
expect(hasVariables('ip: $self:target_ip')).toBe(true);
|
|
193
199
|
expect(hasVariables('$secret:key')).toBe(true);
|
|
194
200
|
});
|
|
195
201
|
|
|
@@ -206,7 +212,7 @@ describe('hasVariables', () => {
|
|
|
206
212
|
|
|
207
213
|
describe('isValidVariableFormat', () => {
|
|
208
214
|
test('should validate correct variable formats', () => {
|
|
209
|
-
expect(isValidVariableFormat('$self:
|
|
215
|
+
expect(isValidVariableFormat('$self:target_ip')).toBe(true);
|
|
210
216
|
expect(isValidVariableFormat('$system:management.ip')).toBe(true);
|
|
211
217
|
expect(isValidVariableFormat('$secret:api_key')).toBe(true);
|
|
212
218
|
expect(isValidVariableFormat('$capability:dns_external.nameserver')).toBe(true);
|
|
@@ -219,7 +225,7 @@ describe('isValidVariableFormat', () => {
|
|
|
219
225
|
});
|
|
220
226
|
|
|
221
227
|
test('should reject invalid variable formats', () => {
|
|
222
|
-
expect(isValidVariableFormat('self:
|
|
228
|
+
expect(isValidVariableFormat('self:target_ip')).toBe(false); // missing $
|
|
223
229
|
expect(isValidVariableFormat('$unknown:value')).toBe(false); // invalid type
|
|
224
230
|
expect(isValidVariableFormat('$self:')).toBe(false); // missing path
|
|
225
231
|
expect(isValidVariableFormat('$self')).toBe(false); // missing colon and path
|
|
@@ -228,4 +234,53 @@ describe('isValidVariableFormat', () => {
|
|
|
228
234
|
expect(isValidVariableFormat('${self:test')).toBe(false); // missing closing brace
|
|
229
235
|
expect(isValidVariableFormat('$self:test}')).toBe(false); // mismatched brace
|
|
230
236
|
});
|
|
237
|
+
|
|
238
|
+
test('should accept [N] array-index suffix on $self:', () => {
|
|
239
|
+
expect(isValidVariableFormat('$self:domains[0]')).toBe(true);
|
|
240
|
+
expect(isValidVariableFormat('$self:domains[42]')).toBe(true);
|
|
241
|
+
expect(isValidVariableFormat('${self:domains[0]}')).toBe(true);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('should reject malformed [N] indexes', () => {
|
|
245
|
+
expect(isValidVariableFormat('$self:domains[]')).toBe(false);
|
|
246
|
+
expect(isValidVariableFormat('$self:domains[a]')).toBe(false);
|
|
247
|
+
expect(isValidVariableFormat('$self:domains[-1]')).toBe(false);
|
|
248
|
+
expect(isValidVariableFormat('$self:domains[0')).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('parsePath', () => {
|
|
253
|
+
test('returns name without index when no brackets', () => {
|
|
254
|
+
expect(parsePath('domains')).toEqual({ name: 'domains' });
|
|
255
|
+
expect(parsePath('a.b.c')).toEqual({ name: 'a.b.c' });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('separates trailing [N] into index field', () => {
|
|
259
|
+
expect(parsePath('domains[0]')).toEqual({ name: 'domains', index: 0 });
|
|
260
|
+
expect(parsePath('domains[7]')).toEqual({ name: 'domains', index: 7 });
|
|
261
|
+
expect(parsePath('a.b.c[2]')).toEqual({ name: 'a.b.c', index: 2 });
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('applyIndex', () => {
|
|
266
|
+
test('returns the value unchanged when index is undefined', () => {
|
|
267
|
+
expect(applyIndex('hello', undefined)).toBe('hello');
|
|
268
|
+
expect(applyIndex(['a', 'b'], undefined)).toEqual(['a', 'b']);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('drills into array at the given index', () => {
|
|
272
|
+
expect(applyIndex(['a', 'b', 'c'], 0)).toBe('a');
|
|
273
|
+
expect(applyIndex(['a', 'b', 'c'], 2)).toBe('c');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('returns undefined for out-of-bounds indexes', () => {
|
|
277
|
+
expect(applyIndex(['a'], 5)).toBeUndefined();
|
|
278
|
+
expect(applyIndex([], 0)).toBeUndefined();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('returns undefined when applying an index to a non-array', () => {
|
|
282
|
+
expect(applyIndex('not an array', 0)).toBeUndefined();
|
|
283
|
+
expect(applyIndex(42, 0)).toBeUndefined();
|
|
284
|
+
expect(applyIndex(null, 0)).toBeUndefined();
|
|
285
|
+
});
|
|
231
286
|
});
|