@celilo/cli 0.1.4 → 0.1.5
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/package.json +2 -2
- package/src/ansible/inventory.test.ts +6 -6
- package/src/ansible/inventory.ts +4 -6
- package/src/capabilities/public-web-publish.test.ts +11 -11
- 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/commands/module-show.ts +1 -1
- package/src/db/schema.test.ts +3 -3
- package/src/hooks/capability-loader.ts +20 -17
- package/src/hooks/define-hook.test.ts +2 -2
- 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/packaging/build.ts +36 -9
- package/src/services/deploy-planner.ts +5 -5
- package/src/services/dns-auto-register.ts +4 -4
- package/src/services/health-runner.ts +1 -1
- package/src/services/infrastructure-variable-resolver.test.ts +1 -1
- package/src/services/infrastructure-variable-resolver.ts +3 -3
- package/src/services/module-deploy.ts +2 -2
- package/src/services/proxmox-state-recovery.ts +6 -6
- package/src/templates/generator.test.ts +2 -2
- package/src/templates/generator.ts +1 -1
- package/src/variables/context.test.ts +31 -31
- package/src/variables/context.ts +16 -16
- package/src/variables/parser.test.ts +8 -8
- package/src/variables/resolver.test.ts +14 -14
- package/src/variables/types.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@aws-sdk/client-s3": "^3.1024.0",
|
|
49
49
|
"@clack/prompts": "^1.1.0",
|
|
50
|
-
"@celilo/capabilities": "^0.1.
|
|
50
|
+
"@celilo/capabilities": "^0.1.2",
|
|
51
51
|
"ajv": "^8.18.0",
|
|
52
52
|
"drizzle-orm": "^0.36.4",
|
|
53
53
|
"tar": "^7.5.10",
|
|
@@ -99,14 +99,14 @@ describe('generateHostVarsYaml', () => {
|
|
|
99
99
|
const vars = {
|
|
100
100
|
vmid: 2110,
|
|
101
101
|
hostname: 'iot',
|
|
102
|
-
|
|
102
|
+
target_ip: '192.168.0.110/24',
|
|
103
103
|
};
|
|
104
104
|
|
|
105
105
|
const result = generateHostVarsYaml(vars);
|
|
106
106
|
|
|
107
107
|
expect(result).toContain('vmid: 2110');
|
|
108
108
|
expect(result).toContain('hostname: iot');
|
|
109
|
-
expect(result).toContain('
|
|
109
|
+
expect(result).toContain('target_ip: 192.168.0.110/24');
|
|
110
110
|
});
|
|
111
111
|
|
|
112
112
|
test('generates YAML for arrays', () => {
|
|
@@ -289,7 +289,7 @@ describe('Database integration', () => {
|
|
|
289
289
|
{ moduleId: 'homebridge', key: 'hostname', value: 'iot', valueJson: null },
|
|
290
290
|
{
|
|
291
291
|
moduleId: 'homebridge',
|
|
292
|
-
key: '
|
|
292
|
+
key: 'target_ip',
|
|
293
293
|
value: '192.168.0.110/24',
|
|
294
294
|
valueJson: null,
|
|
295
295
|
},
|
|
@@ -301,7 +301,7 @@ describe('Database integration', () => {
|
|
|
301
301
|
|
|
302
302
|
expect(vars.vmid).toBe(2110);
|
|
303
303
|
expect(vars.hostname).toBe('iot');
|
|
304
|
-
expect(vars.
|
|
304
|
+
expect(vars.target_ip).toBe('192.168.0.110/24');
|
|
305
305
|
expect(vars.cores).toBe(1);
|
|
306
306
|
});
|
|
307
307
|
|
|
@@ -404,7 +404,7 @@ describe('Database integration', () => {
|
|
|
404
404
|
db.insert(moduleConfigs)
|
|
405
405
|
.values([
|
|
406
406
|
{ moduleId: 'homebridge', key: 'hostname', value: 'iot' },
|
|
407
|
-
{ moduleId: 'homebridge', key: '
|
|
407
|
+
{ moduleId: 'homebridge', key: 'target_ip', value: '192.168.0.110/24' },
|
|
408
408
|
])
|
|
409
409
|
.run();
|
|
410
410
|
|
|
@@ -446,7 +446,7 @@ describe('Database integration', () => {
|
|
|
446
446
|
db.insert(moduleConfigs)
|
|
447
447
|
.values([
|
|
448
448
|
{ moduleId: 'test', key: 'hostname', value: 'iot' },
|
|
449
|
-
// Missing
|
|
449
|
+
// Missing target_ip or vps_ip for ansible_host
|
|
450
450
|
])
|
|
451
451
|
.run();
|
|
452
452
|
|
package/src/ansible/inventory.ts
CHANGED
|
@@ -259,13 +259,11 @@ export function extractInventoryHost(moduleId: string, db: DbClient): InventoryH
|
|
|
259
259
|
}
|
|
260
260
|
|
|
261
261
|
// Auto-derive ansible_host from infrastructure variables
|
|
262
|
-
// Priority:
|
|
263
|
-
if (moduleConfig.
|
|
264
|
-
const slashIndex = moduleConfig.
|
|
262
|
+
// Priority: target_ip > ip.primary > vps_ip (for backward compatibility)
|
|
263
|
+
if (moduleConfig.target_ip) {
|
|
264
|
+
const slashIndex = moduleConfig.target_ip.indexOf('/');
|
|
265
265
|
derived['inventory.ansible_host'] =
|
|
266
|
-
slashIndex === -1
|
|
267
|
-
? moduleConfig.container_ip
|
|
268
|
-
: moduleConfig.container_ip.slice(0, slashIndex);
|
|
266
|
+
slashIndex === -1 ? moduleConfig.target_ip : moduleConfig.target_ip.slice(0, slashIndex);
|
|
269
267
|
} else if (moduleConfig['ip.primary']) {
|
|
270
268
|
derived['inventory.ansible_host'] = moduleConfig['ip.primary'];
|
|
271
269
|
} else if (moduleConfig.vps_ip) {
|
|
@@ -109,7 +109,7 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
109
109
|
moduleId: 'lunacycle',
|
|
110
110
|
logger: noopLogger,
|
|
111
111
|
config: {
|
|
112
|
-
|
|
112
|
+
target_ip: '10.0.10.20/24',
|
|
113
113
|
hostname: 'www',
|
|
114
114
|
primary_domain: 'example.com',
|
|
115
115
|
email: 'admin@example.com',
|
|
@@ -150,7 +150,7 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
150
150
|
moduleId: 'lunacycle',
|
|
151
151
|
logger: noopLogger,
|
|
152
152
|
config: {
|
|
153
|
-
|
|
153
|
+
target_ip: '10.0.10.20/24',
|
|
154
154
|
hostname: 'www',
|
|
155
155
|
primary_domain: 'example.com',
|
|
156
156
|
email: 'admin@example.com',
|
|
@@ -174,7 +174,7 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
174
174
|
moduleId: 'lunacycle',
|
|
175
175
|
logger: noopLogger,
|
|
176
176
|
config: {
|
|
177
|
-
|
|
177
|
+
target_ip: '10.0.10.20/24',
|
|
178
178
|
hostname: 'www',
|
|
179
179
|
primary_domain: 'example.com',
|
|
180
180
|
email: 'admin@example.com',
|
|
@@ -201,7 +201,7 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
201
201
|
moduleId: 'lunacycle',
|
|
202
202
|
logger: noopLogger,
|
|
203
203
|
config: {
|
|
204
|
-
|
|
204
|
+
target_ip: '10.0.10.20/24',
|
|
205
205
|
hostname: 'www',
|
|
206
206
|
primary_domain: 'example.com',
|
|
207
207
|
email: 'admin@example.com',
|
|
@@ -225,7 +225,7 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
225
225
|
moduleId: 'lunacycle',
|
|
226
226
|
logger: noopLogger,
|
|
227
227
|
config: {
|
|
228
|
-
|
|
228
|
+
target_ip: '10.0.10.20/24',
|
|
229
229
|
hostname: 'www',
|
|
230
230
|
primary_domain: 'example.com',
|
|
231
231
|
email: 'admin@example.com',
|
|
@@ -261,7 +261,7 @@ describe('registerReverseProxy', () => {
|
|
|
261
261
|
moduleId: 'lunacycle',
|
|
262
262
|
logger: noopLogger,
|
|
263
263
|
config: {
|
|
264
|
-
|
|
264
|
+
target_ip: '10.0.10.20/24',
|
|
265
265
|
hostname: 'www',
|
|
266
266
|
primary_domain: 'example.com',
|
|
267
267
|
email: 'admin@example.com',
|
|
@@ -294,7 +294,7 @@ describe('registerReverseProxy', () => {
|
|
|
294
294
|
moduleId: 'lunacycle',
|
|
295
295
|
logger: noopLogger,
|
|
296
296
|
config: {
|
|
297
|
-
|
|
297
|
+
target_ip: '10.0.10.20/24',
|
|
298
298
|
hostname: 'www',
|
|
299
299
|
primary_domain: 'example.com',
|
|
300
300
|
email: 'admin@example.com',
|
|
@@ -337,7 +337,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
|
|
|
337
337
|
moduleId: 'lunacycle',
|
|
338
338
|
logger,
|
|
339
339
|
config: {
|
|
340
|
-
|
|
340
|
+
target_ip: '10.0.10.20/24',
|
|
341
341
|
hostname: 'www',
|
|
342
342
|
primary_domain: 'example.com',
|
|
343
343
|
email: 'admin@example.com',
|
|
@@ -368,7 +368,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
|
|
|
368
368
|
moduleId: 'lunacycle',
|
|
369
369
|
logger,
|
|
370
370
|
config: {
|
|
371
|
-
|
|
371
|
+
target_ip: '10.0.10.20/24',
|
|
372
372
|
hostname: 'www',
|
|
373
373
|
primary_domain: 'example.com',
|
|
374
374
|
email: 'admin@example.com',
|
|
@@ -400,7 +400,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
|
|
|
400
400
|
moduleId: 'lunacycle',
|
|
401
401
|
logger,
|
|
402
402
|
config: {
|
|
403
|
-
|
|
403
|
+
target_ip: '10.0.10.20/24',
|
|
404
404
|
hostname: 'www',
|
|
405
405
|
primary_domain: 'example.com',
|
|
406
406
|
email: 'admin@example.com',
|
|
@@ -432,7 +432,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
|
|
|
432
432
|
moduleId: 'lunacycle',
|
|
433
433
|
logger,
|
|
434
434
|
config: {
|
|
435
|
-
|
|
435
|
+
target_ip: '10.0.10.20/24',
|
|
436
436
|
hostname: 'www',
|
|
437
437
|
primary_domain: 'example.com',
|
|
438
438
|
email: 'admin@example.com',
|
|
@@ -43,7 +43,7 @@ describe('Capability Registration', () => {
|
|
|
43
43
|
version: '1.0.0',
|
|
44
44
|
data: {
|
|
45
45
|
server: {
|
|
46
|
-
ip: '$self:
|
|
46
|
+
ip: '$self:target_ip',
|
|
47
47
|
port: 443,
|
|
48
48
|
},
|
|
49
49
|
},
|
|
@@ -65,7 +65,7 @@ describe('Capability Registration', () => {
|
|
|
65
65
|
// Variables are preserved, not resolved
|
|
66
66
|
expect(result).toEqual({
|
|
67
67
|
server: {
|
|
68
|
-
ip: '$self:
|
|
68
|
+
ip: '$self:target_ip',
|
|
69
69
|
port: 443,
|
|
70
70
|
},
|
|
71
71
|
});
|
|
@@ -177,7 +177,7 @@ describe('Capability Registration', () => {
|
|
|
177
177
|
name: 'test_capability',
|
|
178
178
|
version: '1.0.0',
|
|
179
179
|
data: {
|
|
180
|
-
variable: '$self:
|
|
180
|
+
variable: '$self:target_ip',
|
|
181
181
|
literal: 'not-a-variable',
|
|
182
182
|
with_dollar: '$100',
|
|
183
183
|
empty: '',
|
|
@@ -198,7 +198,7 @@ describe('Capability Registration', () => {
|
|
|
198
198
|
const result = buildCapabilityData(capability, manifest);
|
|
199
199
|
|
|
200
200
|
expect(result).toEqual({
|
|
201
|
-
variable: '$self:
|
|
201
|
+
variable: '$self:target_ip',
|
|
202
202
|
literal: 'not-a-variable',
|
|
203
203
|
with_dollar: '$100',
|
|
204
204
|
empty: '',
|
|
@@ -337,7 +337,7 @@ describe('Capability Registration', () => {
|
|
|
337
337
|
version: '1.0.0',
|
|
338
338
|
data: {
|
|
339
339
|
server: {
|
|
340
|
-
ip: '$self:
|
|
340
|
+
ip: '$self:target_ip',
|
|
341
341
|
},
|
|
342
342
|
},
|
|
343
343
|
};
|
|
@@ -360,7 +360,7 @@ describe('Capability Registration', () => {
|
|
|
360
360
|
(result as any).server.ip = 'modified';
|
|
361
361
|
|
|
362
362
|
// Original should be unchanged
|
|
363
|
-
expect(capability.data.server.ip).toBe('$self:
|
|
363
|
+
expect(capability.data.server.ip).toBe('$self:target_ip');
|
|
364
364
|
});
|
|
365
365
|
});
|
|
366
366
|
|
|
@@ -170,7 +170,7 @@ describe('Well-Known Capabilities Registry', () => {
|
|
|
170
170
|
expect(schema).toEqual({
|
|
171
171
|
server: {
|
|
172
172
|
ip: {
|
|
173
|
-
primary: '$self:
|
|
173
|
+
primary: '$self:target_ip',
|
|
174
174
|
},
|
|
175
175
|
port: 443,
|
|
176
176
|
},
|
|
@@ -196,7 +196,7 @@ describe('Well-Known Capabilities Registry', () => {
|
|
|
196
196
|
expect(schema).toEqual({
|
|
197
197
|
server: {
|
|
198
198
|
ip: {
|
|
199
|
-
primary: '$self:
|
|
199
|
+
primary: '$self:target_ip',
|
|
200
200
|
},
|
|
201
201
|
port: 9000,
|
|
202
202
|
},
|
|
@@ -41,7 +41,7 @@ export const WELL_KNOWN_CAPABILITIES: Record<string, WellKnownCapability> = {
|
|
|
41
41
|
data_schema: {
|
|
42
42
|
server: {
|
|
43
43
|
ip: {
|
|
44
|
-
primary: '$self:
|
|
44
|
+
primary: '$self:target_ip',
|
|
45
45
|
},
|
|
46
46
|
port: 443,
|
|
47
47
|
},
|
|
@@ -76,7 +76,7 @@ export const WELL_KNOWN_CAPABILITIES: Record<string, WellKnownCapability> = {
|
|
|
76
76
|
data_schema: {
|
|
77
77
|
server: {
|
|
78
78
|
ip: {
|
|
79
|
-
primary: '$self:
|
|
79
|
+
primary: '$self:target_ip',
|
|
80
80
|
},
|
|
81
81
|
port: 53,
|
|
82
82
|
},
|
|
@@ -102,7 +102,7 @@ export const WELL_KNOWN_CAPABILITIES: Record<string, WellKnownCapability> = {
|
|
|
102
102
|
data_schema: {
|
|
103
103
|
server: {
|
|
104
104
|
ip: {
|
|
105
|
-
primary: '$self:
|
|
105
|
+
primary: '$self:target_ip',
|
|
106
106
|
},
|
|
107
107
|
port: 9000,
|
|
108
108
|
},
|
|
@@ -121,12 +121,12 @@ export const WELL_KNOWN_CAPABILITIES: Record<string, WellKnownCapability> = {
|
|
|
121
121
|
data_schema: {
|
|
122
122
|
server: {
|
|
123
123
|
ip: {
|
|
124
|
-
primary: '$self:
|
|
124
|
+
primary: '$self:target_ip',
|
|
125
125
|
},
|
|
126
126
|
port: '$self:port', // Database-specific port
|
|
127
127
|
},
|
|
128
128
|
connection: {
|
|
129
|
-
host: '$self:
|
|
129
|
+
host: '$self:target_ip',
|
|
130
130
|
port: '$self:port',
|
|
131
131
|
name: '$self:database_name',
|
|
132
132
|
},
|
|
@@ -68,7 +68,7 @@ export async function handleModuleShowConfig(args: string[]): Promise<CommandRes
|
|
|
68
68
|
if (
|
|
69
69
|
key.startsWith('inventory.') ||
|
|
70
70
|
key === 'vmid' ||
|
|
71
|
-
key === '
|
|
71
|
+
key === 'target_ip' ||
|
|
72
72
|
key === 'gateway' ||
|
|
73
73
|
key === 'vlan' ||
|
|
74
74
|
key === 'subnet' ||
|
package/src/db/schema.test.ts
CHANGED
|
@@ -121,7 +121,7 @@ describe('Database Schema', () => {
|
|
|
121
121
|
// Insert config
|
|
122
122
|
const newConfig: NewModuleConfig = {
|
|
123
123
|
moduleId: 'homebridge',
|
|
124
|
-
key: '
|
|
124
|
+
key: 'target_ip',
|
|
125
125
|
value: '192.168.0.50',
|
|
126
126
|
};
|
|
127
127
|
|
|
@@ -129,7 +129,7 @@ describe('Database Schema', () => {
|
|
|
129
129
|
|
|
130
130
|
expect(result).toBeDefined();
|
|
131
131
|
expect(result.moduleId).toBe('homebridge');
|
|
132
|
-
expect(result.key).toBe('
|
|
132
|
+
expect(result.key).toBe('target_ip');
|
|
133
133
|
expect(result.value).toBe('192.168.0.50');
|
|
134
134
|
});
|
|
135
135
|
|
|
@@ -206,7 +206,7 @@ describe('Database Schema', () => {
|
|
|
206
206
|
// Insert config
|
|
207
207
|
const newConfig: NewModuleConfig = {
|
|
208
208
|
moduleId: 'homebridge',
|
|
209
|
-
key: '
|
|
209
|
+
key: 'target_ip',
|
|
210
210
|
value: '192.168.0.50',
|
|
211
211
|
};
|
|
212
212
|
db.insert(moduleConfigs).values(newConfig).run();
|
|
@@ -124,22 +124,6 @@ export async function loadCapabilityFunctions(
|
|
|
124
124
|
const providerSecrets = await loadModuleSecrets(provider.moduleId, masterKey, db);
|
|
125
125
|
const routeOps = buildRouteOps(db);
|
|
126
126
|
|
|
127
|
-
// Inject machine IP if module is deployed to a machine (not in moduleConfigs table)
|
|
128
|
-
if (!providerConfig.container_ip) {
|
|
129
|
-
const infra = db
|
|
130
|
-
.select()
|
|
131
|
-
.from(moduleInfrastructure)
|
|
132
|
-
.where(eq(moduleInfrastructure.moduleId, provider.moduleId))
|
|
133
|
-
.get();
|
|
134
|
-
if (infra?.machineId) {
|
|
135
|
-
const machine = db.select().from(machines).where(eq(machines.id, infra.machineId)).get();
|
|
136
|
-
if (machine) {
|
|
137
|
-
providerConfig.container_ip = machine.ipAddress;
|
|
138
|
-
debugLog(`public_web: injected container_ip=${machine.ipAddress} from machine`);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
127
|
try {
|
|
144
128
|
// createPublicWeb internally applies wrapWithLogging using the
|
|
145
129
|
// logger we pass in here, so the consumer doesn't need to wrap
|
|
@@ -308,7 +292,11 @@ function buildCapabilityInterface(
|
|
|
308
292
|
}
|
|
309
293
|
|
|
310
294
|
/**
|
|
311
|
-
* Load all config values for a module
|
|
295
|
+
* Load all config values for a module.
|
|
296
|
+
*
|
|
297
|
+
* Injects `target_ip` from the deployment machine when the module was deployed
|
|
298
|
+
* to an existing machine (IPAM writes `target_ip` to moduleConfigs for
|
|
299
|
+
* container deploys, but machine deploys skip that path).
|
|
312
300
|
*/
|
|
313
301
|
async function loadModuleConfig(moduleId: string, db: DbClient): Promise<Record<string, unknown>> {
|
|
314
302
|
const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all();
|
|
@@ -317,6 +305,21 @@ async function loadModuleConfig(moduleId: string, db: DbClient): Promise<Record<
|
|
|
317
305
|
for (const c of configs) {
|
|
318
306
|
result[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
|
|
319
307
|
}
|
|
308
|
+
|
|
309
|
+
if (!result.target_ip) {
|
|
310
|
+
const infra = db
|
|
311
|
+
.select()
|
|
312
|
+
.from(moduleInfrastructure)
|
|
313
|
+
.where(eq(moduleInfrastructure.moduleId, moduleId))
|
|
314
|
+
.get();
|
|
315
|
+
if (infra?.machineId) {
|
|
316
|
+
const machine = db.select().from(machines).where(eq(machines.id, infra.machineId)).get();
|
|
317
|
+
if (machine) {
|
|
318
|
+
result.target_ip = machine.ipAddress;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
320
323
|
return result;
|
|
321
324
|
}
|
|
322
325
|
|
|
@@ -286,7 +286,7 @@ describe('defineCapabilityFunction', () => {
|
|
|
286
286
|
const factory = defineCapabilityFunction({
|
|
287
287
|
capability: 'idp',
|
|
288
288
|
handler: ({ config, secrets }) => {
|
|
289
|
-
expect(config).toEqual({
|
|
289
|
+
expect(config).toEqual({ target_ip: '10.0.0.5' });
|
|
290
290
|
expect(secrets.token).toBe('abc');
|
|
291
291
|
return {
|
|
292
292
|
async create_oidc_client(
|
|
@@ -310,7 +310,7 @@ describe('defineCapabilityFunction', () => {
|
|
|
310
310
|
});
|
|
311
311
|
|
|
312
312
|
const methods = factory({
|
|
313
|
-
config: {
|
|
313
|
+
config: { target_ip: '10.0.0.5' },
|
|
314
314
|
secrets: { token: 'abc' },
|
|
315
315
|
logger: makeLogger(),
|
|
316
316
|
});
|
|
@@ -68,7 +68,7 @@ function pathExistsInManifest(manifest: ModuleManifest, path: string): boolean {
|
|
|
68
68
|
*/
|
|
69
69
|
const AUTO_ALLOCATED_VARIABLES = new Set([
|
|
70
70
|
'vmid', // Auto-allocated by IPAM (container-based modules)
|
|
71
|
-
'
|
|
71
|
+
'target_ip', // Auto-allocated by IPAM or injected from machine infrastructure
|
|
72
72
|
'vlan', // Auto-derived from zone configuration
|
|
73
73
|
'gateway', // Auto-derived from zone configuration
|
|
74
74
|
'target_node', // Can be auto-derived from system config
|
|
@@ -140,17 +140,44 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
|
|
|
140
140
|
return { success: false, error: dirError };
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
// Copy source to a temp
|
|
144
|
-
// source
|
|
145
|
-
//
|
|
143
|
+
// Copy source to a temp dir for building. Strategy:
|
|
144
|
+
// - If the source has a package.json, use `bun pm pack` to respect the
|
|
145
|
+
// `files` field (or .npmignore), copying only what the build needs.
|
|
146
|
+
// Avoids copying node_modules, build artifacts, git history.
|
|
147
|
+
// - If no package.json (simple modules, test fixtures), fall back to
|
|
148
|
+
// recursive copy with EXCLUDE_PATTERNS filter.
|
|
146
149
|
const buildDir = mkdtempSync(join(tmpdir(), 'celilo-package-'));
|
|
150
|
+
const hasPackageJson = existsSync(join(sourceDir, 'package.json'));
|
|
147
151
|
|
|
148
152
|
try {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
if (hasPackageJson) {
|
|
154
|
+
console.log('Staging source for build (via bun pm pack)...');
|
|
155
|
+
try {
|
|
156
|
+
execSync(`bun pm pack --destination ${buildDir}`, {
|
|
157
|
+
cwd: sourceDir,
|
|
158
|
+
stdio: 'pipe',
|
|
159
|
+
timeout: 60_000,
|
|
160
|
+
});
|
|
161
|
+
const tarballs = execSync(`ls ${buildDir}/*.tgz`, { encoding: 'utf-8' }).trim().split('\n');
|
|
162
|
+
if (tarballs.length === 0) throw new Error('bun pm pack produced no tarball');
|
|
163
|
+
execSync(`tar -xzf ${tarballs[0]} --strip-components=1 -C ${buildDir}`, {
|
|
164
|
+
timeout: 60_000,
|
|
165
|
+
});
|
|
166
|
+
rmSync(tarballs[0], { force: true });
|
|
167
|
+
} catch (err) {
|
|
168
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
169
|
+
return {
|
|
170
|
+
success: false,
|
|
171
|
+
error: `Failed to stage source for build: ${errMsg}\n\nTip: Add a "files" field to your package.json listing the files/directories needed for the build.`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
console.log('Staging source for build (no package.json, using file copy)...');
|
|
176
|
+
cpSync(sourceDir, buildDir, {
|
|
177
|
+
recursive: true,
|
|
178
|
+
filter: (src) => !shouldExclude(relative(sourceDir, src)),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
154
181
|
|
|
155
182
|
// Read manifest to get module ID
|
|
156
183
|
const manifestPath = join(buildDir, 'manifest.yml');
|
|
@@ -230,7 +257,7 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
|
|
|
230
257
|
error: `Failed to build module: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
231
258
|
};
|
|
232
259
|
} finally {
|
|
233
|
-
// Clean up temp directory
|
|
260
|
+
// Clean up temp build directory
|
|
234
261
|
rmSync(buildDir, { recursive: true, force: true });
|
|
235
262
|
}
|
|
236
263
|
}
|
|
@@ -126,15 +126,15 @@ export async function extractTargetHost(
|
|
|
126
126
|
const hostname = configMap.get('hostname') || moduleId;
|
|
127
127
|
|
|
128
128
|
// Extract IP - support infrastructure-derived variables
|
|
129
|
-
// Priority:
|
|
129
|
+
// Priority: target_ip > ip.primary > vps_ip (backward compatibility)
|
|
130
130
|
let ip = '';
|
|
131
|
-
const
|
|
131
|
+
const targetIp = configMap.get('target_ip');
|
|
132
132
|
const ipPrimary = configMap.get('ip.primary');
|
|
133
133
|
const vpsIp = configMap.get('vps_ip');
|
|
134
134
|
|
|
135
|
-
if (
|
|
136
|
-
//
|
|
137
|
-
ip =
|
|
135
|
+
if (targetIp) {
|
|
136
|
+
// Target IP format can be "10.0.10.10/24" - extract just IP
|
|
137
|
+
ip = targetIp.split('/')[0];
|
|
138
138
|
} else if (ipPrimary) {
|
|
139
139
|
// Infrastructure-derived IP (already plain format)
|
|
140
140
|
ip = ipPrimary;
|
|
@@ -38,15 +38,15 @@ async function getModuleHostAndIp(
|
|
|
38
38
|
|
|
39
39
|
if (!hostnameConfig?.value) return null;
|
|
40
40
|
|
|
41
|
-
// Get IP: try
|
|
42
|
-
const
|
|
41
|
+
// Get IP: try target_ip first, then machine IP
|
|
42
|
+
const targetIpConfig = db
|
|
43
43
|
.select()
|
|
44
44
|
.from(moduleConfigs)
|
|
45
45
|
.where(eq(moduleConfigs.moduleId, moduleId))
|
|
46
46
|
.all()
|
|
47
|
-
.find((c) => c.key === '
|
|
47
|
+
.find((c) => c.key === 'target_ip');
|
|
48
48
|
|
|
49
|
-
let ip =
|
|
49
|
+
let ip = targetIpConfig?.value;
|
|
50
50
|
|
|
51
51
|
if (!ip) {
|
|
52
52
|
// Try machine IP from infrastructure assignment
|
|
@@ -80,7 +80,7 @@ export async function runModuleHealthCheck(
|
|
|
80
80
|
const machine = await getMachine(infraRecord.machineId);
|
|
81
81
|
if (machine) {
|
|
82
82
|
configMap['ip.primary'] = machine.ipAddress;
|
|
83
|
-
configMap.
|
|
83
|
+
configMap.target_ip = machine.ipAddress;
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
@@ -128,10 +128,10 @@ export async function resolveInfrastructureVariables(
|
|
|
128
128
|
} else if (service.providerName === 'proxmox') {
|
|
129
129
|
// Proxmox - use IPAM allocation + service provider config
|
|
130
130
|
const vmid = await getModuleConfig(moduleId, 'vmid', db);
|
|
131
|
-
const
|
|
131
|
+
const targetIp = await getModuleConfig(moduleId, 'target_ip', db);
|
|
132
132
|
const hostname = (await getModuleConfig(moduleId, 'hostname', db)) || moduleId;
|
|
133
133
|
|
|
134
|
-
if (!vmid || !
|
|
134
|
+
if (!vmid || !targetIp) {
|
|
135
135
|
throw new Error(
|
|
136
136
|
`IPAM allocation not found for module ${moduleId}. ` +
|
|
137
137
|
`Run 'celilo module generate ${moduleId}' first.`,
|
|
@@ -143,7 +143,7 @@ export async function resolveInfrastructureVariables(
|
|
|
143
143
|
|
|
144
144
|
properties = extractProxmoxProperties(
|
|
145
145
|
Number.parseInt(vmid, 10),
|
|
146
|
-
|
|
146
|
+
targetIp,
|
|
147
147
|
hostname,
|
|
148
148
|
providerConfig,
|
|
149
149
|
);
|
|
@@ -741,13 +741,13 @@ export async function deployModule(
|
|
|
741
741
|
installConfigMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
|
|
742
742
|
}
|
|
743
743
|
|
|
744
|
-
// Inject
|
|
744
|
+
// Inject target IP into hook config — works for both machine and container deploys
|
|
745
745
|
if (machineId) {
|
|
746
746
|
const { getMachine } = await import('./machine-pool');
|
|
747
747
|
const deployMachine = await getMachine(machineId);
|
|
748
748
|
if (deployMachine) {
|
|
749
749
|
installConfigMap['ip.primary'] = deployMachine.ipAddress;
|
|
750
|
-
installConfigMap.
|
|
750
|
+
installConfigMap.target_ip = deployMachine.ipAddress;
|
|
751
751
|
}
|
|
752
752
|
}
|
|
753
753
|
|
|
@@ -31,7 +31,7 @@ interface TerraformState {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* Ensure module_configs has vmid and
|
|
34
|
+
* Ensure module_configs has vmid and target_ip from Terraform state
|
|
35
35
|
* This recovers from state drift scenarios where container was deleted/recreated
|
|
36
36
|
*
|
|
37
37
|
* @param moduleId - Module identifier
|
|
@@ -43,7 +43,7 @@ export async function ensureProxmoxConfigFromState(
|
|
|
43
43
|
terraformDir: string,
|
|
44
44
|
db: DbClient,
|
|
45
45
|
): Promise<void> {
|
|
46
|
-
// Check if vmid and
|
|
46
|
+
// Check if vmid and target_ip already exist
|
|
47
47
|
const existingVmid = await db
|
|
48
48
|
.select()
|
|
49
49
|
.from(moduleConfigs)
|
|
@@ -53,7 +53,7 @@ export async function ensureProxmoxConfigFromState(
|
|
|
53
53
|
const existingContainerIp = await db
|
|
54
54
|
.select()
|
|
55
55
|
.from(moduleConfigs)
|
|
56
|
-
.where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, '
|
|
56
|
+
.where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, 'target_ip')))
|
|
57
57
|
.get();
|
|
58
58
|
|
|
59
59
|
// If both exist, no recovery needed
|
|
@@ -104,7 +104,7 @@ export async function ensureProxmoxConfigFromState(
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
// Store in module_configs (recovery)
|
|
107
|
-
log.warn(' Recovering vmid and
|
|
107
|
+
log.warn(' Recovering vmid and target_ip from Terraform state...');
|
|
108
108
|
|
|
109
109
|
if (!existingVmid) {
|
|
110
110
|
await db
|
|
@@ -126,7 +126,7 @@ export async function ensureProxmoxConfigFromState(
|
|
|
126
126
|
.insert(moduleConfigs)
|
|
127
127
|
.values({
|
|
128
128
|
moduleId,
|
|
129
|
-
key: '
|
|
129
|
+
key: 'target_ip',
|
|
130
130
|
value: containerIp,
|
|
131
131
|
})
|
|
132
132
|
.onConflictDoUpdate({
|
|
@@ -136,5 +136,5 @@ export async function ensureProxmoxConfigFromState(
|
|
|
136
136
|
.run();
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
log.success(` Recovered: vmid=${vmid},
|
|
139
|
+
log.success(` Recovered: vmid=${vmid}, target_ip=${containerIp}`);
|
|
140
140
|
}
|
|
@@ -321,7 +321,7 @@ describe('Template Generator', () => {
|
|
|
321
321
|
|
|
322
322
|
db.insert(moduleConfigs)
|
|
323
323
|
.values([
|
|
324
|
-
{ moduleId: 'test-module', key: '
|
|
324
|
+
{ moduleId: 'test-module', key: 'target_ip', value: '192.168.0.50' },
|
|
325
325
|
{ moduleId: 'test-module', key: 'hostname', value: 'test' },
|
|
326
326
|
])
|
|
327
327
|
.run();
|
|
@@ -340,7 +340,7 @@ describe('Template Generator', () => {
|
|
|
340
340
|
resource "proxmox_lxc" "container" {
|
|
341
341
|
hostname = "$self:hostname"
|
|
342
342
|
network {
|
|
343
|
-
ip = "$self:
|
|
343
|
+
ip = "$self:target_ip/24"
|
|
344
344
|
gateway = "$system:management.ip"
|
|
345
345
|
}
|
|
346
346
|
}
|
|
@@ -621,7 +621,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
621
621
|
// biome-ignore lint/style/noNonNullAssertion: value is NOT NULL in schema, guaranteed to exist
|
|
622
622
|
context.selfConfig.vmid = String(vmidConfig.value!);
|
|
623
623
|
// biome-ignore lint/style/noNonNullAssertion: value is NOT NULL in schema, guaranteed to exist
|
|
624
|
-
context.selfConfig.
|
|
624
|
+
context.selfConfig.target_ip = ipConfig.value!;
|
|
625
625
|
}
|
|
626
626
|
|
|
627
627
|
// Add infrastructure properties to context (target_node, lxc_template, storage)
|
|
@@ -166,7 +166,7 @@ describe('Variable Context', () => {
|
|
|
166
166
|
|
|
167
167
|
db.insert(moduleConfigs)
|
|
168
168
|
.values([
|
|
169
|
-
{ moduleId: 'homebridge', key: '
|
|
169
|
+
{ moduleId: 'homebridge', key: 'target_ip', value: '192.168.0.50' },
|
|
170
170
|
{ moduleId: 'homebridge', key: 'hostname', value: 'homebridge' },
|
|
171
171
|
])
|
|
172
172
|
.run();
|
|
@@ -174,7 +174,7 @@ describe('Variable Context', () => {
|
|
|
174
174
|
const context = await buildResolutionContext('homebridge', db);
|
|
175
175
|
|
|
176
176
|
expect(context.moduleId).toBe('homebridge');
|
|
177
|
-
expect(context.selfConfig.
|
|
177
|
+
expect(context.selfConfig.target_ip).toBe('192.168.0.50');
|
|
178
178
|
expect(context.selfConfig.hostname).toBe('homebridge');
|
|
179
179
|
// Auto-derived variables
|
|
180
180
|
expect(context.selfConfig['inventory.ansible_host']).toBe('192.168.0.50');
|
|
@@ -262,7 +262,7 @@ describe('Variable Context', () => {
|
|
|
262
262
|
);
|
|
263
263
|
|
|
264
264
|
db.insert(moduleConfigs)
|
|
265
|
-
.values({ moduleId: 'caddy', key: '
|
|
265
|
+
.values({ moduleId: 'caddy', key: 'target_ip', value: '10.0.20.10' })
|
|
266
266
|
.run();
|
|
267
267
|
|
|
268
268
|
db.insert(secrets)
|
|
@@ -290,7 +290,7 @@ describe('Variable Context', () => {
|
|
|
290
290
|
|
|
291
291
|
const context = await buildResolutionContext('caddy', db);
|
|
292
292
|
|
|
293
|
-
expect(context.selfConfig.
|
|
293
|
+
expect(context.selfConfig.target_ip).toBe('10.0.20.10');
|
|
294
294
|
expect(context.secrets.ssl_cert).toBe('cert_data');
|
|
295
295
|
expect(context.capabilities.dns_external).toBeDefined();
|
|
296
296
|
expect(context.systemConfig['dns.primary']).toBe('192.168.0.1');
|
|
@@ -303,7 +303,7 @@ describe('Variable Context', () => {
|
|
|
303
303
|
// Should have auto-derived inventory variables
|
|
304
304
|
expect(context.selfConfig['inventory.ansible_user']).toBe('root');
|
|
305
305
|
expect(context.selfConfig['inventory.groups']).toBe('empty-module');
|
|
306
|
-
// Should not have ansible_host (no
|
|
306
|
+
// Should not have ansible_host (no target_ip)
|
|
307
307
|
expect(context.selfConfig['inventory.ansible_host']).toBeUndefined();
|
|
308
308
|
expect(context.secrets).toEqual({});
|
|
309
309
|
expect(context.capabilities).toEqual({});
|
|
@@ -367,18 +367,18 @@ describe('Variable Context', () => {
|
|
|
367
367
|
expect(context.selfConfig['requires.machine.memory']).toBeUndefined();
|
|
368
368
|
});
|
|
369
369
|
|
|
370
|
-
test('should auto-derive inventory variables from
|
|
370
|
+
test('should auto-derive inventory variables from target_ip', async () => {
|
|
371
371
|
db.$client.run(
|
|
372
372
|
`INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test', '1.0.0', '/path', '{}')`,
|
|
373
373
|
);
|
|
374
374
|
|
|
375
375
|
db.insert(moduleConfigs)
|
|
376
|
-
.values({ moduleId: 'test-module', key: '
|
|
376
|
+
.values({ moduleId: 'test-module', key: 'target_ip', value: '10.0.10.10/24' })
|
|
377
377
|
.run();
|
|
378
378
|
|
|
379
379
|
const context = await buildResolutionContext('test-module', db);
|
|
380
380
|
|
|
381
|
-
// Should strip CIDR from
|
|
381
|
+
// Should strip CIDR from target_ip
|
|
382
382
|
expect(context.selfConfig['inventory.ansible_host']).toBe('10.0.10.10');
|
|
383
383
|
expect(context.selfConfig['inventory.ansible_user']).toBe('root');
|
|
384
384
|
expect(context.selfConfig['inventory.groups']).toBe('test-module');
|
|
@@ -391,7 +391,7 @@ describe('Variable Context', () => {
|
|
|
391
391
|
|
|
392
392
|
db.insert(moduleConfigs)
|
|
393
393
|
.values([
|
|
394
|
-
{ moduleId: 'custom', key: '
|
|
394
|
+
{ moduleId: 'custom', key: 'target_ip', value: '10.0.20.10' },
|
|
395
395
|
{ moduleId: 'custom', key: 'inventory.ansible_user', value: 'admin' },
|
|
396
396
|
])
|
|
397
397
|
.run();
|
|
@@ -403,13 +403,13 @@ describe('Variable Context', () => {
|
|
|
403
403
|
expect(context.selfConfig['inventory.ansible_host']).toBe('10.0.20.10');
|
|
404
404
|
});
|
|
405
405
|
|
|
406
|
-
test('should handle
|
|
406
|
+
test('should handle target_ip without CIDR', async () => {
|
|
407
407
|
db.$client.run(
|
|
408
408
|
`INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('no-cidr', 'No CIDR', '1.0.0', '/path', '{}')`,
|
|
409
409
|
);
|
|
410
410
|
|
|
411
411
|
db.insert(moduleConfigs)
|
|
412
|
-
.values({ moduleId: 'no-cidr', key: '
|
|
412
|
+
.values({ moduleId: 'no-cidr', key: 'target_ip', value: '192.168.1.100' })
|
|
413
413
|
.run();
|
|
414
414
|
|
|
415
415
|
const context = await buildResolutionContext('no-cidr', db);
|
|
@@ -418,8 +418,8 @@ describe('Variable Context', () => {
|
|
|
418
418
|
expect(context.selfConfig['inventory.ansible_host']).toBe('192.168.1.100');
|
|
419
419
|
});
|
|
420
420
|
|
|
421
|
-
test('should auto-allocate IPAM resources when module declares vmid and
|
|
422
|
-
// Create module with manifest that declares vmid and
|
|
421
|
+
test('should auto-allocate IPAM resources when module declares vmid and target_ip', async () => {
|
|
422
|
+
// Create module with manifest that declares vmid and target_ip
|
|
423
423
|
db.insert(modules)
|
|
424
424
|
.values({
|
|
425
425
|
id: 'auto-module',
|
|
@@ -433,7 +433,7 @@ describe('Variable Context', () => {
|
|
|
433
433
|
variables: {
|
|
434
434
|
owns: [
|
|
435
435
|
{ name: 'vmid', type: 'integer', required: true, source: 'user' },
|
|
436
|
-
{ name: '
|
|
436
|
+
{ name: 'target_ip', type: 'string', required: true, source: 'user' },
|
|
437
437
|
],
|
|
438
438
|
},
|
|
439
439
|
requires: {
|
|
@@ -447,9 +447,9 @@ describe('Variable Context', () => {
|
|
|
447
447
|
|
|
448
448
|
const context = await buildResolutionContext('auto-module', db);
|
|
449
449
|
|
|
450
|
-
// Should have auto-allocated vmid and
|
|
450
|
+
// Should have auto-allocated vmid and target_ip
|
|
451
451
|
expect(context.selfConfig.vmid).toBe('2100');
|
|
452
|
-
expect(context.selfConfig.
|
|
452
|
+
expect(context.selfConfig.target_ip).toBe('10.0.10.10/24');
|
|
453
453
|
|
|
454
454
|
// Verify allocation persisted to database
|
|
455
455
|
const allocations = await db.select().from(ipAllocations).all();
|
|
@@ -465,7 +465,7 @@ describe('Variable Context', () => {
|
|
|
465
465
|
.where(eq(moduleConfigs.moduleId, 'auto-module'))
|
|
466
466
|
.all();
|
|
467
467
|
const vmidConfig = configs.find((c) => c.key === 'vmid');
|
|
468
|
-
const ipConfig = configs.find((c) => c.key === '
|
|
468
|
+
const ipConfig = configs.find((c) => c.key === 'target_ip');
|
|
469
469
|
expect(vmidConfig?.value).toBe('2100');
|
|
470
470
|
expect(ipConfig?.value).toBe('10.0.10.10/24');
|
|
471
471
|
});
|
|
@@ -485,7 +485,7 @@ describe('Variable Context', () => {
|
|
|
485
485
|
variables: {
|
|
486
486
|
owns: [
|
|
487
487
|
{ name: 'vmid', type: 'integer', required: true, source: 'user' },
|
|
488
|
-
{ name: '
|
|
488
|
+
{ name: 'target_ip', type: 'string', required: true, source: 'user' },
|
|
489
489
|
],
|
|
490
490
|
},
|
|
491
491
|
},
|
|
@@ -506,14 +506,14 @@ describe('Variable Context', () => {
|
|
|
506
506
|
|
|
507
507
|
// Should reuse existing allocation
|
|
508
508
|
expect(context.selfConfig.vmid).toBe('2150');
|
|
509
|
-
expect(context.selfConfig.
|
|
509
|
+
expect(context.selfConfig.target_ip).toBe('10.0.10.50/24');
|
|
510
510
|
|
|
511
511
|
// Should not create duplicate allocation
|
|
512
512
|
const allocations = await db.select().from(ipAllocations).all();
|
|
513
513
|
expect(allocations).toHaveLength(1);
|
|
514
514
|
});
|
|
515
515
|
|
|
516
|
-
test('should skip IPAM allocation if vmid and
|
|
516
|
+
test('should skip IPAM allocation if vmid and target_ip already configured', async () => {
|
|
517
517
|
// Create module with existing config
|
|
518
518
|
db.insert(modules)
|
|
519
519
|
.values({
|
|
@@ -528,7 +528,7 @@ describe('Variable Context', () => {
|
|
|
528
528
|
variables: {
|
|
529
529
|
owns: [
|
|
530
530
|
{ name: 'vmid', type: 'integer', required: true, source: 'user' },
|
|
531
|
-
{ name: '
|
|
531
|
+
{ name: 'target_ip', type: 'string', required: true, source: 'user' },
|
|
532
532
|
],
|
|
533
533
|
},
|
|
534
534
|
},
|
|
@@ -538,7 +538,7 @@ describe('Variable Context', () => {
|
|
|
538
538
|
db.insert(moduleConfigs)
|
|
539
539
|
.values([
|
|
540
540
|
{ moduleId: 'manual-config', key: 'vmid', value: '9999' },
|
|
541
|
-
{ moduleId: 'manual-config', key: '
|
|
541
|
+
{ moduleId: 'manual-config', key: 'target_ip', value: '192.168.99.99/24' },
|
|
542
542
|
])
|
|
543
543
|
.run();
|
|
544
544
|
|
|
@@ -546,15 +546,15 @@ describe('Variable Context', () => {
|
|
|
546
546
|
|
|
547
547
|
// Should use existing config
|
|
548
548
|
expect(context.selfConfig.vmid).toBe('9999');
|
|
549
|
-
expect(context.selfConfig.
|
|
549
|
+
expect(context.selfConfig.target_ip).toBe('192.168.99.99/24');
|
|
550
550
|
|
|
551
551
|
// Should not create allocation
|
|
552
552
|
const allocations = await db.select().from(ipAllocations).all();
|
|
553
553
|
expect(allocations).toHaveLength(0);
|
|
554
554
|
});
|
|
555
555
|
|
|
556
|
-
test('should skip IPAM allocation for VPS modules without
|
|
557
|
-
// Create VPS module (no
|
|
556
|
+
test('should skip IPAM allocation for VPS modules without target_ip', async () => {
|
|
557
|
+
// Create VPS module (no target_ip variable)
|
|
558
558
|
db.insert(modules)
|
|
559
559
|
.values({
|
|
560
560
|
id: 'vps-module',
|
|
@@ -576,7 +576,7 @@ describe('Variable Context', () => {
|
|
|
576
576
|
|
|
577
577
|
// Should not allocate IPAM resources
|
|
578
578
|
expect(context.selfConfig.vmid).toBeUndefined();
|
|
579
|
-
expect(context.selfConfig.
|
|
579
|
+
expect(context.selfConfig.target_ip).toBeUndefined();
|
|
580
580
|
|
|
581
581
|
const allocations = await db.select().from(ipAllocations).all();
|
|
582
582
|
expect(allocations).toHaveLength(0);
|
|
@@ -597,7 +597,7 @@ describe('Variable Context', () => {
|
|
|
597
597
|
variables: {
|
|
598
598
|
owns: [
|
|
599
599
|
{ name: 'vmid', type: 'integer', required: true, source: 'user' },
|
|
600
|
-
{ name: '
|
|
600
|
+
{ name: 'target_ip', type: 'string', required: true, source: 'user' },
|
|
601
601
|
],
|
|
602
602
|
},
|
|
603
603
|
},
|
|
@@ -618,7 +618,7 @@ describe('Variable Context', () => {
|
|
|
618
618
|
variables: {
|
|
619
619
|
owns: [
|
|
620
620
|
{ name: 'vmid', type: 'integer', required: true, source: 'user' },
|
|
621
|
-
{ name: '
|
|
621
|
+
{ name: 'target_ip', type: 'string', required: true, source: 'user' },
|
|
622
622
|
],
|
|
623
623
|
},
|
|
624
624
|
},
|
|
@@ -634,8 +634,8 @@ describe('Variable Context', () => {
|
|
|
634
634
|
expect(context2.selfConfig.vmid).toBe('2101');
|
|
635
635
|
|
|
636
636
|
// Should have sequential IPs
|
|
637
|
-
expect(context1.selfConfig.
|
|
638
|
-
expect(context2.selfConfig.
|
|
637
|
+
expect(context1.selfConfig.target_ip).toBe('10.0.10.10/24');
|
|
638
|
+
expect(context2.selfConfig.target_ip).toBe('10.0.10.11/24');
|
|
639
639
|
|
|
640
640
|
const allocations = await db.select().from(ipAllocations).all();
|
|
641
641
|
expect(allocations).toHaveLength(2);
|
|
@@ -1254,7 +1254,7 @@ describe('Variable Context', () => {
|
|
|
1254
1254
|
|
|
1255
1255
|
test('should auto-derive inventory variables in buildContextFromData', () => {
|
|
1256
1256
|
const context = buildContextFromData('my-module', {
|
|
1257
|
-
selfConfig: {
|
|
1257
|
+
selfConfig: { target_ip: '10.0.30.15/24', hostname: 'my-host' },
|
|
1258
1258
|
});
|
|
1259
1259
|
|
|
1260
1260
|
expect(context.selfConfig['inventory.ansible_host']).toBe('10.0.30.15');
|
package/src/variables/context.ts
CHANGED
|
@@ -92,7 +92,7 @@ async function autoAssignFromWellKnown(
|
|
|
92
92
|
* Policy function - checks manifest and config
|
|
93
93
|
*
|
|
94
94
|
* A module needs IPAM if:
|
|
95
|
-
* 1. It declares vmid and
|
|
95
|
+
* 1. It declares vmid and target_ip variables (container-based), AND
|
|
96
96
|
* 2. These values are not already set in module config
|
|
97
97
|
*
|
|
98
98
|
* @param manifest - Module manifest
|
|
@@ -106,16 +106,16 @@ function needsIpamAllocation(
|
|
|
106
106
|
const variables = manifest.variables?.owns ?? [];
|
|
107
107
|
|
|
108
108
|
const hasVmid = variables.some((v) => v.name === 'vmid');
|
|
109
|
-
const
|
|
109
|
+
const hasTargetIp = variables.some((v) => v.name === 'target_ip');
|
|
110
110
|
|
|
111
|
-
// Module must declare both vmid and
|
|
112
|
-
if (!hasVmid || !
|
|
111
|
+
// Module must declare both vmid and target_ip to be container-based
|
|
112
|
+
if (!hasVmid || !hasTargetIp) {
|
|
113
113
|
return false;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
// Check if already allocated (both must be present)
|
|
117
117
|
const vmidSet = selfConfig.vmid !== undefined && selfConfig.vmid !== '';
|
|
118
|
-
const ipSet = selfConfig.
|
|
118
|
+
const ipSet = selfConfig.target_ip !== undefined && selfConfig.target_ip !== '';
|
|
119
119
|
|
|
120
120
|
// Need allocation if either is missing
|
|
121
121
|
return !vmidSet || !ipSet;
|
|
@@ -162,7 +162,7 @@ function determineModuleZone(
|
|
|
162
162
|
*
|
|
163
163
|
* These variables are automatically available in Ansible templates:
|
|
164
164
|
* - inventory.hostname: Derived from hostname variable
|
|
165
|
-
* - inventory.ansible_host: Derived from
|
|
165
|
+
* - inventory.ansible_host: Derived from target_ip (strips CIDR) or vps_ip
|
|
166
166
|
* - inventory.ansible_user: Defaults to "root"
|
|
167
167
|
* - inventory.groups: Derived from module ID
|
|
168
168
|
*
|
|
@@ -181,9 +181,9 @@ function autoDeriveInventoryVariables(
|
|
|
181
181
|
derived['inventory.hostname'] = selfConfig.hostname;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
// Auto-derive ansible_host from
|
|
185
|
-
if (selfConfig.
|
|
186
|
-
derived['inventory.ansible_host'] = stripCidr(selfConfig.
|
|
184
|
+
// Auto-derive ansible_host from target_ip (strips CIDR) or vps_ip
|
|
185
|
+
if (selfConfig.target_ip) {
|
|
186
|
+
derived['inventory.ansible_host'] = stripCidr(selfConfig.target_ip);
|
|
187
187
|
} else if (selfConfig.vps_ip) {
|
|
188
188
|
// VPS-based modules use vps_ip directly (no CIDR to strip)
|
|
189
189
|
derived['inventory.ansible_host'] = selfConfig.vps_ip;
|
|
@@ -329,7 +329,7 @@ export async function buildResolutionContext(
|
|
|
329
329
|
}
|
|
330
330
|
|
|
331
331
|
// IPAM auto-allocation
|
|
332
|
-
// If module declares vmid/
|
|
332
|
+
// If module declares vmid/target_ip but they're not configured, allocate automatically
|
|
333
333
|
if (module?.manifestData) {
|
|
334
334
|
const manifest = module.manifestData as ModuleManifest;
|
|
335
335
|
|
|
@@ -342,17 +342,17 @@ export async function buildResolutionContext(
|
|
|
342
342
|
const existing = await getAllocation(moduleId, tx);
|
|
343
343
|
|
|
344
344
|
let vmid: number;
|
|
345
|
-
let
|
|
345
|
+
let allocatedIp: string;
|
|
346
346
|
|
|
347
347
|
if (existing) {
|
|
348
348
|
// Use existing allocation
|
|
349
349
|
vmid = existing.vmid;
|
|
350
|
-
|
|
350
|
+
allocatedIp = existing.containerIp;
|
|
351
351
|
} else {
|
|
352
352
|
// Allocate new resources (persists to ipAllocations)
|
|
353
353
|
const allocation = await allocateResources(moduleId, zone, tx);
|
|
354
354
|
vmid = allocation.vmid;
|
|
355
|
-
|
|
355
|
+
allocatedIp = allocation.containerIp;
|
|
356
356
|
}
|
|
357
357
|
|
|
358
358
|
// Ensure values are in module config (upsert to handle existing keys)
|
|
@@ -366,15 +366,15 @@ export async function buildResolutionContext(
|
|
|
366
366
|
|
|
367
367
|
await tx
|
|
368
368
|
.insert(moduleConfigs)
|
|
369
|
-
.values({ moduleId, key: '
|
|
369
|
+
.values({ moduleId, key: 'target_ip', value: allocatedIp })
|
|
370
370
|
.onConflictDoUpdate({
|
|
371
371
|
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
372
|
-
set: { value:
|
|
372
|
+
set: { value: allocatedIp },
|
|
373
373
|
});
|
|
374
374
|
|
|
375
375
|
// Update selfConfig with allocated values
|
|
376
376
|
selfConfig.vmid = String(vmid);
|
|
377
|
-
selfConfig.
|
|
377
|
+
selfConfig.target_ip = allocatedIp;
|
|
378
378
|
});
|
|
379
379
|
}
|
|
380
380
|
}
|
|
@@ -3,14 +3,14 @@ import { hasVariables, isValidVariableFormat, parseVariables } from './parser';
|
|
|
3
3
|
|
|
4
4
|
describe('parseVariables', () => {
|
|
5
5
|
test('should parse self variables', () => {
|
|
6
|
-
const content = 'ip: $self:
|
|
6
|
+
const content = 'ip: $self:target_ip';
|
|
7
7
|
const variables = parseVariables(content);
|
|
8
8
|
|
|
9
9
|
expect(variables).toHaveLength(1);
|
|
10
10
|
expect(variables[0]).toEqual({
|
|
11
11
|
type: 'self',
|
|
12
|
-
path: '
|
|
13
|
-
raw: '$self:
|
|
12
|
+
path: 'target_ip',
|
|
13
|
+
raw: '$self:target_ip',
|
|
14
14
|
});
|
|
15
15
|
});
|
|
16
16
|
|
|
@@ -52,7 +52,7 @@ describe('parseVariables', () => {
|
|
|
52
52
|
|
|
53
53
|
test('should parse multiple variables in same content', () => {
|
|
54
54
|
const content = `
|
|
55
|
-
ip: $self:
|
|
55
|
+
ip: $self:target_ip
|
|
56
56
|
dns: $capability:dns_external.nameserver
|
|
57
57
|
key: $secret:api_key
|
|
58
58
|
`;
|
|
@@ -101,7 +101,7 @@ resource "proxmox_lxc" "homebridge" {
|
|
|
101
101
|
cores = $self:cores
|
|
102
102
|
memory = $self:memory
|
|
103
103
|
network {
|
|
104
|
-
ip = "$self:
|
|
104
|
+
ip = "$self:target_ip/24"
|
|
105
105
|
gw = "$system:management.ip"
|
|
106
106
|
}
|
|
107
107
|
}
|
|
@@ -189,7 +189,7 @@ resource "proxmox_lxc" "homebridge" {
|
|
|
189
189
|
|
|
190
190
|
describe('hasVariables', () => {
|
|
191
191
|
test('should return true for content with variables', () => {
|
|
192
|
-
expect(hasVariables('ip: $self:
|
|
192
|
+
expect(hasVariables('ip: $self:target_ip')).toBe(true);
|
|
193
193
|
expect(hasVariables('$secret:key')).toBe(true);
|
|
194
194
|
});
|
|
195
195
|
|
|
@@ -206,7 +206,7 @@ describe('hasVariables', () => {
|
|
|
206
206
|
|
|
207
207
|
describe('isValidVariableFormat', () => {
|
|
208
208
|
test('should validate correct variable formats', () => {
|
|
209
|
-
expect(isValidVariableFormat('$self:
|
|
209
|
+
expect(isValidVariableFormat('$self:target_ip')).toBe(true);
|
|
210
210
|
expect(isValidVariableFormat('$system:management.ip')).toBe(true);
|
|
211
211
|
expect(isValidVariableFormat('$secret:api_key')).toBe(true);
|
|
212
212
|
expect(isValidVariableFormat('$capability:dns_external.nameserver')).toBe(true);
|
|
@@ -219,7 +219,7 @@ describe('isValidVariableFormat', () => {
|
|
|
219
219
|
});
|
|
220
220
|
|
|
221
221
|
test('should reject invalid variable formats', () => {
|
|
222
|
-
expect(isValidVariableFormat('self:
|
|
222
|
+
expect(isValidVariableFormat('self:target_ip')).toBe(false); // missing $
|
|
223
223
|
expect(isValidVariableFormat('$unknown:value')).toBe(false); // invalid type
|
|
224
224
|
expect(isValidVariableFormat('$self:')).toBe(false); // missing path
|
|
225
225
|
expect(isValidVariableFormat('$self')).toBe(false); // missing colon and path
|
|
@@ -29,7 +29,7 @@ const mockDb = {
|
|
|
29
29
|
const createContext = (overrides?: Partial<ResolutionContext>): ResolutionContext => ({
|
|
30
30
|
moduleId: 'test-module',
|
|
31
31
|
selfConfig: {
|
|
32
|
-
|
|
32
|
+
target_ip: '192.168.0.50',
|
|
33
33
|
hostname: 'homebridge',
|
|
34
34
|
cores: '2',
|
|
35
35
|
'resources.machine.cpu': '2',
|
|
@@ -69,8 +69,8 @@ describe('resolveVariable', () => {
|
|
|
69
69
|
test('should resolve self variable', async () => {
|
|
70
70
|
const variable: VariableReference = {
|
|
71
71
|
type: 'self',
|
|
72
|
-
path: '
|
|
73
|
-
raw: '$self:
|
|
72
|
+
path: 'target_ip',
|
|
73
|
+
raw: '$self:target_ip',
|
|
74
74
|
};
|
|
75
75
|
const context = createContext();
|
|
76
76
|
|
|
@@ -245,7 +245,7 @@ describe('resolveVariable', () => {
|
|
|
245
245
|
|
|
246
246
|
describe('resolveTemplate', () => {
|
|
247
247
|
test('should resolve template with single variable', async () => {
|
|
248
|
-
const template = 'ip: $self:
|
|
248
|
+
const template = 'ip: $self:target_ip';
|
|
249
249
|
const context = createContext();
|
|
250
250
|
|
|
251
251
|
const result = await resolveTemplate(template, context, mockDb);
|
|
@@ -259,7 +259,7 @@ describe('resolveTemplate', () => {
|
|
|
259
259
|
test('should resolve template with multiple variables', async () => {
|
|
260
260
|
const template = `
|
|
261
261
|
hostname: $self:hostname
|
|
262
|
-
ip: $self:
|
|
262
|
+
ip: $self:target_ip
|
|
263
263
|
domain: $system:network.domain
|
|
264
264
|
`;
|
|
265
265
|
const context = createContext();
|
|
@@ -276,7 +276,7 @@ domain: $system:network.domain
|
|
|
276
276
|
|
|
277
277
|
test('should resolve template with mixed variable types', async () => {
|
|
278
278
|
const template = `
|
|
279
|
-
|
|
279
|
+
target_ip: $self:target_ip
|
|
280
280
|
management_ip: $system:management.ip
|
|
281
281
|
dns_server: $capability:dns_external.nameserver
|
|
282
282
|
api_key: $secret:api_key
|
|
@@ -287,7 +287,7 @@ api_key: $secret:api_key
|
|
|
287
287
|
|
|
288
288
|
expect(result.success).toBe(true);
|
|
289
289
|
if (result.success) {
|
|
290
|
-
expect(result.content).toContain('
|
|
290
|
+
expect(result.content).toContain('target_ip: 192.168.0.50');
|
|
291
291
|
expect(result.content).toContain('management_ip: 192.168.0.10');
|
|
292
292
|
expect(result.content).toContain('dns_server: ns1.example.com');
|
|
293
293
|
expect(result.content).toContain('api_key: secret123');
|
|
@@ -324,7 +324,7 @@ resource "proxmox_lxc" "container" {
|
|
|
324
324
|
hostname = "$self:hostname"
|
|
325
325
|
cores = $self:cores
|
|
326
326
|
network {
|
|
327
|
-
ip = "$self:
|
|
327
|
+
ip = "$self:target_ip/24"
|
|
328
328
|
gateway = "$system:management.ip"
|
|
329
329
|
}
|
|
330
330
|
environment = {
|
|
@@ -362,7 +362,7 @@ resource "proxmox_lxc" "container" {
|
|
|
362
362
|
|
|
363
363
|
test('should return errors for missing variables', async () => {
|
|
364
364
|
const template = `
|
|
365
|
-
ip: $self:
|
|
365
|
+
ip: $self:target_ip
|
|
366
366
|
missing: $self:missing_var
|
|
367
367
|
another_missing: $secret:missing_secret
|
|
368
368
|
`;
|
|
@@ -380,9 +380,9 @@ another_missing: $secret:missing_secret
|
|
|
380
380
|
|
|
381
381
|
test('should handle repeated variables', async () => {
|
|
382
382
|
const template = `
|
|
383
|
-
ip1: $self:
|
|
384
|
-
ip2: $self:
|
|
385
|
-
ip3: $self:
|
|
383
|
+
ip1: $self:target_ip
|
|
384
|
+
ip2: $self:target_ip
|
|
385
|
+
ip3: $self:target_ip
|
|
386
386
|
`;
|
|
387
387
|
const context = createContext();
|
|
388
388
|
|
|
@@ -393,7 +393,7 @@ ip3: $self:container_ip
|
|
|
393
393
|
expect(result.content).toContain('ip1: 192.168.0.50');
|
|
394
394
|
expect(result.content).toContain('ip2: 192.168.0.50');
|
|
395
395
|
expect(result.content).toContain('ip3: 192.168.0.50');
|
|
396
|
-
expect(result.content).not.toContain('$self:
|
|
396
|
+
expect(result.content).not.toContain('$self:target_ip');
|
|
397
397
|
}
|
|
398
398
|
});
|
|
399
399
|
|
|
@@ -420,7 +420,7 @@ $self:hostname
|
|
|
420
420
|
const template = 'ansible_host: $self:inventory.ansible_host';
|
|
421
421
|
const context = createContext({
|
|
422
422
|
selfConfig: {
|
|
423
|
-
|
|
423
|
+
target_ip: '10.0.10.10/24',
|
|
424
424
|
'inventory.ansible_host': '10.0.10.10', // Auto-derived (CIDR stripped)
|
|
425
425
|
'inventory.ansible_user': 'root',
|
|
426
426
|
'inventory.groups': 'test',
|
package/src/variables/types.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Variable reference parsed from template
|
|
7
|
-
* Example: $self:
|
|
7
|
+
* Example: $self:target_ip -> { type: 'self', path: 'target_ip', raw: '$self:target_ip' }
|
|
8
8
|
*/
|
|
9
9
|
export interface VariableReference {
|
|
10
10
|
type: 'self' | 'system' | 'system_secret' | 'secret' | 'capability';
|