@celilo/cli 0.5.0-alpha.5 → 0.5.0-alpha.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/package.json +3 -3
- package/src/ansible/inventory.test.ts +10 -10
- package/src/ansible/validation.test.ts +25 -15
- package/src/cli/commands/events.test.ts +4 -4
- package/src/cli/commands/events.ts +2 -2
- package/src/cli/commands/service-add-proxmox.ts +9 -0
- package/src/cli/index.ts +2 -2
- package/src/config/paths.test.ts +61 -48
- package/src/hooks/capability-loader-firewall.test.ts +3 -3
- package/src/infrastructure/property-extractor.test.ts +15 -0
- package/src/infrastructure/property-extractor.ts +12 -0
- package/src/manifest/schema.ts +7 -0
- package/src/manifest/validate.test.ts +53 -0
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +2 -2
- package/src/services/celilo-mgmt-hooks.test.ts +3 -2
- package/src/services/deploy-validation.test.ts +2 -2
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-registrations.test.ts +10 -10
- package/src/services/module-build.test.ts +43 -38
- package/src/templates/generator.test.ts +62 -12
- package/src/templates/generator.ts +48 -50
- package/src/test-utils/fixtures.test.ts +1 -1
- package/src/test-utils/integration-guard.ts +33 -0
- package/src/types/infrastructure.ts +6 -0
- package/src/variables/computed/computed-integration.test.ts +3 -3
- package/src/variables/computed/computed.test.ts +5 -5
- package/src/variables/declarative-derivation.test.ts +6 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.5.0-alpha.
|
|
3
|
+
"version": "0.5.0-alpha.6",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -52,9 +52,9 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@aws-sdk/client-s3": "^3.1024.0",
|
|
55
|
-
"@celilo/capabilities": "^0.4.
|
|
55
|
+
"@celilo/capabilities": "^0.4.2",
|
|
56
56
|
"@celilo/cli-display": "^0.1.9",
|
|
57
|
-
"@celilo/event-bus": "^0.1.
|
|
57
|
+
"@celilo/event-bus": "^0.1.7",
|
|
58
58
|
"@clack/prompts": "^1.1.0",
|
|
59
59
|
"ajv": "^8.18.0",
|
|
60
60
|
"drizzle-orm": "^0.36.4",
|
|
@@ -61,7 +61,7 @@ describe('generateHostsIni', () => {
|
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
hostname: 'dns-ext',
|
|
64
|
-
ansibleHost: '
|
|
64
|
+
ansibleHost: '192.0.2.20',
|
|
65
65
|
ansibleUser: 'root',
|
|
66
66
|
groups: ['dns_external'],
|
|
67
67
|
},
|
|
@@ -72,7 +72,7 @@ describe('generateHostsIni', () => {
|
|
|
72
72
|
expect(result).toContain('[homebridge]');
|
|
73
73
|
expect(result).toContain('[dns_external]');
|
|
74
74
|
expect(result).toContain('iot ansible_host=192.168.0.110 ansible_user=root');
|
|
75
|
-
expect(result).toContain('dns-ext ansible_host=
|
|
75
|
+
expect(result).toContain('dns-ext ansible_host=192.0.2.20 ansible_user=root');
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
test('includes SSH private key file path when provided', () => {
|
|
@@ -136,8 +136,8 @@ describe('generateHostVarsYaml', () => {
|
|
|
136
136
|
test('generates YAML for arrays', () => {
|
|
137
137
|
const vars = {
|
|
138
138
|
zone_records: [
|
|
139
|
-
{ name: 'ns1', type: 'A', value: '
|
|
140
|
-
{ name: 'www', type: 'A', value: '
|
|
139
|
+
{ name: 'ns1', type: 'A', value: '192.0.2.20' },
|
|
140
|
+
{ name: 'www', type: 'A', value: '203.0.113.10' },
|
|
141
141
|
],
|
|
142
142
|
};
|
|
143
143
|
|
|
@@ -146,9 +146,9 @@ describe('generateHostVarsYaml', () => {
|
|
|
146
146
|
expect(result).toContain('zone_records:');
|
|
147
147
|
expect(result).toContain('- name: ns1');
|
|
148
148
|
expect(result).toContain('type: A');
|
|
149
|
-
expect(result).toContain('value:
|
|
149
|
+
expect(result).toContain('value: 192.0.2.20');
|
|
150
150
|
expect(result).toContain('- name: www');
|
|
151
|
-
expect(result).toContain('value:
|
|
151
|
+
expect(result).toContain('value: 203.0.113.10');
|
|
152
152
|
});
|
|
153
153
|
|
|
154
154
|
test('generates YAML for nested objects', () => {
|
|
@@ -359,13 +359,13 @@ describe('Database integration', () => {
|
|
|
359
359
|
);
|
|
360
360
|
|
|
361
361
|
upsertModuleConfig(db, 'dns', 'zone_records', [
|
|
362
|
-
{ name: 'ns1', type: 'A', value: '
|
|
362
|
+
{ name: 'ns1', type: 'A', value: '192.0.2.20' },
|
|
363
363
|
]);
|
|
364
364
|
|
|
365
365
|
const vars = buildHostVars('dns', db);
|
|
366
366
|
|
|
367
367
|
expect(Array.isArray(vars.zone_records)).toBe(true);
|
|
368
|
-
expect(vars.zone_records).toEqual([{ name: 'ns1', type: 'A', value: '
|
|
368
|
+
expect(vars.zone_records).toEqual([{ name: 'ns1', type: 'A', value: '192.0.2.20' }]);
|
|
369
369
|
});
|
|
370
370
|
});
|
|
371
371
|
|
|
@@ -416,13 +416,13 @@ describe('Database integration', () => {
|
|
|
416
416
|
);
|
|
417
417
|
|
|
418
418
|
upsertModuleConfig(db, 'dns-external', 'hostname', 'dns-ext');
|
|
419
|
-
upsertModuleConfig(db, 'dns-external', 'vps_ip', '
|
|
419
|
+
upsertModuleConfig(db, 'dns-external', 'vps_ip', '192.0.2.20');
|
|
420
420
|
|
|
421
421
|
const host = extractInventoryHost('dns-external', db);
|
|
422
422
|
|
|
423
423
|
expect(host).not.toBeNull();
|
|
424
424
|
expect(host?.hostname).toBe('dns-ext');
|
|
425
|
-
expect(host?.ansibleHost).toBe('
|
|
425
|
+
expect(host?.ansibleHost).toBe('192.0.2.20'); // VPS IP used directly
|
|
426
426
|
expect(host?.ansibleUser).toBe('root');
|
|
427
427
|
expect(host?.groups).toEqual(['dns-external']);
|
|
428
428
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { skipIntegration } from '../test-utils/integration-guard';
|
|
2
3
|
import {
|
|
3
4
|
isAnsibleInventoryAvailable,
|
|
4
5
|
isAnsibleLintAvailable,
|
|
@@ -54,17 +55,23 @@ describe('validateWithAnsibleLint', () => {
|
|
|
54
55
|
});
|
|
55
56
|
|
|
56
57
|
describe('validatePlaybookSyntax', () => {
|
|
57
|
-
test(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
test.skipIf(skipIntegration({ tools: ['ansible'] }))(
|
|
59
|
+
'returns error when playbook does not exist',
|
|
60
|
+
async () => {
|
|
61
|
+
const result = await validatePlaybookSyntax('/nonexistent/playbook.yml', '/tmp');
|
|
62
|
+
expect(result.success).toBe(false);
|
|
63
|
+
expect(result.error).toContain('Playbook not found');
|
|
64
|
+
},
|
|
65
|
+
);
|
|
62
66
|
|
|
63
|
-
test(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
test.skipIf(skipIntegration({ tools: ['ansible'] }))(
|
|
68
|
+
'returns error when inventory does not exist',
|
|
69
|
+
async () => {
|
|
70
|
+
const result = await validatePlaybookSyntax('/tmp/playbook.yml', '/nonexistent/inventory');
|
|
71
|
+
expect(result.success).toBe(false);
|
|
72
|
+
expect(result.error).toContain('not found');
|
|
73
|
+
},
|
|
74
|
+
);
|
|
68
75
|
|
|
69
76
|
test('returns error when ansible-playbook not installed', async () => {
|
|
70
77
|
if (!isAnsiblePlaybookAvailable()) {
|
|
@@ -76,11 +83,14 @@ describe('validatePlaybookSyntax', () => {
|
|
|
76
83
|
});
|
|
77
84
|
|
|
78
85
|
describe('validateInventory', () => {
|
|
79
|
-
test(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
test.skipIf(skipIntegration({ tools: ['ansible'] }))(
|
|
87
|
+
'returns error when inventory does not exist',
|
|
88
|
+
async () => {
|
|
89
|
+
const result = await validateInventory('/nonexistent/inventory');
|
|
90
|
+
expect(result.success).toBe(false);
|
|
91
|
+
expect(result.error).toContain('Inventory not found');
|
|
92
|
+
},
|
|
93
|
+
);
|
|
84
94
|
|
|
85
95
|
test('returns error when ansible-inventory not installed', async () => {
|
|
86
96
|
if (!isAnsibleInventoryAvailable()) {
|
|
@@ -165,20 +165,20 @@ describe('celilo events command handlers', () => {
|
|
|
165
165
|
});
|
|
166
166
|
setupBus.close();
|
|
167
167
|
|
|
168
|
-
const res = await handleEventsReply([String(query.id), '"
|
|
168
|
+
const res = await handleEventsReply([String(query.id), '"example.net"'], {});
|
|
169
169
|
expect(res.success).toBe(true);
|
|
170
170
|
if (!res.success) throw new Error('expected success');
|
|
171
171
|
const data = res.data as { status: string; family: string; value: unknown };
|
|
172
172
|
expect(data.status).toBe('replied');
|
|
173
173
|
expect(data.family).toBe('config');
|
|
174
|
-
expect(data.value).toBe('
|
|
174
|
+
expect(data.value).toBe('example.net');
|
|
175
175
|
|
|
176
176
|
const checkBus = openBus({ dbPath, events: defineEvents({}) });
|
|
177
177
|
const replies = checkBus.recentEvents({ type: 'config.required.lunacycle.domain.reply' });
|
|
178
178
|
checkBus.close();
|
|
179
179
|
expect(replies).toHaveLength(1);
|
|
180
180
|
expect(replies[0].replyFor).toBe(query.id);
|
|
181
|
-
expect(replies[0].payload).toEqual({ value: '
|
|
181
|
+
expect(replies[0].payload).toEqual({ value: 'example.net' });
|
|
182
182
|
expect(replies[0].emittedBy).toBe('claude-config-responder');
|
|
183
183
|
});
|
|
184
184
|
|
|
@@ -229,7 +229,7 @@ describe('celilo events command handlers', () => {
|
|
|
229
229
|
setupBus.close();
|
|
230
230
|
|
|
231
231
|
// A bare word isn't valid JSON — the operator must quote strings.
|
|
232
|
-
const res = await handleEventsReply([String(query.id), '
|
|
232
|
+
const res = await handleEventsReply([String(query.id), 'example.net'], {});
|
|
233
233
|
expect(res.success).toBe(false);
|
|
234
234
|
if (res.success) throw new Error('expected failure');
|
|
235
235
|
expect(res.error).toContain('Invalid JSON value');
|
|
@@ -439,7 +439,7 @@ export async function handleEventsReply(
|
|
|
439
439
|
return {
|
|
440
440
|
success: false,
|
|
441
441
|
error: `Usage: celilo events reply <query-event-id> <value-json>
|
|
442
|
-
e.g. celilo events reply 42 '"
|
|
442
|
+
e.g. celilo events reply 42 '"example.net"'`,
|
|
443
443
|
};
|
|
444
444
|
}
|
|
445
445
|
const queryId = Number(idArg);
|
|
@@ -454,7 +454,7 @@ export async function handleEventsReply(
|
|
|
454
454
|
return {
|
|
455
455
|
success: false,
|
|
456
456
|
error: `Invalid JSON value: ${err instanceof Error ? err.message : String(err)}
|
|
457
|
-
Encode the answer as JSON, e.g. '"
|
|
457
|
+
Encode the answer as JSON, e.g. '"example.net"', '8080', '["a","b"]'.`,
|
|
458
458
|
};
|
|
459
459
|
}
|
|
460
460
|
|
|
@@ -122,6 +122,14 @@ export async function handleServiceAddProxmox(
|
|
|
122
122
|
validate: validateRequired('Storage'),
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
// VM template name for `requires.system.type: vm` modules. Optional —
|
|
126
|
+
// skip if you only deploy LXC modules. See v2/PROXMOX_VM_TEMPLATE.md.
|
|
127
|
+
const vmTemplateInput = await promptText({
|
|
128
|
+
message: 'VM template name (optional — for VM-type modules):',
|
|
129
|
+
placeholder: 'e.g., ubuntu-2404-cloud-init-9000',
|
|
130
|
+
});
|
|
131
|
+
const vmTemplate = vmTemplateInput?.trim() || undefined;
|
|
132
|
+
|
|
125
133
|
// Find storage that supports vztmpl content. We do this BEFORE prompting
|
|
126
134
|
// for a template so the user only ever sees one storage in subsequent
|
|
127
135
|
// messages, and so the saved volid uses the right storage.
|
|
@@ -178,6 +186,7 @@ export async function handleServiceAddProxmox(
|
|
|
178
186
|
default_target_node: targetNode,
|
|
179
187
|
lxc_template: lxcTemplate,
|
|
180
188
|
storage,
|
|
189
|
+
...(vmTemplate ? { vm_template: vmTemplate } : {}),
|
|
181
190
|
},
|
|
182
191
|
});
|
|
183
192
|
|
package/src/cli/index.ts
CHANGED
|
@@ -287,7 +287,7 @@ Examples:
|
|
|
287
287
|
celilo events tail --type deploy.completed.lunacycle # filter by type
|
|
288
288
|
celilo events emit deploy.completed.lunacycle '{}' # operator-fired event
|
|
289
289
|
celilo events tail --type 'config.required.*' # see pending deploy questions
|
|
290
|
-
celilo events reply 42 '"
|
|
290
|
+
celilo events reply 42 '"example.net"' # answer query #42 (config)
|
|
291
291
|
`;
|
|
292
292
|
return { success: true, message: helpText.trim() };
|
|
293
293
|
}
|
|
@@ -1091,7 +1091,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1091
1091
|
'',
|
|
1092
1092
|
'Examples:',
|
|
1093
1093
|
' celilo hook run namecheap validate_config --debug',
|
|
1094
|
-
' celilo hook run namecheap container_created vps_ip=
|
|
1094
|
+
' celilo hook run namecheap container_created vps_ip=192.0.2.30',
|
|
1095
1095
|
].join('\n'),
|
|
1096
1096
|
};
|
|
1097
1097
|
}
|
package/src/config/paths.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { skipIntegration } from '../test-utils/integration-guard';
|
|
2
3
|
import { getDataDir, getDbPath, getMasterKeyPath, getModuleStoragePath } from './paths';
|
|
3
4
|
|
|
4
5
|
describe('paths configuration', () => {
|
|
@@ -34,18 +35,21 @@ describe('paths configuration', () => {
|
|
|
34
35
|
expect(path).toMatch(/celilo-data\/modules$/);
|
|
35
36
|
});
|
|
36
37
|
|
|
37
|
-
it(
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
it.skipIf(skipIntegration({ platform: 'darwin' }))(
|
|
39
|
+
'returns platform-specific path by default',
|
|
40
|
+
() => {
|
|
41
|
+
process.env.CELILO_DATA_DIR = undefined;
|
|
42
|
+
process.env.ENVIRONMENT = undefined;
|
|
40
43
|
|
|
41
|
-
|
|
44
|
+
const path = getModuleStoragePath();
|
|
42
45
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
if (process.platform === 'darwin') {
|
|
47
|
+
expect(path).toMatch(/Library\/Application Support\/celilo\/modules$/);
|
|
48
|
+
} else {
|
|
49
|
+
expect(path).toBe('/var/lib/celilo/modules');
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
);
|
|
49
53
|
|
|
50
54
|
it('prioritizes CELILO_DATA_DIR over ENVIRONMENT=dev', () => {
|
|
51
55
|
process.env.CELILO_DATA_DIR = '/custom/data';
|
|
@@ -73,18 +77,21 @@ describe('paths configuration', () => {
|
|
|
73
77
|
expect(path).toMatch(/celilo-data$/);
|
|
74
78
|
});
|
|
75
79
|
|
|
76
|
-
it(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
80
|
+
it.skipIf(skipIntegration({ platform: 'darwin' }))(
|
|
81
|
+
'returns platform-specific path by default',
|
|
82
|
+
() => {
|
|
83
|
+
process.env.CELILO_DATA_DIR = undefined;
|
|
84
|
+
process.env.ENVIRONMENT = undefined;
|
|
85
|
+
|
|
86
|
+
const path = getDataDir();
|
|
87
|
+
|
|
88
|
+
if (process.platform === 'darwin') {
|
|
89
|
+
expect(path).toMatch(/Library\/Application Support\/celilo$/);
|
|
90
|
+
} else {
|
|
91
|
+
expect(path).toBe('/var/lib/celilo');
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
);
|
|
88
95
|
});
|
|
89
96
|
|
|
90
97
|
describe('getMasterKeyPath', () => {
|
|
@@ -103,19 +110,22 @@ describe('paths configuration', () => {
|
|
|
103
110
|
expect(path).toBe('/custom/data/master.key');
|
|
104
111
|
});
|
|
105
112
|
|
|
106
|
-
it(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
113
|
+
it.skipIf(skipIntegration({ platform: 'darwin' }))(
|
|
114
|
+
'uses platform-specific data dir by default',
|
|
115
|
+
() => {
|
|
116
|
+
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
117
|
+
process.env.CELILO_DATA_DIR = undefined;
|
|
118
|
+
process.env.ENVIRONMENT = undefined;
|
|
119
|
+
|
|
120
|
+
const path = getMasterKeyPath();
|
|
121
|
+
|
|
122
|
+
if (process.platform === 'darwin') {
|
|
123
|
+
expect(path).toMatch(/Library\/Application Support\/celilo\/master\.key$/);
|
|
124
|
+
} else {
|
|
125
|
+
expect(path).toBe('/var/lib/celilo/master.key');
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
);
|
|
119
129
|
});
|
|
120
130
|
|
|
121
131
|
describe('getDbPath', () => {
|
|
@@ -128,19 +138,22 @@ describe('paths configuration', () => {
|
|
|
128
138
|
expect(path).toBe('/custom/path/celilo.db');
|
|
129
139
|
});
|
|
130
140
|
|
|
131
|
-
it(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
it.skipIf(skipIntegration({ platform: 'darwin' }))(
|
|
142
|
+
'returns data dir + celilo.db on macOS in production',
|
|
143
|
+
() => {
|
|
144
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
145
|
+
process.env.CELILO_DATA_DIR = undefined;
|
|
146
|
+
process.env.ENVIRONMENT = undefined;
|
|
147
|
+
|
|
148
|
+
const path = getDbPath();
|
|
149
|
+
|
|
150
|
+
if (process.platform === 'darwin') {
|
|
151
|
+
expect(path).toMatch(/Library\/Application Support\/celilo\/celilo.db$/);
|
|
152
|
+
} else {
|
|
153
|
+
expect(path).toBe('/var/lib/celilo/celilo.db');
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
);
|
|
144
157
|
|
|
145
158
|
it('returns celilo-data/celilo.db in development mode', () => {
|
|
146
159
|
process.env.CELILO_DB_PATH = undefined;
|
|
@@ -37,7 +37,7 @@ export default defineCapabilityFunction({
|
|
|
37
37
|
capability: 'firewall',
|
|
38
38
|
handler: ({ config, secrets }) => ({
|
|
39
39
|
exposeService: async (opts) => ({
|
|
40
|
-
externalIp: '
|
|
40
|
+
externalIp: '203.0.113.10',
|
|
41
41
|
natIp: opts.internalIp,
|
|
42
42
|
}),
|
|
43
43
|
unexposeService: async () => {},
|
|
@@ -135,7 +135,7 @@ describe('Firewall Chain Building', () => {
|
|
|
135
135
|
ports: [80],
|
|
136
136
|
description: 'test',
|
|
137
137
|
});
|
|
138
|
-
expect(exposed.externalIp).toBe('
|
|
138
|
+
expect(exposed.externalIp).toBe('203.0.113.10');
|
|
139
139
|
});
|
|
140
140
|
|
|
141
141
|
test('builds chain with two providers (iptables → greenwave)', async () => {
|
|
@@ -206,7 +206,7 @@ describe('Firewall Chain Building', () => {
|
|
|
206
206
|
});
|
|
207
207
|
|
|
208
208
|
// External IP came from greenwave (leaf)
|
|
209
|
-
expect(exposed.externalIp).toBe('
|
|
209
|
+
expect(exposed.externalIp).toBe('203.0.113.10');
|
|
210
210
|
// NAT IP is iptables' NAT address
|
|
211
211
|
expect(exposed.natIp).toBe('192.168.0.253');
|
|
212
212
|
// iptables created local rules
|
|
@@ -78,6 +78,21 @@ describe('extractProxmoxProperties', () => {
|
|
|
78
78
|
});
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
test('omits vm_template when the service config has none (LXC services)', () => {
|
|
82
|
+
const properties = extractProxmoxProperties(100, '10.0.10.5', 'caddy', mockProxmoxConfig);
|
|
83
|
+
// Omitted, not empty — so a required-infrastructure `vm_template` var fails
|
|
84
|
+
// loudly rather than resolving to "".
|
|
85
|
+
expect('vm_template' in properties).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('includes vm_template when the service config provides one (VM services)', () => {
|
|
89
|
+
const properties = extractProxmoxProperties(100, '10.0.10.5', 'builder', {
|
|
90
|
+
...mockProxmoxConfig,
|
|
91
|
+
vm_template: 'ubuntu-2204-cloudinit',
|
|
92
|
+
});
|
|
93
|
+
expect(properties.vm_template).toBe('ubuntu-2204-cloudinit');
|
|
94
|
+
});
|
|
95
|
+
|
|
81
96
|
test('converts vmid number to string', () => {
|
|
82
97
|
const properties = extractProxmoxProperties(12345, '10.0.20.15', 'caddy', mockProxmoxConfig);
|
|
83
98
|
|
|
@@ -47,6 +47,13 @@ export interface ProxmoxProviderConfig {
|
|
|
47
47
|
default_target_node: string;
|
|
48
48
|
lxc_template: string;
|
|
49
49
|
storage: string;
|
|
50
|
+
/**
|
|
51
|
+
* Cloud-init VM template to clone for `requires.system.type: vm` modules — the
|
|
52
|
+
* VM analogue of `lxc_template`. Optional: only Proxmox services that host VM
|
|
53
|
+
* modules configure it. A VM module declares `vm_template` as a *required*
|
|
54
|
+
* infrastructure var, so a service missing it fails loudly at resolution.
|
|
55
|
+
*/
|
|
56
|
+
vm_template?: string;
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
/**
|
|
@@ -72,6 +79,11 @@ export function extractProxmoxProperties(
|
|
|
72
79
|
target_node: providerConfig.default_target_node,
|
|
73
80
|
lxc_template: providerConfig.lxc_template,
|
|
74
81
|
storage: providerConfig.storage,
|
|
82
|
+
// VM clone source — present only when the service configures it. VM modules
|
|
83
|
+
// declare `vm_template` as a required infrastructure var (resolution errors
|
|
84
|
+
// if absent); LXC modules never reference it. Omitted (not empty) when unset
|
|
85
|
+
// so the resolver's required/optional handling stays correct.
|
|
86
|
+
...(providerConfig.vm_template ? { vm_template: providerConfig.vm_template } : {}),
|
|
75
87
|
};
|
|
76
88
|
}
|
|
77
89
|
|
package/src/manifest/schema.ts
CHANGED
|
@@ -311,6 +311,13 @@ export const SystemResourceSchema = z.object({
|
|
|
311
311
|
memory: z.number().int().positive().optional().describe('Recommended memory in MB'),
|
|
312
312
|
disk: z.number().int().positive().optional().describe('Recommended disk size in GB'),
|
|
313
313
|
storage: z.string().optional().describe('Proxmox storage backend (defaults to system config)'),
|
|
314
|
+
type: z
|
|
315
|
+
.enum(['lxc', 'vm'])
|
|
316
|
+
.default('lxc')
|
|
317
|
+
.describe(
|
|
318
|
+
'Proxmox provisioning type: lxc (default) or vm (qemu, for Docker / kernel-module workloads). ' +
|
|
319
|
+
'Modules declare this explicitly; celilo never infers it. Moot for machine-pool / external infra.',
|
|
320
|
+
),
|
|
314
321
|
zone: z
|
|
315
322
|
.enum(['internal', 'dmz', 'app', 'secure', 'external'])
|
|
316
323
|
.describe('Required security zone for this module'),
|
|
@@ -77,6 +77,59 @@ variables:
|
|
|
77
77
|
}
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
+
test('requires.system.type defaults to lxc when omitted', () => {
|
|
81
|
+
const yaml = `
|
|
82
|
+
${CONTRACT_LINE}
|
|
83
|
+
id: homebridge
|
|
84
|
+
name: Homebridge
|
|
85
|
+
version: 1.0.0
|
|
86
|
+
requires:
|
|
87
|
+
system:
|
|
88
|
+
cpu: 1
|
|
89
|
+
zone: app
|
|
90
|
+
`;
|
|
91
|
+
const result = validateManifest(yaml);
|
|
92
|
+
expect(result.success).toBe(true);
|
|
93
|
+
if (result.success) {
|
|
94
|
+
expect(result.data.requires.system?.type).toBe('lxc');
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('requires.system.type accepts an explicit vm', () => {
|
|
99
|
+
const yaml = `
|
|
100
|
+
${CONTRACT_LINE}
|
|
101
|
+
id: forgejo-runner
|
|
102
|
+
name: Forgejo Runner
|
|
103
|
+
version: 1.0.0
|
|
104
|
+
requires:
|
|
105
|
+
system:
|
|
106
|
+
cpu: 4
|
|
107
|
+
memory: 8192
|
|
108
|
+
type: vm
|
|
109
|
+
zone: dmz
|
|
110
|
+
`;
|
|
111
|
+
const result = validateManifest(yaml);
|
|
112
|
+
expect(result.success).toBe(true);
|
|
113
|
+
if (result.success) {
|
|
114
|
+
expect(result.data.requires.system?.type).toBe('vm');
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('requires.system.type rejects an unknown infra type', () => {
|
|
119
|
+
const yaml = `
|
|
120
|
+
${CONTRACT_LINE}
|
|
121
|
+
id: homebridge
|
|
122
|
+
name: Homebridge
|
|
123
|
+
version: 1.0.0
|
|
124
|
+
requires:
|
|
125
|
+
system:
|
|
126
|
+
type: container
|
|
127
|
+
zone: app
|
|
128
|
+
`;
|
|
129
|
+
const result = validateManifest(yaml);
|
|
130
|
+
expect(result.success).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
80
133
|
test('should validate dns-external manifest with capability provider', () => {
|
|
81
134
|
const yaml = `
|
|
82
135
|
${CONTRACT_LINE}
|
|
@@ -42,7 +42,7 @@ describe('busInterview', () => {
|
|
|
42
42
|
const watch = responderBus.watch('config.required.lunacycle.domain', (event) => {
|
|
43
43
|
responderBus.emitRaw(
|
|
44
44
|
`${event.type}.reply`,
|
|
45
|
-
{ value: '
|
|
45
|
+
{ value: 'example.net' },
|
|
46
46
|
{ replyFor: event.id, emittedBy: 'test-responder' },
|
|
47
47
|
);
|
|
48
48
|
});
|
|
@@ -58,7 +58,7 @@ describe('busInterview', () => {
|
|
|
58
58
|
payload,
|
|
59
59
|
);
|
|
60
60
|
|
|
61
|
-
expect(reply.value).toBe('
|
|
61
|
+
expect(reply.value).toBe('example.net');
|
|
62
62
|
|
|
63
63
|
watch.close();
|
|
64
64
|
responderBus.close();
|
|
@@ -350,7 +350,7 @@ describe('bus-mediated interviewForMissingSecrets', () => {
|
|
|
350
350
|
const { seen, close } = startTestResponder(
|
|
351
351
|
bus,
|
|
352
352
|
'secret.required.testmod.ddns_passwords',
|
|
353
|
-
{ value: JSON.stringify({ '
|
|
353
|
+
{ value: JSON.stringify({ 'example.net': 'pw1', 'celilo.computer': 'pw2' }) },
|
|
354
354
|
testDb,
|
|
355
355
|
);
|
|
356
356
|
|
|
@@ -380,7 +380,7 @@ describe('bus-mediated interviewForMissingSecrets', () => {
|
|
|
380
380
|
|
|
381
381
|
const masterKey = await getOrCreateMasterKey();
|
|
382
382
|
const stored = await readModuleSecretKey('testmod', 'ddns_passwords', testDb, masterKey);
|
|
383
|
-
expect(stored).toBe(JSON.stringify({ '
|
|
383
|
+
expect(stored).toBe(JSON.stringify({ 'example.net': 'pw1', 'celilo.computer': 'pw2' }));
|
|
384
384
|
|
|
385
385
|
close();
|
|
386
386
|
});
|
|
@@ -18,6 +18,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
|
18
18
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
19
|
import { tmpdir } from 'node:os';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
|
+
import { skipIntegration } from '../test-utils/integration-guard';
|
|
21
22
|
|
|
22
23
|
import type { HookContext } from '@celilo/capabilities';
|
|
23
24
|
|
|
@@ -77,7 +78,7 @@ function buildContext(extras: Record<string, unknown>): HookContext {
|
|
|
77
78
|
} as HookContext;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
describe('celilo-mgmt on_backup', () => {
|
|
81
|
+
describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', () => {
|
|
81
82
|
let dir: string;
|
|
82
83
|
let backupDir: string;
|
|
83
84
|
let crossModuleRoot: string;
|
|
@@ -214,7 +215,7 @@ describe('celilo-mgmt on_backup', () => {
|
|
|
214
215
|
});
|
|
215
216
|
});
|
|
216
217
|
|
|
217
|
-
describe('celilo-mgmt on_restore', () => {
|
|
218
|
+
describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_restore', () => {
|
|
218
219
|
let dir: string;
|
|
219
220
|
let restoreDir: string;
|
|
220
221
|
let crossModuleWriteRoot: string;
|
|
@@ -265,8 +265,8 @@ describe('findMissingSecrets (shared)', () => {
|
|
|
265
265
|
// The terminal-responder reads these off the bus payload to apply
|
|
266
266
|
// input-time regex validation. Without manifest → MissingVariable
|
|
267
267
|
// propagation, the responder never sees them and operators end
|
|
268
|
-
// up entering invalid keys (e.g. 'www.
|
|
269
|
-
// the apex '
|
|
268
|
+
// up entering invalid keys (e.g. 'www.example.net' instead of
|
|
269
|
+
// the apex 'example.net').
|
|
270
270
|
const missing = await findMissingSecrets(
|
|
271
271
|
'testmod',
|
|
272
272
|
{
|
|
@@ -105,14 +105,14 @@ describe('backfillWebRouteDns (ISS-0029)', () => {
|
|
|
105
105
|
seedFirewall('100.64.0.1');
|
|
106
106
|
seedRoute('apt.celilo.computer', '/');
|
|
107
107
|
seedRoute('apt.celilo.computer', '/-/publish'); // same host, different path → deduped
|
|
108
|
-
seedRoute('registry.
|
|
108
|
+
seedRoute('registry.example.net', '/');
|
|
109
109
|
|
|
110
110
|
const calls: DnsRecordRequest[] = [];
|
|
111
111
|
await backfillWebRouteDns('technitium', getDb(), silentLogger, stubLoader(calls));
|
|
112
112
|
|
|
113
113
|
expect(calls.map((c) => c.host).sort()).toEqual([
|
|
114
114
|
'apt.celilo.computer',
|
|
115
|
-
'registry.
|
|
115
|
+
'registry.example.net',
|
|
116
116
|
]);
|
|
117
117
|
expect(calls.every((c) => c.value === '100.64.0.1' && c.type === 'A')).toBe(true);
|
|
118
118
|
});
|