@celilo/cli 0.4.0 → 0.5.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
@@ -8,6 +8,7 @@
8
8
  * via this service) is an e2e concern, not exercised at the unit level.
9
9
  */
10
10
 
11
+ import { Database } from 'bun:sqlite';
11
12
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
12
13
  import {
13
14
  existsSync,
@@ -38,11 +39,14 @@ describe('applyStagedSystemFiles', () => {
38
39
  keyPath = join(dir, 'master.key');
39
40
  process.env.CELILO_DB_PATH = livePath;
40
41
  process.env.CELILO_MASTER_KEY_PATH = keyPath;
42
+ process.env.CELILO_DATA_DIR = dir; // getModuleStoragePath() = dir/modules
41
43
  });
42
44
 
43
45
  afterEach(() => {
46
+ closeDb();
44
47
  process.env.CELILO_DB_PATH = undefined;
45
48
  process.env.CELILO_MASTER_KEY_PATH = undefined;
49
+ process.env.CELILO_DATA_DIR = undefined;
46
50
  try {
47
51
  rmSync(dir, { recursive: true, force: true });
48
52
  } catch {
@@ -124,6 +128,48 @@ describe('applyStagedSystemFiles', () => {
124
128
  expect(result.dbApplied).toBe(false);
125
129
  expect(result.keyApplied).toBe(false);
126
130
  });
131
+
132
+ it('lays down staged module source dirs at the modules storage path', () => {
133
+ const stagedSrc = join(stagingDir, 'module_src');
134
+ mkdirSync(join(stagedSrc, 'caddy', 'scripts'), { recursive: true });
135
+ writeFileSync(join(stagedSrc, 'caddy', 'manifest.yml'), 'id: caddy');
136
+ writeFileSync(join(stagedSrc, 'caddy', 'scripts', 'hook.ts'), '// hook');
137
+
138
+ const result = applyStagedSystemFiles(stagingDir);
139
+ expect(result.moduleSourcesApplied).toBe(1);
140
+ // getModuleStoragePath() = <CELILO_DATA_DIR>/modules = dir/modules.
141
+ const laid = join(dir, 'modules', 'caddy');
142
+ expect(readFileSync(join(laid, 'manifest.yml'), 'utf-8')).toBe('id: caddy');
143
+ expect(readFileSync(join(laid, 'scripts', 'hook.ts'), 'utf-8')).toBe('// hook');
144
+ });
145
+
146
+ it("rewrites every module's source_path to this box on the staged DB before swap", () => {
147
+ // A real staged SQLite DB whose module points at a FOREIGN (macOS) path —
148
+ // exactly the macOS→Linux cross-host case ISS-0051 broke on. Build a minimal
149
+ // self-contained DB (no runMigrations, which would hold a connection and
150
+ // lock the journal-mode switch the rewrite needs).
151
+ const stagedDbPath = join(stagingDir, 'celilo.db');
152
+ const seed = new Database(stagedDbPath);
153
+ seed.run(
154
+ 'CREATE TABLE modules (id TEXT PRIMARY KEY, name TEXT, version TEXT, manifest_data TEXT, source_path TEXT)',
155
+ );
156
+ seed.run(
157
+ "INSERT INTO modules (id, name, version, manifest_data, source_path) VALUES ('caddy', 'caddy', '2.0.0', '{}', '/Users/someone/Library/Application Support/celilo/modules/caddy')",
158
+ );
159
+ seed.close();
160
+
161
+ const result = applyStagedSystemFiles(stagingDir);
162
+ expect(result.dbApplied).toBe(true);
163
+
164
+ // The swapped-in live DB must now point at THIS box's modules dir, not the
165
+ // source box's dead macOS path.
166
+ const live = new Database(livePath);
167
+ const row = live.query("SELECT source_path AS sp FROM modules WHERE id = 'caddy'").get() as {
168
+ sp: string;
169
+ };
170
+ live.close();
171
+ expect(row.sp).toBe(join(dir, 'modules', 'caddy'));
172
+ });
127
173
  });
128
174
 
129
175
  describe('restoreFromArtifactFile error paths', () => {
@@ -16,13 +16,18 @@
16
16
  * of the restore the hook itself cannot do (live DB is open).
17
17
  */
18
18
 
19
+ import { Database } from 'bun:sqlite';
19
20
  import { execSync } from 'node:child_process';
20
21
  import {
21
22
  chmodSync,
23
+ closeSync,
22
24
  copyFileSync,
25
+ cpSync,
23
26
  existsSync,
24
27
  mkdirSync,
28
+ openSync,
25
29
  readFileSync,
30
+ readSync,
26
31
  readdirSync,
27
32
  rmSync,
28
33
  writeFileSync,
@@ -30,7 +35,7 @@ import {
30
35
  import { tmpdir } from 'node:os';
31
36
  import { dirname, join } from 'node:path';
32
37
  import { eq } from 'drizzle-orm';
33
- import { getDbPath, getMasterKeyPath } from '../config/paths';
38
+ import { getDbPath, getMasterKeyPath, getModuleStoragePath } from '../config/paths';
34
39
  import { closeDb, getDb } from '../db/client';
35
40
  import { runMigrations } from '../db/migrate';
36
41
  import { modules } from '../db/schema';
@@ -61,6 +66,8 @@ export interface RestoreFromFileResult {
61
66
  masterKeyApplied?: boolean;
62
67
  /** Was the fleet SSH keypair restored to <dataDir>/.ssh/? */
63
68
  sshKeyApplied?: boolean;
69
+ /** How many module source dirs were laid down at the target's modules dir. */
70
+ moduleSourcesApplied?: number;
64
71
  }
65
72
 
66
73
  export interface RestoreFromFileOptions {
@@ -201,10 +208,21 @@ export async function restoreFromArtifactFile(
201
208
  hookInputs.cross_module_write_root = crossModuleWriteRoot;
202
209
  }
203
210
 
204
- // 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;
205
223
  const logger = createConsoleLogger(mod.id, 'on_restore');
206
224
  const hookResult = await invokeHook(
207
- mod.sourcePath,
225
+ onRestoreModulePath,
208
226
  'on_restore',
209
227
  manifest.celilo_contract,
210
228
  hookDef,
@@ -258,6 +276,7 @@ export async function restoreFromArtifactFile(
258
276
  systemDbApplied: apply.dbApplied,
259
277
  masterKeyApplied: apply.keyApplied,
260
278
  sshKeyApplied: apply.sshApplied,
279
+ moduleSourcesApplied: apply.moduleSourcesApplied,
261
280
  };
262
281
  } finally {
263
282
  try {
@@ -272,6 +291,61 @@ export interface StagedSystemApplyResult {
272
291
  dbApplied: boolean;
273
292
  keyApplied: boolean;
274
293
  sshApplied: boolean;
294
+ /** How many module source dirs were laid down at the target's modules dir. */
295
+ moduleSourcesApplied: number;
296
+ }
297
+
298
+ /** Cheap check for the SQLite file magic without reading the whole DB. */
299
+ function looksLikeSqlite(path: string): boolean {
300
+ let fd: number;
301
+ try {
302
+ fd = openSync(path, 'r');
303
+ } catch {
304
+ return false;
305
+ }
306
+ try {
307
+ const buf = Buffer.alloc(16);
308
+ readSync(fd, buf, 0, 16, 0);
309
+ // Magic header is "SQLite format 3" (15 bytes) + a NUL terminator.
310
+ return buf.subarray(0, 15).toString('utf-8') === 'SQLite format 3' && buf[15] === 0;
311
+ } finally {
312
+ closeSync(fd);
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Recompute every module's source_path to THIS box's modules dir, on the
318
+ * STAGED DB file (pre-swap). The artifact carries the SOURCE box's absolute
319
+ * source_path (e.g. macOS `~/Library/Application Support/celilo/modules/<id>`),
320
+ * which doesn't exist on a different box/OS — so a restored DB references module
321
+ * code at a dead path and no deploy can generate. By construction (import.ts)
322
+ * source_path is always `${dataDir}/modules/<id>`, so it's safe to recompute for
323
+ * the target; a no-op when the paths already match (same-OS restore).
324
+ *
325
+ * Done on the staged file (a fresh connection that's about to be swapped in),
326
+ * never the live DB, so there's no post-swap in-process reopen (ISS-0037). The
327
+ * PRAGMA forces commits into the main file (no -wal sibling) so the copy below
328
+ * captures the rewrite; the live DB re-enters WAL when celilo reopens it.
329
+ */
330
+ function rewriteStagedModuleSourcePaths(stagedDbPath: string): void {
331
+ // Tolerate non-SQLite staged files: some callers/tests stage placeholder
332
+ // bytes to exercise the file-copy path. A real artifact DB always opens
333
+ // with the "SQLite format 3\0" magic header — skip anything that doesn't
334
+ // (a valid-header-but-corrupt DB still surfaces by throwing below).
335
+ if (!looksLikeSqlite(stagedDbPath)) return;
336
+
337
+ const moduleStorageBase = getModuleStoragePath();
338
+ const staged = new Database(stagedDbPath);
339
+ try {
340
+ staged.query('PRAGMA journal_mode = DELETE').run();
341
+ const rows = staged.query('SELECT id FROM modules').all() as Array<{ id: string }>;
342
+ const update = staged.query('UPDATE modules SET source_path = ? WHERE id = ?');
343
+ for (const { id } of rows) {
344
+ update.run(join(moduleStorageBase, id), id);
345
+ }
346
+ } finally {
347
+ staged.close();
348
+ }
275
349
  }
276
350
 
277
351
  /**
@@ -291,14 +365,16 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
291
365
  let dbApplied = false;
292
366
  let keyApplied = false;
293
367
  let sshApplied = false;
368
+ let moduleSourcesApplied = 0;
294
369
 
295
370
  if (!existsSync(systemStagingDir)) {
296
- return { dbApplied, keyApplied, sshApplied };
371
+ return { dbApplied, keyApplied, sshApplied, moduleSourcesApplied };
297
372
  }
298
373
 
299
374
  const stagedDb = join(systemStagingDir, 'celilo.db');
300
375
  const stagedKey = join(systemStagingDir, 'master.key');
301
376
  const stagedSsh = join(systemStagingDir, 'ssh');
377
+ const stagedModuleSrc = join(systemStagingDir, 'module_src');
302
378
 
303
379
  // master.key first: it's not load-bearing for the running process
304
380
  // (already in memory if the daemon's running). Safe to swap before
@@ -329,9 +405,31 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
329
405
  sshApplied = true;
330
406
  }
331
407
 
408
+ // Module SOURCE code → lay down at THIS box's modules dir so the restored
409
+ // box has the code for EVERY module (incl. non-registry ones like lunacycle).
410
+ // on_backup captured each module's source; without this the restored DB
411
+ // references modules whose code isn't on disk and no deploy can generate.
412
+ // The artifact carries no generated/ subtree, so any existing generated/
413
+ // under a module dir (e.g. restored TF state) is preserved by the merge.
414
+ if (existsSync(stagedModuleSrc)) {
415
+ const moduleStorageBase = getModuleStoragePath();
416
+ mkdirSync(moduleStorageBase, { recursive: true });
417
+ for (const entry of readdirSync(stagedModuleSrc, { withFileTypes: true })) {
418
+ if (!entry.isDirectory()) continue;
419
+ cpSync(join(stagedModuleSrc, entry.name), join(moduleStorageBase, entry.name), {
420
+ recursive: true,
421
+ });
422
+ moduleSourcesApplied += 1;
423
+ }
424
+ }
425
+
332
426
  // celilo.db: must close the live DB connection before replacing
333
427
  // the file or SQLite gets confused (macOS holds a vnode handle).
334
428
  if (existsSync(stagedDb)) {
429
+ // Reconcile module source_paths to THIS box on the staged file BEFORE the
430
+ // swap (and before closeDb) — fixes cross-host / cross-OS restores. See
431
+ // rewriteStagedModuleSourcePaths.
432
+ rewriteStagedModuleSourcePaths(stagedDb);
335
433
  closeDb();
336
434
  const livePath = getDbPath();
337
435
  mkdirSync(join(livePath, '..'), { recursive: true });
@@ -348,7 +446,7 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
348
446
  dbApplied = true;
349
447
  }
350
448
 
351
- return { dbApplied, keyApplied, sshApplied };
449
+ return { dbApplied, keyApplied, sshApplied, moduleSourcesApplied };
352
450
  }
353
451
 
354
452
  /**
@@ -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 {
@@ -495,7 +495,7 @@ resource "proxmox_lxc" "container" {
495
495
  provides: { capabilities: [] },
496
496
  requires: {
497
497
  capabilities: [],
498
- machine: {
498
+ system: {
499
499
  cpu: 1,
500
500
  memory: 1024,
501
501
  disk: 10,
@@ -650,7 +650,9 @@ resource "proxmox_lxc" "container" {
650
650
  const out = injectProxmoxLxcDns(LXC, true);
651
651
  expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
652
652
  expect(out).toContain(' lifecycle {');
653
- expect(out).toContain(' ignore_changes = [nameserver]');
653
+ expect(out).toContain(
654
+ ' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]',
655
+ );
654
656
  // Injected immediately after the opening line, before author attributes.
655
657
  const lines = out.split('\n');
656
658
  expect(lines[0]).toBe('resource "proxmox_lxc" "caddy" {');
@@ -665,7 +667,9 @@ resource "proxmox_lxc" "container" {
665
667
  const out = injectProxmoxLxcDns(LXC, false);
666
668
  expect(out).not.toContain('nameserver = "$self:lxc_nameserver"');
667
669
  expect(out).toContain(' lifecycle {');
668
- expect(out).toContain(' ignore_changes = [nameserver]');
670
+ expect(out).toContain(
671
+ ' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]',
672
+ );
669
673
  });
670
674
 
671
675
  test('is idempotent — already-injected content is returned unchanged', () => {
@@ -687,8 +691,8 @@ resource "proxmox_lxc" "container" {
687
691
  const out = injectProxmoxLxcDns(stale, true);
688
692
  // Exactly one nameserver attribute survives.
689
693
  expect(out.match(/nameserver\s*=/g)?.length).toBe(1);
690
- // The lifecycle guard is still added.
691
- expect(out).toContain('ignore_changes = [nameserver]');
694
+ // The lifecycle guard is still added (ISS-0055 extended the ignore list).
695
+ expect(out).toContain('ignore_changes = [nameserver,');
692
696
  });
693
697
 
694
698
  test('leaves non-proxmox_lxc resources untouched', () => {
@@ -699,7 +703,7 @@ resource "proxmox_lxc" "container" {
699
703
  test('injects into every proxmox_lxc block in a multi-resource file', () => {
700
704
  const two = `${LXC}\n\n${LXC.replace('"caddy"', '"forgejo"')}`;
701
705
  const out = injectProxmoxLxcDns(two, true);
702
- expect(out.match(/ignore_changes = \[nameserver\]/g)?.length).toBe(2);
706
+ expect(out.match(/ignore_changes = \[nameserver,/g)?.length).toBe(2);
703
707
  });
704
708
 
705
709
  test('matches the indentation of the resource opening line', () => {
@@ -707,7 +711,9 @@ resource "proxmox_lxc" "container" {
707
711
  const out = injectProxmoxLxcDns(indented, true);
708
712
  expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
709
713
  expect(out).toContain(' lifecycle {');
710
- expect(out).toContain(' ignore_changes = [nameserver]');
714
+ expect(out).toContain(
715
+ ' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]',
716
+ );
711
717
  });
712
718
  });
713
719
  });
@@ -15,6 +15,7 @@ import {
15
15
  moduleInfrastructure,
16
16
  modules,
17
17
  } from '../db/schema';
18
+ import { getSingularSystemSpec } from '../manifest/schema';
18
19
  import type { AnsibleCollection, ModuleManifest } from '../manifest/schema';
19
20
  import { validateZoneRequirements } from '../manifest/validate';
20
21
  import { selectInfrastructure } from '../services/infrastructure-selector';
@@ -161,7 +162,10 @@ export function getOutputFilename(templateFilename: string): string {
161
162
  */
162
163
  export function injectProxmoxLxcDns(content: string, hasNameserver: boolean): string {
163
164
  // Already injected (idempotent) or author opted into the lifecycle — done.
164
- if (content.includes('ignore_changes = [nameserver]')) {
165
+ // Match the `[nameserver` prefix (no closing bracket) so this stays true
166
+ // whether the list is the original `[nameserver]` or the ISS-0055-extended
167
+ // `[nameserver, network[0].hwaddr, …]` — otherwise re-generate double-injects.
168
+ if (content.includes('ignore_changes = [nameserver')) {
165
169
  return content;
166
170
  }
167
171
 
@@ -181,7 +185,17 @@ export function injectProxmoxLxcDns(content: string, hasNameserver: boolean): st
181
185
  injected.push(`${inner}nameserver = "$self:lxc_nameserver"`);
182
186
  }
183
187
  injected.push(`${inner}lifecycle {`);
184
- injected.push(`${inner} ignore_changes = [nameserver]`);
188
+ // ISS-0055: ignore the ForceNew attributes Proxmox assigns at create time —
189
+ // the MAC (network hwaddr) and the rootfs volume path. telmate marks these
190
+ // ForceNew, so leaving them out of the config makes every re-deploy plan a
191
+ // destructive REPLACE. Ignoring just these two makes an unchanged re-deploy a
192
+ // no-op (the computed network id/type stay stable once the block is no longer
193
+ // being replaced) and a real change (e.g. a memory bump) an in-place UPDATE.
194
+ // We deliberately do NOT list network[0].id / network[0].type — they aren't
195
+ // schema attributes in telmate ~>2.9 and `terraform validate` rejects them.
196
+ // `nameserver` stays for the original DNS reason; `rootfs.size` is NOT ignored
197
+ // so a disk grow still applies in place.
198
+ injected.push(`${inner} ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]`);
185
199
  injected.push(`${inner}}`);
186
200
  return injected.join('\n');
187
201
  });
@@ -507,9 +521,9 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
507
521
 
508
522
  // Infrastructure Selection
509
523
  // Select infrastructure for this module and store in database
510
- // Only required for modules that specify requires.machine (container-based or machine-poolbased)
524
+ // Only required for modules that specify requires.system (container-based or machine-pool-based)
511
525
  let infrastructureSelection: InfrastructureSelection | undefined;
512
- const hasResourcesSpec = manifest.requires?.machine;
526
+ const hasResourcesSpec = getSingularSystemSpec(manifest);
513
527
 
514
528
  if (hasResourcesSpec) {
515
529
  // Check if infrastructure already selected (from previous generation)
@@ -559,7 +573,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
559
573
  // IPAM Auto-Allocation
560
574
  // Only allocate for Proxmox container services (not Digital Ocean or machines)
561
575
  // Digital Ocean gets IPs from Terraform outputs; Proxmox needs local IPAM
562
- const zone = manifest.requires?.machine?.zone;
576
+ const zone = getSingularSystemSpec(manifest)?.zone;
563
577
  const hasManualVmid = manifest.variables?.owns?.some((v) => v.name === 'vmid');
564
578
  const isContainerService = infrastructureSelection?.type === 'container_service';
565
579
 
@@ -1094,7 +1108,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
1094
1108
  // Build infrastructure info for CLI output
1095
1109
  let infrastructureInfo: import('./types').InfrastructureInfo | undefined;
1096
1110
  if (infrastructureSelection) {
1097
- const zone = manifest.requires?.machine?.zone || 'unknown';
1111
+ const zone = getSingularSystemSpec(manifest)?.zone || 'unknown';
1098
1112
 
1099
1113
  if (infrastructureSelection.type === 'machine' && infrastructureSelection.machineId) {
1100
1114
  const machine = await db