@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.
@@ -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 SOURCE (excluding generated/) into module_src/', async () => {
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 generated/ excluded (TF state travels via cross_module_state).
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 { ModuleManifest } from '../manifest/schema';
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
- if (manifest.requires?.machine) {
245
- const zone = manifest.requires.machine.zone;
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: { machine: { zone: opts.zone } } },
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: { machine: { zone: 'internal' } } },
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.machine`, normalized to name `main`, or one `requires.systems`
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
- machine: {
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
- machine: {
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
- machine: {
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.machine/);
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
- machine: {
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.machine.zone/);
227
+ await expect(selectInfrastructure(module)).rejects.toThrow(/missing requires.system.zone/);
228
228
  });
229
229
 
230
- it('supports requires.machine format', async () => {
230
+ it('supports requires.system format', async () => {
231
231
  const db = createDbClient({ path: testDbPath });
232
232
 
233
- // Create test module with requires.machine format
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
- machine: {
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
- machine: {
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
- machine: {
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
- machine: {
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
- machine: {
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
- machine: {
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 { ModuleManifest } from '../manifest/schema';
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
- // Check for requires.machine
29
- const machine = manifest.requires?.machine;
28
+ const system = getSingularSystemSpec(manifest);
30
29
 
31
- if (!machine) {
30
+ if (!system) {
32
31
  throw new InfrastructureError(
33
- `Module ${module.id} manifest missing requires.machine configuration`,
32
+ `Module ${module.id} manifest missing requires.system configuration`,
34
33
  );
35
34
  }
36
35
 
37
- if (!machine.zone) {
36
+ if (!system.zone) {
38
37
  throw new InfrastructureError(
39
- `Module ${module.id} manifest missing requires.machine.zone field`,
38
+ `Module ${module.id} manifest missing requires.system.zone field`,
40
39
  );
41
40
  }
42
41
 
43
42
  return {
44
- cpu: machine.cpu ?? 1,
45
- memory: machine.memory ?? 1024,
46
- disk: machine.disk ?? 10,
47
- storage: machine.storage,
48
- zone: machine.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 { ModuleManifest } from '../manifest/schema';
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.requires?.machine?.zone;
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.requires?.machine?.zone;
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.requires?.machine;
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
- mod.sourcePath,
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: Only CREATE operations allowed
61
- if (action.action !== 'create') {
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.machine (infrastructure provisioning)
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.machine
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
- machine: {
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(' ignore_changes = [nameserver]');
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(' ignore_changes = [nameserver]');
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\]/g)?.length).toBe(2);
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(' ignore_changes = [nameserver]');
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
- if (content.includes('ignore_changes = [nameserver]')) {
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
- injected.push(`${inner} ignore_changes = [nameserver]`);
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.machine (container-based or machine-poolbased)
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.requires?.machine;
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.requires?.machine?.zone;
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: providerConfig.default_target_node },
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.requires?.machine?.zone || 'unknown';
1186
+ const zone = getSingularSystemSpec(manifest)?.zone || 'unknown';
1098
1187
 
1099
1188
  if (infrastructureSelection.type === 'machine' && infrastructureSelection.machineId) {
1100
1189
  const machine = await db