@celilo/cli 0.4.1 → 0.5.0-alpha.1
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 +1 -1
- package/src/api-clients/proxmox.test.ts +30 -0
- package/src/api-clients/proxmox.ts +57 -0
- package/src/cli/commands/module-show.ts +3 -3
- package/src/cli/commands/status.ts +2 -2
- package/src/hooks/capability-loader.ts +16 -5
- package/src/manifest/schema.ts +26 -15
- package/src/manifest/template-validator.test.ts +8 -8
- package/src/manifest/template-validator.ts +3 -3
- package/src/manifest/validate.test.ts +18 -17
- package/src/manifest/validate.ts +6 -6
- package/src/module/import.ts +2 -2
- package/src/services/celilo-events.test.ts +35 -5
- package/src/services/celilo-events.ts +17 -3
- package/src/services/celilo-mgmt-hooks.test.ts +14 -3
- package/src/services/deploy-preflight.ts +4 -3
- package/src/services/deployed-systems.test.ts +2 -2
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/infrastructure-selector.test.ts +14 -14
- package/src/services/infrastructure-selector.ts +11 -12
- package/src/services/module-deploy.ts +4 -4
- package/src/services/restore-from-file.ts +13 -2
- package/src/services/terraform-safety.ts +8 -2
- package/src/services/zone-policy.ts +2 -2
- package/src/templates/generator.test.ts +40 -7
- package/src/templates/generator.ts +99 -10
- package/src/variables/context.test.ts +19 -42
- package/src/variables/context.ts +14 -16
- package/src/variables/lxc-nameserver.test.ts +1 -1
- package/tsconfig.json +1 -1
|
@@ -152,14 +152,18 @@ describe('celilo-mgmt on_backup', () => {
|
|
|
152
152
|
expect(result.size_bytes).toBeGreaterThan(0);
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
-
it('captures module
|
|
155
|
+
it('captures LEAN module source (excludes generated/, node_modules/, large build artifacts)', async () => {
|
|
156
156
|
// stateDir = dirname(db_path) = dir; on_backup reads dir/modules/<id>.
|
|
157
157
|
const modSrc = join(dir, 'modules', 'caddy');
|
|
158
|
-
mkdirSync(join(modSrc, 'scripts'), { recursive: true });
|
|
158
|
+
mkdirSync(join(modSrc, 'scripts', 'node_modules', '@celilo'), { recursive: true });
|
|
159
159
|
mkdirSync(join(modSrc, 'generated', 'terraform'), { recursive: true });
|
|
160
|
+
mkdirSync(join(modSrc, 'ansible', 'files'), { recursive: true });
|
|
160
161
|
writeFileSync(join(modSrc, 'manifest.yml'), 'id: caddy');
|
|
161
162
|
writeFileSync(join(modSrc, 'scripts', 'hook.ts'), '// hook');
|
|
162
163
|
writeFileSync(join(modSrc, 'generated', 'terraform', 'main.tf'), 'resource {}');
|
|
164
|
+
writeFileSync(join(modSrc, 'scripts', 'node_modules', '@celilo', 'dep.js'), '// vendored');
|
|
165
|
+
// A >2MB "compiled binary" sitting in source — must be skipped by size.
|
|
166
|
+
writeFileSync(join(modSrc, 'ansible', 'files', 'server-bin'), Buffer.alloc(3 * 1024 * 1024));
|
|
163
167
|
|
|
164
168
|
const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
|
|
165
169
|
await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
|
|
@@ -167,8 +171,15 @@ describe('celilo-mgmt on_backup', () => {
|
|
|
167
171
|
// Source files captured...
|
|
168
172
|
expect(existsSync(join(backupDir, 'module_src', 'caddy', 'manifest.yml'))).toBe(true);
|
|
169
173
|
expect(existsSync(join(backupDir, 'module_src', 'caddy', 'scripts', 'hook.ts'))).toBe(true);
|
|
170
|
-
// ...but
|
|
174
|
+
// ...but build/vendored content excluded (keeps the backup small enough to
|
|
175
|
+
// not OOM the in-memory tar+encrypt — turnip's full dirs were ~1.6GB).
|
|
171
176
|
expect(existsSync(join(backupDir, 'module_src', 'caddy', 'generated'))).toBe(false);
|
|
177
|
+
expect(existsSync(join(backupDir, 'module_src', 'caddy', 'scripts', 'node_modules'))).toBe(
|
|
178
|
+
false,
|
|
179
|
+
);
|
|
180
|
+
expect(
|
|
181
|
+
existsSync(join(backupDir, 'module_src', 'caddy', 'ansible', 'files', 'server-bin')),
|
|
182
|
+
).toBe(false);
|
|
172
183
|
});
|
|
173
184
|
|
|
174
185
|
it('machine-pool.json is valid JSON (array)', async () => {
|
|
@@ -26,7 +26,7 @@ import { eq } from 'drizzle-orm';
|
|
|
26
26
|
import type { DbClient } from '../db/client';
|
|
27
27
|
import { getDb } from '../db/client';
|
|
28
28
|
import { capabilities, moduleConfigs, modules, secrets } from '../db/schema';
|
|
29
|
-
import type
|
|
29
|
+
import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
|
|
30
30
|
import { buildResolutionContext } from '../variables/context';
|
|
31
31
|
import { E2E_CONFLICT_FIX, runningE2eContainers } from './e2e-guard';
|
|
32
32
|
|
|
@@ -241,8 +241,9 @@ export async function runPreflight(
|
|
|
241
241
|
}
|
|
242
242
|
|
|
243
243
|
// 5. Infrastructure availability (for modules that need it)
|
|
244
|
-
|
|
245
|
-
|
|
244
|
+
const systemSpec = getSingularSystemSpec(manifest);
|
|
245
|
+
if (systemSpec) {
|
|
246
|
+
const zone = systemSpec.zone;
|
|
246
247
|
if (zone) {
|
|
247
248
|
// Simplified check: is there a machine or service for the module's zone?
|
|
248
249
|
// The full infrastructure selector (selectInfrastructure) needs a Module
|
|
@@ -60,7 +60,7 @@ function seedProxmoxModule(
|
|
|
60
60
|
id: opts.id,
|
|
61
61
|
name: opts.id,
|
|
62
62
|
version: '1.0.0',
|
|
63
|
-
manifestData: { requires: {
|
|
63
|
+
manifestData: { requires: { system: { zone: opts.zone } } },
|
|
64
64
|
sourcePath: `/tmp/${opts.id}`,
|
|
65
65
|
state: 'VERIFIED',
|
|
66
66
|
})
|
|
@@ -180,7 +180,7 @@ describe('backfillModuleSystems', () => {
|
|
|
180
180
|
id: 'technitium',
|
|
181
181
|
name: 'technitium',
|
|
182
182
|
version: '1.0.0',
|
|
183
|
-
manifestData: { requires: {
|
|
183
|
+
manifestData: { requires: { system: { zone: 'internal' } } },
|
|
184
184
|
sourcePath: '/tmp/technitium',
|
|
185
185
|
state: 'VERIFIED',
|
|
186
186
|
})
|
|
@@ -150,7 +150,7 @@ function asZone(value: string | undefined): NetworkZone | null {
|
|
|
150
150
|
* single sink the old scattered `target_ip` writes collapse into.
|
|
151
151
|
*
|
|
152
152
|
* Transition: every current module declares exactly one system (via
|
|
153
|
-
* `requires.
|
|
153
|
+
* `requires.system`, normalized to name `main`, or one `requires.systems`
|
|
154
154
|
* entry), so this records that single host from `hostname` config + the
|
|
155
155
|
* resolved IP. Returns the systems it recorded ([] for an API-only module with
|
|
156
156
|
* no host — e.g. namecheap). See v2/MODULE_SYSTEMS_ADDRESSING.md.
|
|
@@ -61,7 +61,7 @@ describe('infrastructure-selector', () => {
|
|
|
61
61
|
version: '1.0.0',
|
|
62
62
|
manifestData: {
|
|
63
63
|
requires: {
|
|
64
|
-
|
|
64
|
+
system: {
|
|
65
65
|
cpu: 2,
|
|
66
66
|
memory: 2048,
|
|
67
67
|
disk: 20,
|
|
@@ -119,7 +119,7 @@ describe('infrastructure-selector', () => {
|
|
|
119
119
|
version: '1.0.0',
|
|
120
120
|
manifestData: {
|
|
121
121
|
requires: {
|
|
122
|
-
|
|
122
|
+
system: {
|
|
123
123
|
cpu: 2,
|
|
124
124
|
memory: 2048,
|
|
125
125
|
disk: 20,
|
|
@@ -162,7 +162,7 @@ describe('infrastructure-selector', () => {
|
|
|
162
162
|
version: '1.0.0',
|
|
163
163
|
manifestData: {
|
|
164
164
|
requires: {
|
|
165
|
-
|
|
165
|
+
system: {
|
|
166
166
|
cpu: 2,
|
|
167
167
|
memory: 2048,
|
|
168
168
|
disk: 20,
|
|
@@ -197,7 +197,7 @@ describe('infrastructure-selector', () => {
|
|
|
197
197
|
const module = (await db.select().from(modules).limit(1))[0] as Module;
|
|
198
198
|
|
|
199
199
|
await expect(selectInfrastructure(module)).rejects.toThrow(InfrastructureError);
|
|
200
|
-
await expect(selectInfrastructure(module)).rejects.toThrow(/missing requires.
|
|
200
|
+
await expect(selectInfrastructure(module)).rejects.toThrow(/missing requires.system/);
|
|
201
201
|
});
|
|
202
202
|
|
|
203
203
|
it('throws error when module manifest missing zone', async () => {
|
|
@@ -210,7 +210,7 @@ describe('infrastructure-selector', () => {
|
|
|
210
210
|
version: '1.0.0',
|
|
211
211
|
manifestData: {
|
|
212
212
|
requires: {
|
|
213
|
-
|
|
213
|
+
system: {
|
|
214
214
|
cpu: 2,
|
|
215
215
|
memory: 2048,
|
|
216
216
|
disk: 20,
|
|
@@ -224,20 +224,20 @@ describe('infrastructure-selector', () => {
|
|
|
224
224
|
const module = (await db.select().from(modules).limit(1))[0] as Module;
|
|
225
225
|
|
|
226
226
|
await expect(selectInfrastructure(module)).rejects.toThrow(InfrastructureError);
|
|
227
|
-
await expect(selectInfrastructure(module)).rejects.toThrow(/missing requires.
|
|
227
|
+
await expect(selectInfrastructure(module)).rejects.toThrow(/missing requires.system.zone/);
|
|
228
228
|
});
|
|
229
229
|
|
|
230
|
-
it('supports requires.
|
|
230
|
+
it('supports requires.system format', async () => {
|
|
231
231
|
const db = createDbClient({ path: testDbPath });
|
|
232
232
|
|
|
233
|
-
// Create test module with requires.
|
|
233
|
+
// Create test module with requires.system format
|
|
234
234
|
await db.insert(modules).values({
|
|
235
235
|
id: 'test-module',
|
|
236
236
|
name: 'Test Module',
|
|
237
237
|
version: '1.0.0',
|
|
238
238
|
manifestData: {
|
|
239
239
|
requires: {
|
|
240
|
-
|
|
240
|
+
system: {
|
|
241
241
|
cpu: 2,
|
|
242
242
|
memory: 2048,
|
|
243
243
|
disk: 20,
|
|
@@ -284,7 +284,7 @@ describe('infrastructure-selector', () => {
|
|
|
284
284
|
version: '1.0.0',
|
|
285
285
|
manifestData: {
|
|
286
286
|
requires: {
|
|
287
|
-
|
|
287
|
+
system: {
|
|
288
288
|
cpu: 2,
|
|
289
289
|
memory: 2048,
|
|
290
290
|
disk: 20,
|
|
@@ -326,7 +326,7 @@ describe('infrastructure-selector', () => {
|
|
|
326
326
|
version: '1.0.0',
|
|
327
327
|
manifestData: {
|
|
328
328
|
requires: {
|
|
329
|
-
|
|
329
|
+
system: {
|
|
330
330
|
cpu: 2,
|
|
331
331
|
memory: 2048,
|
|
332
332
|
disk: 20,
|
|
@@ -366,7 +366,7 @@ describe('infrastructure-selector', () => {
|
|
|
366
366
|
version: '1.0.0',
|
|
367
367
|
manifestData: {
|
|
368
368
|
requires: {
|
|
369
|
-
|
|
369
|
+
system: {
|
|
370
370
|
cpu: 2,
|
|
371
371
|
memory: 2048,
|
|
372
372
|
disk: 20,
|
|
@@ -406,7 +406,7 @@ describe('infrastructure-selector', () => {
|
|
|
406
406
|
version: '1.0.0',
|
|
407
407
|
manifestData: {
|
|
408
408
|
requires: {
|
|
409
|
-
|
|
409
|
+
system: {
|
|
410
410
|
cpu: 2,
|
|
411
411
|
memory: 2048,
|
|
412
412
|
disk: 20,
|
|
@@ -449,7 +449,7 @@ describe('infrastructure-selector', () => {
|
|
|
449
449
|
version: '1.0.0',
|
|
450
450
|
manifestData: {
|
|
451
451
|
requires: {
|
|
452
|
-
|
|
452
|
+
system: {
|
|
453
453
|
cpu: 4,
|
|
454
454
|
memory: 4096,
|
|
455
455
|
disk: 128,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Module } from '../db/schema';
|
|
2
|
-
import type
|
|
2
|
+
import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
|
|
3
3
|
import type {
|
|
4
4
|
InfrastructureSelection,
|
|
5
5
|
Machine,
|
|
@@ -25,27 +25,26 @@ export class InfrastructureError extends Error {
|
|
|
25
25
|
function getResourceRequirements(module: Module): ResourceRequirements {
|
|
26
26
|
const manifest = module.manifestData as ModuleManifest;
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
const machine = manifest.requires?.machine;
|
|
28
|
+
const system = getSingularSystemSpec(manifest);
|
|
30
29
|
|
|
31
|
-
if (!
|
|
30
|
+
if (!system) {
|
|
32
31
|
throw new InfrastructureError(
|
|
33
|
-
`Module ${module.id} manifest missing requires.
|
|
32
|
+
`Module ${module.id} manifest missing requires.system configuration`,
|
|
34
33
|
);
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
if (!
|
|
36
|
+
if (!system.zone) {
|
|
38
37
|
throw new InfrastructureError(
|
|
39
|
-
`Module ${module.id} manifest missing requires.
|
|
38
|
+
`Module ${module.id} manifest missing requires.system.zone field`,
|
|
40
39
|
);
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
return {
|
|
44
|
-
cpu:
|
|
45
|
-
memory:
|
|
46
|
-
disk:
|
|
47
|
-
storage:
|
|
48
|
-
zone:
|
|
43
|
+
cpu: system.cpu ?? 1,
|
|
44
|
+
memory: system.memory ?? 1024,
|
|
45
|
+
disk: system.disk ?? 10,
|
|
46
|
+
storage: system.storage,
|
|
47
|
+
zone: system.zone,
|
|
49
48
|
};
|
|
50
49
|
}
|
|
51
50
|
|
|
@@ -16,7 +16,7 @@ import { loadCapabilityFunctions } from '../hooks/capability-loader';
|
|
|
16
16
|
import { invokeHook } from '../hooks/executor';
|
|
17
17
|
import { createGaugeLogger } from '../hooks/logger';
|
|
18
18
|
import type { HookDefinition, HookLogger, HookResult } from '../hooks/types';
|
|
19
|
-
import type
|
|
19
|
+
import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
|
|
20
20
|
import { decryptSecret } from '../secrets/encryption';
|
|
21
21
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
22
22
|
import { buildResolutionContext } from '../variables/context';
|
|
@@ -416,7 +416,7 @@ async function deployModuleImpl(
|
|
|
416
416
|
);
|
|
417
417
|
if (machineDerivable.length > 0) {
|
|
418
418
|
const isFirewall = manifest.provides?.capabilities?.some((cap) => cap.name === 'firewall');
|
|
419
|
-
const moduleZone = manifest
|
|
419
|
+
const moduleZone = getSingularSystemSpec(manifest)?.zone;
|
|
420
420
|
const matchedMachine = await findMachineForModule(
|
|
421
421
|
moduleId,
|
|
422
422
|
moduleZone,
|
|
@@ -473,7 +473,7 @@ async function deployModuleImpl(
|
|
|
473
473
|
if (regularConfig.length > 0) {
|
|
474
474
|
// Look up machine for $machine: derivation (earmarked or best match from pool)
|
|
475
475
|
const isFirewall = manifest.provides?.capabilities?.some((cap) => cap.name === 'firewall');
|
|
476
|
-
const moduleZone = manifest
|
|
476
|
+
const moduleZone = getSingularSystemSpec(manifest)?.zone;
|
|
477
477
|
const matchedMachine = await findMachineForModule(
|
|
478
478
|
moduleId,
|
|
479
479
|
moduleZone,
|
|
@@ -668,7 +668,7 @@ async function deployModuleImpl(
|
|
|
668
668
|
}
|
|
669
669
|
|
|
670
670
|
// Config-only modules (no infrastructure requirements) don't need terraform/ansible
|
|
671
|
-
const isConfigOnly = !manifest
|
|
671
|
+
const isConfigOnly = !getSingularSystemSpec(manifest);
|
|
672
672
|
if (isConfigOnly) {
|
|
673
673
|
log.success('Config-only module — no infrastructure deployment needed');
|
|
674
674
|
|
|
@@ -208,10 +208,21 @@ export async function restoreFromArtifactFile(
|
|
|
208
208
|
hookInputs.cross_module_write_root = crossModuleWriteRoot;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
// 5. Invoke the hook.
|
|
211
|
+
// 5. Invoke the hook. Resolve the module path from getModuleStoragePath()
|
|
212
|
+
// rather than the stored mod.sourcePath (ISS-0052): on a box whose DB was
|
|
213
|
+
// produced by a prior restore from ANOTHER box, source_path is that box's
|
|
214
|
+
// absolute path (e.g. a macOS /Users/... path on a Linux box), and the
|
|
215
|
+
// hook executor's screenshot-dir mkdir then EACCES'es on it. By
|
|
216
|
+
// construction (import.ts) the code always lives at
|
|
217
|
+
// ${getModuleStoragePath()}/<id>, so resolve there. Falls back to the
|
|
218
|
+
// stored path only if the canonical dir is absent (e.g. a custom layout).
|
|
219
|
+
const canonicalModulePath = join(getModuleStoragePath(), mod.id);
|
|
220
|
+
const onRestoreModulePath = existsSync(canonicalModulePath)
|
|
221
|
+
? canonicalModulePath
|
|
222
|
+
: mod.sourcePath;
|
|
212
223
|
const logger = createConsoleLogger(mod.id, 'on_restore');
|
|
213
224
|
const hookResult = await invokeHook(
|
|
214
|
-
|
|
225
|
+
onRestoreModulePath,
|
|
215
226
|
'on_restore',
|
|
216
227
|
manifest.celilo_contract,
|
|
217
228
|
hookDef,
|
|
@@ -57,8 +57,14 @@ export function validateTerraformPlanSafety(planOutput: string): PlanSafetyResul
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// Rule 2:
|
|
61
|
-
|
|
60
|
+
// Rule 2: CREATE and in-place UPDATE are allowed; REPLACE and DELETE are
|
|
61
|
+
// refused. (ISS-0055) An in-place update — the lifecycle-ignored
|
|
62
|
+
// computed-attribute redeploy, or a deliberate memory/cpu resize — is safe
|
|
63
|
+
// and must go through, so the management plane can update containers in
|
|
64
|
+
// place. Only destroy+recreate (replace) and delete are data-loss
|
|
65
|
+
// operations we hard-block. The parser matches `replace` before `update`,
|
|
66
|
+
// so a "must be replaced" plan is still classified as replace and refused.
|
|
67
|
+
if (action.action !== 'create' && action.action !== 'update') {
|
|
62
68
|
return {
|
|
63
69
|
safe: false,
|
|
64
70
|
error: formatUnsafeOperationError(action),
|
|
@@ -101,12 +101,12 @@ export function validateModuleZoneRequirements(
|
|
|
101
101
|
* Policy function - determines if zone validation applies
|
|
102
102
|
*
|
|
103
103
|
* Zone is required if:
|
|
104
|
-
* - Module specifies requires.
|
|
104
|
+
* - Module specifies requires.system (infrastructure provisioning)
|
|
105
105
|
*
|
|
106
106
|
* Zone is optional if:
|
|
107
107
|
* - Module has no infrastructure requirements (e.g., VPS-based modules)
|
|
108
108
|
*
|
|
109
|
-
* @param hasInfrastructureSpec - Whether module has requires.
|
|
109
|
+
* @param hasInfrastructureSpec - Whether module has requires.system
|
|
110
110
|
* @returns True if zone field is required
|
|
111
111
|
*/
|
|
112
112
|
export function isZoneRequired(hasInfrastructureSpec: boolean): boolean {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
injectProxmoxLxcDns,
|
|
13
13
|
isTemplateFile,
|
|
14
14
|
readTemplateFiles,
|
|
15
|
+
targetNodeFromTfState,
|
|
15
16
|
writeGeneratedFiles,
|
|
16
17
|
} from './generator';
|
|
17
18
|
import type { GeneratedFile } from './types';
|
|
@@ -495,7 +496,7 @@ resource "proxmox_lxc" "container" {
|
|
|
495
496
|
provides: { capabilities: [] },
|
|
496
497
|
requires: {
|
|
497
498
|
capabilities: [],
|
|
498
|
-
|
|
499
|
+
system: {
|
|
499
500
|
cpu: 1,
|
|
500
501
|
memory: 1024,
|
|
501
502
|
disk: 10,
|
|
@@ -650,7 +651,9 @@ resource "proxmox_lxc" "container" {
|
|
|
650
651
|
const out = injectProxmoxLxcDns(LXC, true);
|
|
651
652
|
expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
|
|
652
653
|
expect(out).toContain(' lifecycle {');
|
|
653
|
-
expect(out).toContain(
|
|
654
|
+
expect(out).toContain(
|
|
655
|
+
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
|
|
656
|
+
);
|
|
654
657
|
// Injected immediately after the opening line, before author attributes.
|
|
655
658
|
const lines = out.split('\n');
|
|
656
659
|
expect(lines[0]).toBe('resource "proxmox_lxc" "caddy" {');
|
|
@@ -665,7 +668,9 @@ resource "proxmox_lxc" "container" {
|
|
|
665
668
|
const out = injectProxmoxLxcDns(LXC, false);
|
|
666
669
|
expect(out).not.toContain('nameserver = "$self:lxc_nameserver"');
|
|
667
670
|
expect(out).toContain(' lifecycle {');
|
|
668
|
-
expect(out).toContain(
|
|
671
|
+
expect(out).toContain(
|
|
672
|
+
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
|
|
673
|
+
);
|
|
669
674
|
});
|
|
670
675
|
|
|
671
676
|
test('is idempotent — already-injected content is returned unchanged', () => {
|
|
@@ -687,8 +692,8 @@ resource "proxmox_lxc" "container" {
|
|
|
687
692
|
const out = injectProxmoxLxcDns(stale, true);
|
|
688
693
|
// Exactly one nameserver attribute survives.
|
|
689
694
|
expect(out.match(/nameserver\s*=/g)?.length).toBe(1);
|
|
690
|
-
// The lifecycle guard is still added.
|
|
691
|
-
expect(out).toContain('ignore_changes = [nameserver
|
|
695
|
+
// The lifecycle guard is still added (ISS-0055 extended the ignore list).
|
|
696
|
+
expect(out).toContain('ignore_changes = [nameserver,');
|
|
692
697
|
});
|
|
693
698
|
|
|
694
699
|
test('leaves non-proxmox_lxc resources untouched', () => {
|
|
@@ -699,7 +704,7 @@ resource "proxmox_lxc" "container" {
|
|
|
699
704
|
test('injects into every proxmox_lxc block in a multi-resource file', () => {
|
|
700
705
|
const two = `${LXC}\n\n${LXC.replace('"caddy"', '"forgejo"')}`;
|
|
701
706
|
const out = injectProxmoxLxcDns(two, true);
|
|
702
|
-
expect(out.match(/ignore_changes = \[nameserver
|
|
707
|
+
expect(out.match(/ignore_changes = \[nameserver,/g)?.length).toBe(2);
|
|
703
708
|
});
|
|
704
709
|
|
|
705
710
|
test('matches the indentation of the resource opening line', () => {
|
|
@@ -707,7 +712,35 @@ resource "proxmox_lxc" "container" {
|
|
|
707
712
|
const out = injectProxmoxLxcDns(indented, true);
|
|
708
713
|
expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
|
|
709
714
|
expect(out).toContain(' lifecycle {');
|
|
710
|
-
expect(out).toContain(
|
|
715
|
+
expect(out).toContain(
|
|
716
|
+
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
|
|
717
|
+
);
|
|
711
718
|
});
|
|
712
719
|
});
|
|
713
720
|
});
|
|
721
|
+
|
|
722
|
+
describe("targetNodeFromTfState (ISS-0090 — terraform state is celilo's placement record)", () => {
|
|
723
|
+
test('reads the node from the proxmox_lxc target_node attribute', () => {
|
|
724
|
+
const state = {
|
|
725
|
+
resources: [
|
|
726
|
+
{
|
|
727
|
+
type: 'proxmox_lxc',
|
|
728
|
+
instances: [{ attributes: { target_node: 'node2', id: 'node2/lxc/200' } }],
|
|
729
|
+
},
|
|
730
|
+
],
|
|
731
|
+
};
|
|
732
|
+
expect(targetNodeFromTfState(state)).toBe('node2');
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test('falls back to parsing the resource id when target_node is absent', () => {
|
|
736
|
+
const state = {
|
|
737
|
+
resources: [{ type: 'proxmox_lxc', instances: [{ attributes: { id: 'node3/lxc/201' } }] }],
|
|
738
|
+
};
|
|
739
|
+
expect(targetNodeFromTfState(state)).toBe('node3');
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test('returns null when there is no proxmox_lxc resource (fresh/empty state)', () => {
|
|
743
|
+
expect(targetNodeFromTfState({ resources: [] })).toBeNull();
|
|
744
|
+
expect(targetNodeFromTfState({})).toBeNull();
|
|
745
|
+
});
|
|
746
|
+
});
|
|
@@ -6,6 +6,7 @@ import { and, eq } from 'drizzle-orm';
|
|
|
6
6
|
import { generateInventory } from '../ansible/inventory';
|
|
7
7
|
import { generateAnsibleSecrets } from '../ansible/secrets';
|
|
8
8
|
import { log } from '../cli/prompts';
|
|
9
|
+
import { getModuleStoragePath } from '../config/paths';
|
|
9
10
|
import { type DbClient, getDb } from '../db/client';
|
|
10
11
|
import {
|
|
11
12
|
capabilities,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
moduleInfrastructure,
|
|
16
17
|
modules,
|
|
17
18
|
} from '../db/schema';
|
|
19
|
+
import { getSingularSystemSpec } from '../manifest/schema';
|
|
18
20
|
import type { AnsibleCollection, ModuleManifest } from '../manifest/schema';
|
|
19
21
|
import { validateZoneRequirements } from '../manifest/validate';
|
|
20
22
|
import { selectInfrastructure } from '../services/infrastructure-selector';
|
|
@@ -161,7 +163,10 @@ export function getOutputFilename(templateFilename: string): string {
|
|
|
161
163
|
*/
|
|
162
164
|
export function injectProxmoxLxcDns(content: string, hasNameserver: boolean): string {
|
|
163
165
|
// Already injected (idempotent) or author opted into the lifecycle — done.
|
|
164
|
-
|
|
166
|
+
// Match the `[nameserver` prefix (no closing bracket) so this stays true
|
|
167
|
+
// whether the list is the original `[nameserver]` or the ISS-0055-extended
|
|
168
|
+
// `[nameserver, network[0].hwaddr, …]` — otherwise re-generate double-injects.
|
|
169
|
+
if (content.includes('ignore_changes = [nameserver')) {
|
|
165
170
|
return content;
|
|
166
171
|
}
|
|
167
172
|
|
|
@@ -181,12 +186,79 @@ export function injectProxmoxLxcDns(content: string, hasNameserver: boolean): st
|
|
|
181
186
|
injected.push(`${inner}nameserver = "$self:lxc_nameserver"`);
|
|
182
187
|
}
|
|
183
188
|
injected.push(`${inner}lifecycle {`);
|
|
184
|
-
|
|
189
|
+
// ISS-0055: ignore the ForceNew attributes Proxmox assigns at create time —
|
|
190
|
+
// the MAC (network hwaddr) and the rootfs volume path. telmate marks these
|
|
191
|
+
// ForceNew, so leaving them out of the config makes every re-deploy plan a
|
|
192
|
+
// destructive REPLACE. Ignoring just these two makes an unchanged re-deploy a
|
|
193
|
+
// no-op (the computed network id/type stay stable once the block is no longer
|
|
194
|
+
// being replaced) and a real change (e.g. a memory bump) an in-place UPDATE.
|
|
195
|
+
// We deliberately do NOT list network[0].id / network[0].type — they aren't
|
|
196
|
+
// schema attributes in telmate ~>2.9 and `terraform validate` rejects them.
|
|
197
|
+
// `nameserver` stays for the original DNS reason; `rootfs.size` is NOT ignored
|
|
198
|
+
// so a disk grow still applies in place.
|
|
199
|
+
// ISS-0089: also ignore `ssh_public_keys` — it's ForceNew, and on a module
|
|
200
|
+
// deployed before the current fleet key, the LXC's birth keys differ from the
|
|
201
|
+
// regenerated config, forcing a spurious REPLACE on redeploy. Rotating the
|
|
202
|
+
// authorized keys must never recreate the container. We do NOT ignore
|
|
203
|
+
// `target_node` — a node change is real and is handled by migration (ISS-0090),
|
|
204
|
+
// never silently dropped.
|
|
205
|
+
injected.push(
|
|
206
|
+
`${inner} ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]`,
|
|
207
|
+
);
|
|
185
208
|
injected.push(`${inner}}`);
|
|
186
209
|
return injected.join('\n');
|
|
187
210
|
});
|
|
188
211
|
}
|
|
189
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Extract the node a proxmox_lxc resource is deployed on from a parsed terraform
|
|
215
|
+
* state object. Pure logic (Rule 10), split from the file read for testability.
|
|
216
|
+
* Prefers the explicit `target_node` attribute; falls back to parsing the
|
|
217
|
+
* resource id (`<node>/lxc/<vmid>`). Returns null when there's no proxmox_lxc
|
|
218
|
+
* resource (e.g. an empty/fresh state).
|
|
219
|
+
*/
|
|
220
|
+
export function targetNodeFromTfState(state: {
|
|
221
|
+
resources?: Array<{
|
|
222
|
+
type?: string;
|
|
223
|
+
instances?: Array<{ attributes?: { target_node?: string; id?: string } }>;
|
|
224
|
+
}>;
|
|
225
|
+
}): string | null {
|
|
226
|
+
const lxc = state.resources?.find((r) => r.type === 'proxmox_lxc');
|
|
227
|
+
const attrs = lxc?.instances?.[0]?.attributes;
|
|
228
|
+
if (!attrs) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
if (attrs.target_node) {
|
|
232
|
+
return attrs.target_node;
|
|
233
|
+
}
|
|
234
|
+
// id format: "<node>/lxc/<vmid>"
|
|
235
|
+
return attrs.id?.split('/')[0] || null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Read the node a module's container is currently deployed on, from its terraform
|
|
240
|
+
* state file. This is celilo's authoritative record of placement (ISS-0090).
|
|
241
|
+
* Returns null when no state exists yet (a first deploy) or it can't be read —
|
|
242
|
+
* the caller then falls back to the service `default_target_node`.
|
|
243
|
+
*/
|
|
244
|
+
async function readDeployedTargetNode(moduleId: string): Promise<string | null> {
|
|
245
|
+
const statePath = join(
|
|
246
|
+
getModuleStoragePath(),
|
|
247
|
+
moduleId,
|
|
248
|
+
'generated',
|
|
249
|
+
'terraform',
|
|
250
|
+
'terraform.tfstate',
|
|
251
|
+
);
|
|
252
|
+
if (!existsSync(statePath)) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
return targetNodeFromTfState(JSON.parse(await readFile(statePath, 'utf-8')));
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
190
262
|
/**
|
|
191
263
|
* Discover template files in directory recursively
|
|
192
264
|
*
|
|
@@ -507,9 +579,9 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
507
579
|
|
|
508
580
|
// Infrastructure Selection
|
|
509
581
|
// Select infrastructure for this module and store in database
|
|
510
|
-
// Only required for modules that specify requires.
|
|
582
|
+
// Only required for modules that specify requires.system (container-based or machine-pool-based)
|
|
511
583
|
let infrastructureSelection: InfrastructureSelection | undefined;
|
|
512
|
-
const hasResourcesSpec = manifest
|
|
584
|
+
const hasResourcesSpec = getSingularSystemSpec(manifest);
|
|
513
585
|
|
|
514
586
|
if (hasResourcesSpec) {
|
|
515
587
|
// Check if infrastructure already selected (from previous generation)
|
|
@@ -559,7 +631,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
559
631
|
// IPAM Auto-Allocation
|
|
560
632
|
// Only allocate for Proxmox container services (not Digital Ocean or machines)
|
|
561
633
|
// Digital Ocean gets IPs from Terraform outputs; Proxmox needs local IPAM
|
|
562
|
-
const zone = manifest
|
|
634
|
+
const zone = getSingularSystemSpec(manifest)?.zone;
|
|
563
635
|
const hasManualVmid = manifest.variables?.owns?.some((v) => v.name === 'vmid');
|
|
564
636
|
const isContainerService = infrastructureSelection?.type === 'container_service';
|
|
565
637
|
|
|
@@ -624,9 +696,28 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
624
696
|
storage: string;
|
|
625
697
|
};
|
|
626
698
|
|
|
699
|
+
// ISS-0090: target the node the container ACTUALLY lives on, not the
|
|
700
|
+
// service default. `default_target_node` governs only NEW placement; a
|
|
701
|
+
// changed default must NEVER relocate a running system. celilo's terraform
|
|
702
|
+
// state is its authoritative record of where it placed this container (kept
|
|
703
|
+
// in sync by deploy and by `module migrate`), so read the deployed node from
|
|
704
|
+
// there. Fall back to the default only when there's no state yet — a first
|
|
705
|
+
// deploy. Deliberate relocation is an explicit `module migrate`, not a
|
|
706
|
+
// side-effect of the default changing.
|
|
707
|
+
let targetNode = providerConfig.default_target_node;
|
|
708
|
+
const deployedNode = await readDeployedTargetNode(moduleId);
|
|
709
|
+
if (deployedNode) {
|
|
710
|
+
if (deployedNode !== targetNode) {
|
|
711
|
+
log.info(
|
|
712
|
+
`${moduleId} is already deployed on node '${deployedNode}' — targeting it (service default is '${providerConfig.default_target_node}'). Relocating requires a deliberate migration.`,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
targetNode = deployedNode;
|
|
716
|
+
}
|
|
717
|
+
|
|
627
718
|
// Store provider config values as temporary config (similar to IPAM allocation)
|
|
628
719
|
const infraProperties = [
|
|
629
|
-
{ key: 'target_node', value:
|
|
720
|
+
{ key: 'target_node', value: targetNode },
|
|
630
721
|
{ key: 'lxc_template', value: providerConfig.lxc_template },
|
|
631
722
|
{ key: 'storage', value: providerConfig.storage },
|
|
632
723
|
];
|
|
@@ -635,9 +726,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
635
726
|
upsertModuleConfig(db, moduleId, `__infra_${prop.key}`, prop.value);
|
|
636
727
|
}
|
|
637
728
|
|
|
638
|
-
log.success(
|
|
639
|
-
`Infrastructure properties resolved from service: target_node=${providerConfig.default_target_node}`,
|
|
640
|
-
);
|
|
729
|
+
log.success(`Infrastructure properties resolved from service: target_node=${targetNode}`);
|
|
641
730
|
}
|
|
642
731
|
}
|
|
643
732
|
|
|
@@ -1094,7 +1183,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
1094
1183
|
// Build infrastructure info for CLI output
|
|
1095
1184
|
let infrastructureInfo: import('./types').InfrastructureInfo | undefined;
|
|
1096
1185
|
if (infrastructureSelection) {
|
|
1097
|
-
const zone = manifest
|
|
1186
|
+
const zone = getSingularSystemSpec(manifest)?.zone || 'unknown';
|
|
1098
1187
|
|
|
1099
1188
|
if (infrastructureSelection.type === 'machine' && infrastructureSelection.machineId) {
|
|
1100
1189
|
const machine = await db
|