@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/variables/context.ts
CHANGED
|
@@ -4,16 +4,23 @@ import { getDb } from '../db/client';
|
|
|
4
4
|
import type { DbClient } from '../db/client';
|
|
5
5
|
import {
|
|
6
6
|
capabilities,
|
|
7
|
+
containerServices,
|
|
8
|
+
machines,
|
|
7
9
|
moduleConfigs,
|
|
10
|
+
moduleInfrastructure,
|
|
8
11
|
modules,
|
|
9
12
|
secrets,
|
|
10
13
|
systemConfig,
|
|
11
14
|
systemSecrets,
|
|
12
15
|
} from '../db/schema';
|
|
13
16
|
import { allocateResources, getAllocation } from '../ipam/allocator';
|
|
14
|
-
import type
|
|
17
|
+
import { type ModuleManifest, getDeclaredSystems } from '../manifest/schema';
|
|
15
18
|
import { decryptSecret } from '../secrets/encryption';
|
|
16
19
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
20
|
+
import { upsertModuleConfig } from '../services/module-config';
|
|
21
|
+
import { resolveComputedFields } from './computed/evaluate';
|
|
22
|
+
import { containsComputedMarker } from './computed/marker';
|
|
23
|
+
import { buildProviderLookup } from './computed/provider-lookup';
|
|
17
24
|
import { applyDeclarativeDerivations } from './declarative-derivation';
|
|
18
25
|
import { applyIndex, parsePath } from './parser';
|
|
19
26
|
import type { ResolutionContext } from './types';
|
|
@@ -88,75 +95,6 @@ async function autoAssignFromWellKnown(
|
|
|
88
95
|
return result;
|
|
89
96
|
}
|
|
90
97
|
|
|
91
|
-
/**
|
|
92
|
-
* Determine if module needs IPAM auto-allocation
|
|
93
|
-
* Policy function - checks manifest and config
|
|
94
|
-
*
|
|
95
|
-
* A module needs IPAM if:
|
|
96
|
-
* 1. It declares vmid and target_ip variables (container-based), AND
|
|
97
|
-
* 2. These values are not already set in module config
|
|
98
|
-
*
|
|
99
|
-
* @param manifest - Module manifest
|
|
100
|
-
* @param selfConfig - Current module configuration
|
|
101
|
-
* @returns true if IPAM allocation needed
|
|
102
|
-
*/
|
|
103
|
-
function needsIpamAllocation(
|
|
104
|
-
manifest: ModuleManifest,
|
|
105
|
-
selfConfig: Record<string, string>,
|
|
106
|
-
): boolean {
|
|
107
|
-
const variables = manifest.variables?.owns ?? [];
|
|
108
|
-
|
|
109
|
-
const hasVmid = variables.some((v) => v.name === 'vmid');
|
|
110
|
-
const hasTargetIp = variables.some((v) => v.name === 'target_ip');
|
|
111
|
-
|
|
112
|
-
// Module must declare both vmid and target_ip to be container-based
|
|
113
|
-
if (!hasVmid || !hasTargetIp) {
|
|
114
|
-
return false;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Check if already allocated (both must be present)
|
|
118
|
-
const vmidSet = selfConfig.vmid !== undefined && selfConfig.vmid !== '';
|
|
119
|
-
const ipSet = selfConfig.target_ip !== undefined && selfConfig.target_ip !== '';
|
|
120
|
-
|
|
121
|
-
// Need allocation if either is missing
|
|
122
|
-
return !vmidSet || !ipSet;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Determine network zone for module
|
|
127
|
-
* Policy function - extracts zone from manifest or config
|
|
128
|
-
*
|
|
129
|
-
* Zone determination priority:
|
|
130
|
-
* 1. Explicit zone in module config (user override)
|
|
131
|
-
* 2. VM resources zone in manifest
|
|
132
|
-
* 3. Default to 'dmz'
|
|
133
|
-
*
|
|
134
|
-
* @param manifest - Module manifest
|
|
135
|
-
* @param selfConfig - Current module configuration
|
|
136
|
-
* @returns Network zone ('dmz', 'app', 'secure', or 'internal')
|
|
137
|
-
*/
|
|
138
|
-
function determineModuleZone(
|
|
139
|
-
manifest: ModuleManifest,
|
|
140
|
-
selfConfig: Record<string, string>,
|
|
141
|
-
): 'dmz' | 'app' | 'secure' | 'internal' {
|
|
142
|
-
// Priority 1: Explicit zone in config
|
|
143
|
-
if (selfConfig.zone) {
|
|
144
|
-
const zone = selfConfig.zone;
|
|
145
|
-
if (zone === 'dmz' || zone === 'app' || zone === 'secure' || zone === 'internal') {
|
|
146
|
-
return zone;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Priority 2: VM resources zone in manifest
|
|
151
|
-
const vmZone = manifest.requires?.machine?.zone;
|
|
152
|
-
if (vmZone === 'dmz' || vmZone === 'app' || vmZone === 'secure' || vmZone === 'internal') {
|
|
153
|
-
return vmZone;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Default: dmz (public-facing services)
|
|
157
|
-
return 'dmz';
|
|
158
|
-
}
|
|
159
|
-
|
|
160
98
|
/**
|
|
161
99
|
* Auto-derive inventory variables from module configuration
|
|
162
100
|
* Policy function - derives values from existing config
|
|
@@ -204,17 +142,18 @@ function autoDeriveInventoryVariables(
|
|
|
204
142
|
}
|
|
205
143
|
|
|
206
144
|
/**
|
|
207
|
-
* Recursively resolve "$self:key" strings in a
|
|
208
|
-
* using the provider module's actual config values
|
|
209
|
-
* optional `[N]` array-index suffix
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
* with
|
|
145
|
+
* Recursively resolve "$self:key" and "$infra:<name>.<field>" strings in a
|
|
146
|
+
* capability data object using the provider module's actual config values and
|
|
147
|
+
* its deployed systems. `$self:` supports the optional `[N]` array-index suffix
|
|
148
|
+
* (e.g. `$self:domains[0]`). `$infra:` references a provider's deployed system
|
|
149
|
+
* by name (the replacement for the old `$self:target_ip` — see
|
|
150
|
+
* v2/MODULE_SYSTEMS_ADDRESSING.md). Non-string values and strings that don't
|
|
151
|
+
* start with one of those prefixes are returned unchanged.
|
|
214
152
|
*/
|
|
215
153
|
function resolveSelfRefsInObject(
|
|
216
154
|
obj: Record<string, unknown>,
|
|
217
155
|
providerConfig: Record<string, unknown>,
|
|
156
|
+
providerSystems: Record<string, import('./types').InfraSystemFields> = {},
|
|
218
157
|
): Record<string, unknown> {
|
|
219
158
|
const result: Record<string, unknown> = {};
|
|
220
159
|
for (const [key, value] of Object.entries(obj)) {
|
|
@@ -222,8 +161,20 @@ function resolveSelfRefsInObject(
|
|
|
222
161
|
const { name, index } = parsePath(value.slice(6));
|
|
223
162
|
const resolved = applyIndex(providerConfig[name], index);
|
|
224
163
|
result[key] = resolved !== undefined ? resolved : value;
|
|
164
|
+
} else if (typeof value === 'string' && value.startsWith('$infra:')) {
|
|
165
|
+
const path = value.slice('$infra:'.length);
|
|
166
|
+
const dot = path.indexOf('.');
|
|
167
|
+
const sysName = dot === -1 ? path : path.slice(0, dot);
|
|
168
|
+
const field = dot === -1 ? '' : path.slice(dot + 1);
|
|
169
|
+
const sys = providerSystems[sysName] as unknown as Record<string, unknown> | undefined;
|
|
170
|
+
const resolved = sys ? sys[field] : undefined;
|
|
171
|
+
result[key] = resolved !== undefined && resolved !== '' ? resolved : value;
|
|
225
172
|
} else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
226
|
-
result[key] = resolveSelfRefsInObject(
|
|
173
|
+
result[key] = resolveSelfRefsInObject(
|
|
174
|
+
value as Record<string, unknown>,
|
|
175
|
+
providerConfig,
|
|
176
|
+
providerSystems,
|
|
177
|
+
);
|
|
227
178
|
} else {
|
|
228
179
|
result[key] = value;
|
|
229
180
|
}
|
|
@@ -256,12 +207,13 @@ export async function buildResolutionContext(
|
|
|
256
207
|
|
|
257
208
|
const selfConfig: Record<string, string> = {};
|
|
258
209
|
for (const row of configRows) {
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
210
|
+
// Template variables substitute as strings (the YAML/HCL files
|
|
211
|
+
// this map drives expect string replacements). `value` is already
|
|
212
|
+
// the human-readable string form populated alongside valueJson;
|
|
213
|
+
// safe to use directly here. The typed path (numbers, booleans
|
|
214
|
+
// as their JS types) is parseStoredConfigValue, used by hook
|
|
215
|
+
// contexts.
|
|
216
|
+
selfConfig[row.key] = row.value;
|
|
265
217
|
}
|
|
266
218
|
|
|
267
219
|
// Well-known capability auto-assignment
|
|
@@ -272,24 +224,12 @@ export async function buildResolutionContext(
|
|
|
272
224
|
|
|
273
225
|
// Store assigned values in module config
|
|
274
226
|
if (assigned.hostname) {
|
|
275
|
-
|
|
276
|
-
.insert(moduleConfigs)
|
|
277
|
-
.values({ moduleId, key: 'hostname', value: assigned.hostname })
|
|
278
|
-
.onConflictDoUpdate({
|
|
279
|
-
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
280
|
-
set: { value: assigned.hostname },
|
|
281
|
-
});
|
|
227
|
+
upsertModuleConfig(db, moduleId, 'hostname', assigned.hostname);
|
|
282
228
|
selfConfig.hostname = assigned.hostname;
|
|
283
229
|
}
|
|
284
230
|
|
|
285
231
|
if (assigned.zone) {
|
|
286
|
-
|
|
287
|
-
.insert(moduleConfigs)
|
|
288
|
-
.values({ moduleId, key: 'zone', value: assigned.zone })
|
|
289
|
-
.onConflictDoUpdate({
|
|
290
|
-
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
291
|
-
set: { value: assigned.zone },
|
|
292
|
-
});
|
|
232
|
+
upsertModuleConfig(db, moduleId, 'zone', assigned.zone);
|
|
293
233
|
selfConfig.zone = assigned.zone;
|
|
294
234
|
}
|
|
295
235
|
}
|
|
@@ -301,19 +241,18 @@ export async function buildResolutionContext(
|
|
|
301
241
|
const variables = manifest.variables?.owns ?? [];
|
|
302
242
|
|
|
303
243
|
for (const variable of variables) {
|
|
304
|
-
// Only apply if variable has a default and config doesn't already have it
|
|
244
|
+
// Only apply if variable has a default and config doesn't already have it.
|
|
245
|
+
// Pass the typed manifest default directly so valueJson preserves the
|
|
246
|
+
// declared shape — e.g. `default: 2222` (YAML int) round-trips as
|
|
247
|
+
// `number` not the string "2222". This is the root of Defect 1.
|
|
305
248
|
if (variable.default !== undefined && !selfConfig[variable.name]) {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
.
|
|
310
|
-
.
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
set: { value: valueStr },
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
selfConfig[variable.name] = valueStr;
|
|
249
|
+
upsertModuleConfig(
|
|
250
|
+
db,
|
|
251
|
+
moduleId,
|
|
252
|
+
variable.name,
|
|
253
|
+
variable.default as string | number | boolean | unknown[] | Record<string, unknown>,
|
|
254
|
+
);
|
|
255
|
+
selfConfig[variable.name] = String(variable.default);
|
|
317
256
|
}
|
|
318
257
|
}
|
|
319
258
|
}
|
|
@@ -339,72 +278,99 @@ export async function buildResolutionContext(
|
|
|
339
278
|
for (const { manifestKey, configKey } of resourceMappings) {
|
|
340
279
|
const value = machineResources[manifestKey];
|
|
341
280
|
|
|
342
|
-
//
|
|
281
|
+
// Manifest fields are typed (cpu: number, storage: string, etc.).
|
|
282
|
+
// Pass them through unstringified so valueJson preserves the
|
|
283
|
+
// shape — see comment in the variable-defaults block above.
|
|
343
284
|
if (value !== undefined && !selfConfig[configKey]) {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
set: { value: valueStr },
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
selfConfig[configKey] = valueStr;
|
|
285
|
+
upsertModuleConfig(
|
|
286
|
+
db,
|
|
287
|
+
moduleId,
|
|
288
|
+
configKey,
|
|
289
|
+
value as string | number | boolean | unknown[] | Record<string, unknown>,
|
|
290
|
+
);
|
|
291
|
+
selfConfig[configKey] = String(value);
|
|
355
292
|
}
|
|
356
293
|
}
|
|
357
294
|
}
|
|
358
295
|
}
|
|
359
296
|
|
|
360
|
-
//
|
|
361
|
-
//
|
|
297
|
+
// Deployed-system recording (v2/MODULE_SYSTEMS_ADDRESSING.md).
|
|
298
|
+
// Record this module's system(s) into module_systems at generate time, so
|
|
299
|
+
// `$infra:<name>.…` resolves in templates. The address comes from:
|
|
300
|
+
// - machine pool → the machine's own IP; no vmid (no container).
|
|
301
|
+
// - container_service → IPAM-allocated zone IP + vmid (proxmox only).
|
|
302
|
+
// - DigitalOcean → assigned by terraform; recorded at deploy from
|
|
303
|
+
// outputs (resolveInfrastructureVariables), not here.
|
|
304
|
+
// This is the single place generate-time addresses are recorded — `target_ip`
|
|
305
|
+
// no longer lives in module_configs.
|
|
362
306
|
if (module?.manifestData) {
|
|
363
307
|
const manifest = module.manifestData as ModuleManifest;
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
//
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
vmid = allocation.vmid;
|
|
384
|
-
allocatedIp = allocation.containerIp;
|
|
308
|
+
const declared = getDeclaredSystems(manifest);
|
|
309
|
+
const hostname = selfConfig.hostname;
|
|
310
|
+
|
|
311
|
+
if (declared.length > 0 && hostname) {
|
|
312
|
+
// Single-system transition: every current module declares one system.
|
|
313
|
+
const decl = declared[0];
|
|
314
|
+
const zone = decl.resources.zone;
|
|
315
|
+
const infraSelection = db
|
|
316
|
+
.select()
|
|
317
|
+
.from(moduleInfrastructure)
|
|
318
|
+
.where(eq(moduleInfrastructure.moduleId, moduleId))
|
|
319
|
+
.get();
|
|
320
|
+
const { upsertDeployedSystem } = await import('../services/deployed-systems');
|
|
321
|
+
|
|
322
|
+
if (infraSelection?.infrastructureType === 'machine') {
|
|
323
|
+
if (!infraSelection.machineId) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`Module '${moduleId}' is assigned to a machine but module_infrastructure.machineId is null`,
|
|
326
|
+
);
|
|
385
327
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
.
|
|
390
|
-
.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
.
|
|
398
|
-
|
|
399
|
-
.
|
|
400
|
-
|
|
401
|
-
|
|
328
|
+
const machineRow = db
|
|
329
|
+
.select()
|
|
330
|
+
.from(machines)
|
|
331
|
+
.where(eq(machines.id, infraSelection.machineId))
|
|
332
|
+
.get();
|
|
333
|
+
if (!machineRow?.ipAddress) {
|
|
334
|
+
throw new Error(
|
|
335
|
+
`Machine '${infraSelection.machineId}' for module '${moduleId}' has no ipAddress`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
upsertDeployedSystem(db, moduleId, {
|
|
339
|
+
name: decl.name,
|
|
340
|
+
hostname,
|
|
341
|
+
ipv4Address: machineRow.ipAddress,
|
|
342
|
+
zone,
|
|
343
|
+
infraType: 'machine',
|
|
344
|
+
machineId: infraSelection.machineId,
|
|
345
|
+
});
|
|
346
|
+
} else if (infraSelection?.infrastructureType === 'container_service') {
|
|
347
|
+
// Only proxmox needs IPAM; DigitalOcean's IP comes from terraform
|
|
348
|
+
// outputs at deploy. Determine the provider from the selected service.
|
|
349
|
+
let isProxmox = false;
|
|
350
|
+
if (infraSelection.serviceId) {
|
|
351
|
+
const svc = db
|
|
352
|
+
.select()
|
|
353
|
+
.from(containerServices)
|
|
354
|
+
.where(eq(containerServices.id, infraSelection.serviceId))
|
|
355
|
+
.get();
|
|
356
|
+
isProxmox = svc?.providerName === 'proxmox';
|
|
357
|
+
}
|
|
358
|
+
if (isProxmox && zone !== 'external') {
|
|
359
|
+
await db.transaction(async (tx) => {
|
|
360
|
+
const existing = await getAllocation(moduleId, tx);
|
|
361
|
+
const allocation = existing ?? (await allocateResources(moduleId, zone, tx));
|
|
362
|
+
upsertDeployedSystem(tx as unknown as DbClient, moduleId, {
|
|
363
|
+
name: decl.name,
|
|
364
|
+
hostname,
|
|
365
|
+
ipv4Address: allocation.containerIp,
|
|
366
|
+
zone,
|
|
367
|
+
infraType: 'container_service',
|
|
368
|
+
serviceId: infraSelection.serviceId,
|
|
369
|
+
vmid: allocation.vmid,
|
|
370
|
+
});
|
|
402
371
|
});
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
selfConfig.vmid = String(vmid);
|
|
406
|
-
selfConfig.target_ip = allocatedIp;
|
|
407
|
-
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
408
374
|
}
|
|
409
375
|
}
|
|
410
376
|
|
|
@@ -463,10 +429,38 @@ export async function buildResolutionContext(
|
|
|
463
429
|
for (const c of providerConfigs) {
|
|
464
430
|
providerConfigMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
|
|
465
431
|
}
|
|
466
|
-
|
|
432
|
+
// The provider's deployed systems, so capability data can reference
|
|
433
|
+
// `$infra:<name>.ipv4_address` (replacing the old `$self:target_ip`).
|
|
434
|
+
// CIDR uses the default prefix here (capability data reads bare ipv4/host,
|
|
435
|
+
// not cidr); the full systemConfig isn't built yet at this point.
|
|
436
|
+
const { buildInfraSystemsMap } = await import('../services/deployed-systems');
|
|
437
|
+
const providerSystems = buildInfraSystemsMap(row.moduleId, db, {});
|
|
438
|
+
let resolved = resolveSelfRefsInObject(
|
|
467
439
|
data as Record<string, unknown>,
|
|
468
440
|
providerConfigMap,
|
|
441
|
+
providerSystems,
|
|
469
442
|
);
|
|
443
|
+
|
|
444
|
+
// Evaluate computed-field markers (v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md
|
|
445
|
+
// D1) in the PROVIDER's context, so consumers — both `$capability:` template
|
|
446
|
+
// refs AND `variables.imports` — see the real value (e.g. domain_list as an
|
|
447
|
+
// array) rather than the raw marker. Only build the provider lookup when a
|
|
448
|
+
// marker is actually present (the common case has none). Best-effort: a
|
|
449
|
+
// single provider's eval failure must not break an unrelated module's
|
|
450
|
+
// context build, so we log and leave the original data on error.
|
|
451
|
+
if (containsComputedMarker(resolved)) {
|
|
452
|
+
try {
|
|
453
|
+
const lookup = await buildProviderLookup(row.moduleId, db);
|
|
454
|
+
resolved = resolveComputedFields(resolved, lookup) as Record<string, unknown>;
|
|
455
|
+
} catch (err) {
|
|
456
|
+
console.error(
|
|
457
|
+
`Failed to evaluate computed fields for capability '${row.capabilityName}' (provider '${row.moduleId}'):`,
|
|
458
|
+
err,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
capabilitiesMap[row.capabilityName] = resolved;
|
|
470
464
|
}
|
|
471
465
|
|
|
472
466
|
// Fetch system configuration (for $system: variables)
|
|
@@ -508,6 +502,16 @@ export async function buildResolutionContext(
|
|
|
508
502
|
// System secrets are optional
|
|
509
503
|
}
|
|
510
504
|
|
|
505
|
+
// Build the `$infra:<name>.<field>` lookup from the systems recorded above
|
|
506
|
+
// (proxmox/machine at generate; DigitalOcean gets refreshed at deploy from
|
|
507
|
+
// terraform outputs). This is what lets `$infra:` resolve in templates.
|
|
508
|
+
// v2/MODULE_SYSTEMS_ADDRESSING.md.
|
|
509
|
+
let infraSystemsMap: Record<string, import('./types').InfraSystemFields> = {};
|
|
510
|
+
if (module?.manifestData) {
|
|
511
|
+
const { buildInfraSystemsMap } = await import('../services/deployed-systems');
|
|
512
|
+
infraSystemsMap = buildInfraSystemsMap(moduleId, db, systemConfigMap);
|
|
513
|
+
}
|
|
514
|
+
|
|
511
515
|
// Zone-based networking
|
|
512
516
|
// Auto-derive network config from zone (gateway, vlan, subnet, bridge)
|
|
513
517
|
if (module?.manifestData) {
|
|
@@ -516,13 +520,7 @@ export async function buildResolutionContext(
|
|
|
516
520
|
|
|
517
521
|
// If zone from manifest but not in selfConfig, store it as first-class config
|
|
518
522
|
if (zone && !selfConfig.zone) {
|
|
519
|
-
|
|
520
|
-
.insert(moduleConfigs)
|
|
521
|
-
.values({ moduleId, key: 'zone', value: zone })
|
|
522
|
-
.onConflictDoUpdate({
|
|
523
|
-
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
524
|
-
set: { value: zone },
|
|
525
|
-
});
|
|
523
|
+
upsertModuleConfig(db, moduleId, 'zone', zone);
|
|
526
524
|
selfConfig.zone = zone;
|
|
527
525
|
}
|
|
528
526
|
|
|
@@ -537,15 +535,25 @@ export async function buildResolutionContext(
|
|
|
537
535
|
const value = systemConfigMap[systemConfigKey];
|
|
538
536
|
|
|
539
537
|
if (value) {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
538
|
+
// system_config stores everything as strings (no
|
|
539
|
+
// valueJson companion column there — that's a sister
|
|
540
|
+
// type-fidelity gap, tracked separately). Use a
|
|
541
|
+
// try-JSON-parse heuristic at the boundary to recover
|
|
542
|
+
// primitive types: "20" → 20 (vlan tags are numbers),
|
|
543
|
+
// "true" → true, IPs/hostnames fall through to string.
|
|
544
|
+
// Once system_config gets the same treatment as
|
|
545
|
+
// module_configs this coercion becomes redundant.
|
|
546
|
+
const coerced = ((): string | number | boolean => {
|
|
547
|
+
try {
|
|
548
|
+
const parsed = JSON.parse(value);
|
|
549
|
+
if (typeof parsed === 'number' || typeof parsed === 'boolean') return parsed;
|
|
550
|
+
} catch {
|
|
551
|
+
// not JSON — fall through
|
|
552
|
+
}
|
|
553
|
+
return value;
|
|
554
|
+
})();
|
|
555
|
+
upsertModuleConfig(db, moduleId, field, coerced);
|
|
556
|
+
selfConfig[field] = String(coerced);
|
|
549
557
|
}
|
|
550
558
|
}
|
|
551
559
|
}
|
|
@@ -563,6 +571,7 @@ export async function buildResolutionContext(
|
|
|
563
571
|
systemSecrets: systemSecretsMap,
|
|
564
572
|
secrets: secretsMap,
|
|
565
573
|
capabilities: capabilitiesMap,
|
|
574
|
+
systems: infraSystemsMap,
|
|
566
575
|
};
|
|
567
576
|
|
|
568
577
|
// Snapshot values before derivation so we can detect changes
|
|
@@ -603,14 +612,7 @@ export async function buildResolutionContext(
|
|
|
603
612
|
}
|
|
604
613
|
|
|
605
614
|
if (isNew || isChanged) {
|
|
606
|
-
|
|
607
|
-
.insert(moduleConfigs)
|
|
608
|
-
.values({ moduleId, key, value })
|
|
609
|
-
.onConflictDoUpdate({
|
|
610
|
-
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
611
|
-
set: { value },
|
|
612
|
-
});
|
|
613
|
-
|
|
615
|
+
upsertModuleConfig(db, moduleId, key, value);
|
|
614
616
|
selfConfig[key] = value;
|
|
615
617
|
}
|
|
616
618
|
}
|
|
@@ -630,6 +632,7 @@ export async function buildResolutionContext(
|
|
|
630
632
|
systemSecrets: systemSecretsMap,
|
|
631
633
|
secrets: secretsMap,
|
|
632
634
|
capabilities: capabilitiesMap,
|
|
635
|
+
systems: infraSystemsMap,
|
|
633
636
|
};
|
|
634
637
|
}
|
|
635
638
|
|
|
@@ -650,6 +653,7 @@ export function buildContextFromData(
|
|
|
650
653
|
systemSecrets?: Record<string, string>;
|
|
651
654
|
secrets?: Record<string, string>;
|
|
652
655
|
capabilities?: Record<string, Record<string, unknown>>;
|
|
656
|
+
systems?: Record<string, import('./types').InfraSystemFields>;
|
|
653
657
|
} = {},
|
|
654
658
|
): ResolutionContext {
|
|
655
659
|
const selfConfig = data.selfConfig ?? {};
|
|
@@ -668,5 +672,6 @@ export function buildContextFromData(
|
|
|
668
672
|
systemSecrets: data.systemSecrets ?? {},
|
|
669
673
|
secrets: data.secrets ?? {},
|
|
670
674
|
capabilities: data.capabilities ?? {},
|
|
675
|
+
systems: data.systems ?? {},
|
|
671
676
|
};
|
|
672
677
|
}
|
package/src/variables/parser.ts
CHANGED
|
@@ -21,7 +21,7 @@ import type { VariableReference } from './types';
|
|
|
21
21
|
* computed alias for the canonical default).
|
|
22
22
|
*/
|
|
23
23
|
const VARIABLE_PATTERN =
|
|
24
|
-
/\$\{(self|system|system_secret|secret|capability):([a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)\}|\$(self|system|system_secret|secret|capability):([a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)/g;
|
|
24
|
+
/\$\{(self|system|system_secret|secret|capability|infra):([a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)\}|\$(self|system|system_secret|secret|capability|infra):([a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)/g;
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Parse template content to extract variable references
|
|
@@ -64,7 +64,7 @@ export function hasVariables(content: string): boolean {
|
|
|
64
64
|
// Create new regex without state to avoid issues with global flag
|
|
65
65
|
// Matches both ${type:path} and $type:path (with optional [N] index)
|
|
66
66
|
const pattern =
|
|
67
|
-
/\$\{?(self|system|system_secret|secret|capability):([a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)/;
|
|
67
|
+
/\$\{?(self|system|system_secret|secret|capability|infra):([a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)/;
|
|
68
68
|
return pattern.test(content);
|
|
69
69
|
}
|
|
70
70
|
|
|
@@ -78,7 +78,7 @@ export function isValidVariableFormat(variable: string): boolean {
|
|
|
78
78
|
// Path must start with letter or underscore, not digit
|
|
79
79
|
// Accepts both ${type:path} and $type:path with optional [N] indexing
|
|
80
80
|
const pattern =
|
|
81
|
-
/^(?:\$\{(self|system|system_secret|secret|capability):[a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?\}|\$(self|system|system_secret|secret|capability):[a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)$/;
|
|
81
|
+
/^(?:\$\{(self|system|system_secret|secret|capability|infra):[a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?\}|\$(self|system|system_secret|secret|capability|infra):[a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)$/;
|
|
82
82
|
return pattern.test(variable);
|
|
83
83
|
}
|
|
84
84
|
|
|
@@ -82,6 +82,67 @@ describe('resolveVariable', () => {
|
|
|
82
82
|
}
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
describe('$infra: deployed-system selector', () => {
|
|
86
|
+
const infraContext = () =>
|
|
87
|
+
createContext({
|
|
88
|
+
systems: {
|
|
89
|
+
main: {
|
|
90
|
+
name: 'main',
|
|
91
|
+
hostname: 'homebridge',
|
|
92
|
+
ipv4_address: '10.0.20.20',
|
|
93
|
+
zone: 'app',
|
|
94
|
+
cidr: '10.0.20.20/24',
|
|
95
|
+
vmid: '2100',
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('resolves $infra:<name>.ipv4_address', async () => {
|
|
101
|
+
const result = await resolveVariable(
|
|
102
|
+
{ type: 'infra', path: 'main.ipv4_address', raw: '$infra:main.ipv4_address' },
|
|
103
|
+
infraContext(),
|
|
104
|
+
mockDb,
|
|
105
|
+
);
|
|
106
|
+
expect(result.success).toBe(true);
|
|
107
|
+
if (result.success) expect(result.value).toBe('10.0.20.20');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('resolves $infra:<name>.cidr and .vmid', async () => {
|
|
111
|
+
const ctx = infraContext();
|
|
112
|
+
const cidr = await resolveVariable(
|
|
113
|
+
{ type: 'infra', path: 'main.cidr', raw: '$infra:main.cidr' },
|
|
114
|
+
ctx,
|
|
115
|
+
mockDb,
|
|
116
|
+
);
|
|
117
|
+
const vmid = await resolveVariable(
|
|
118
|
+
{ type: 'infra', path: 'main.vmid', raw: '$infra:main.vmid' },
|
|
119
|
+
ctx,
|
|
120
|
+
mockDb,
|
|
121
|
+
);
|
|
122
|
+
expect(cidr.success && cidr.value).toBe('10.0.20.20/24');
|
|
123
|
+
expect(vmid.success && vmid.value).toBe('2100');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('fails clearly for an unknown system name', async () => {
|
|
127
|
+
const result = await resolveVariable(
|
|
128
|
+
{ type: 'infra', path: 'db.ipv4_address', raw: '$infra:db.ipv4_address' },
|
|
129
|
+
infraContext(),
|
|
130
|
+
mockDb,
|
|
131
|
+
);
|
|
132
|
+
expect(result.success).toBe(false);
|
|
133
|
+
if (!result.success) expect(result.error).toContain("No deployed system named 'db'");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('fails when no systems recorded (API-only / pre-deploy)', async () => {
|
|
137
|
+
const result = await resolveVariable(
|
|
138
|
+
{ type: 'infra', path: 'main.ipv4_address', raw: '$infra:main.ipv4_address' },
|
|
139
|
+
createContext(),
|
|
140
|
+
mockDb,
|
|
141
|
+
);
|
|
142
|
+
expect(result.success).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
85
146
|
test('should resolve system variable', async () => {
|
|
86
147
|
const variable: VariableReference = {
|
|
87
148
|
type: 'system',
|