@celilo/cli 0.14.4 → 0.16.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.
Files changed (64) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +19 -2
  3. package/drizzle/0018_drop_alert_policy_snapshot.sql +46 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +3 -3
  6. package/src/cli/commands/alerts-list.ts +10 -0
  7. package/src/cli/commands/alerts-poll.ts +12 -6
  8. package/src/cli/commands/alerts-sweep.ts +22 -81
  9. package/src/cli/commands/backup-sweep.ts +65 -0
  10. package/src/cli/commands/module-config.test.ts +77 -1
  11. package/src/cli/commands/module-config.ts +45 -3
  12. package/src/cli/commands/module-journal.test.ts +47 -0
  13. package/src/cli/commands/module-journal.ts +98 -0
  14. package/src/cli/commands/module-operations.test.ts +93 -0
  15. package/src/cli/commands/module-operations.ts +134 -0
  16. package/src/cli/commands/module-upgrade.test.ts +32 -20
  17. package/src/cli/commands/module-upgrade.ts +37 -32
  18. package/src/cli/commands/monitor.ts +26 -6
  19. package/src/cli/commands/system-audit.ts +3 -30
  20. package/src/cli/completion.ts +20 -1
  21. package/src/cli/generate-zsh-completion.ts +4 -0
  22. package/src/cli/index.ts +14 -0
  23. package/src/db/schema.ts +5 -3
  24. package/src/manifest/schema.ts +4 -1
  25. package/src/module/packaging/build.ts +4 -0
  26. package/src/services/alerting/builtin-source.ts +17 -2
  27. package/src/services/alerting/delivery-loop.test.ts +5 -1
  28. package/src/services/alerting/format.test.ts +0 -1
  29. package/src/services/alerting/inbound-poller.test.ts +235 -8
  30. package/src/services/alerting/inbound-poller.ts +95 -34
  31. package/src/services/alerting/inbound.test.ts +213 -2
  32. package/src/services/alerting/inbound.ts +161 -32
  33. package/src/services/alerting/interview-responder.test.ts +0 -32
  34. package/src/services/alerting/interview-responder.ts +6 -17
  35. package/src/services/alerting/notify-deps.ts +113 -0
  36. package/src/services/alerting/run-monitor.ts +0 -1
  37. package/src/services/alerting/store.test.ts +1 -1
  38. package/src/services/alerting/store.ts +0 -2
  39. package/src/services/alerting/sweep-runner.test.ts +11 -2
  40. package/src/services/alerting/sweep-runner.ts +14 -7
  41. package/src/services/alerting/tokens.ts +39 -1
  42. package/src/services/audit/backup-source.ts +54 -0
  43. package/src/services/audit/backups.test.ts +7 -2
  44. package/src/services/audit/backups.ts +10 -18
  45. package/src/services/backup-cipher.test.ts +188 -0
  46. package/src/services/backup-cipher.ts +178 -0
  47. package/src/services/backup-create.ts +20 -30
  48. package/src/services/backup-envelope-roundtrip.test.ts +6 -26
  49. package/src/services/backup-restore.ts +10 -16
  50. package/src/services/backup-schedule.ts +35 -0
  51. package/src/services/backup-sweep.test.ts +148 -0
  52. package/src/services/backup-sweep.ts +124 -0
  53. package/src/services/deploy-posture.ts +15 -2
  54. package/src/services/module-journal.test.ts +302 -0
  55. package/src/services/module-journal.ts +160 -0
  56. package/src/services/module-operations.test.ts +67 -6
  57. package/src/services/module-operations.ts +69 -19
  58. package/src/services/module-subscriptions.test.ts +33 -2
  59. package/src/services/module-subscriptions.ts +10 -1
  60. package/src/services/module-validator/typescript-build.test.ts +20 -1
  61. package/src/services/module-validator/typescript-build.ts +9 -5
  62. package/src/services/restore-from-file.ts +6 -21
  63. package/src/templates/generator.test.ts +88 -0
  64. package/src/templates/generator.ts +119 -16
@@ -13,13 +13,23 @@
13
13
  * throw err;
14
14
  * }
15
15
  *
16
- * Rows with status='in_progress' whose pid is no longer alive are
17
- * treated as abandoned (the process crashed before the completion
18
- * update landed) and ignored by `checkInFlight()`. This keeps a single
19
- * Ctrl-C from wedging the system, at the cost of leaving stale rows in
20
- * the table; a future cleanup command can sweep them.
16
+ * A row with status='in_progress' stops holding the lock once it looks
17
+ * abandoned, which is three different things:
18
+ *
19
+ * - the process is GONE — it crashed before writing completion
20
+ * - the process is STOPPED — suspended (Ctrl-Z) or a zombie, so it
21
+ * will never reach the completion write
22
+ * - the row is OLD — past `OPERATION_TTL_MS`
23
+ *
24
+ * Only the first was originally handled, and the other two are not
25
+ * hypothetical: a `module deploy` Ctrl-Z'd on a lost terminal held the
26
+ * lock for 20 days and blocked every backup on the fleet.
27
+ *
28
+ * Stale rows are ignored rather than deleted; `celilo module operations`
29
+ * lists them and `... clear` sweeps them.
21
30
  */
22
31
 
32
+ import { spawnSync } from 'node:child_process';
23
33
  import { randomUUID } from 'node:crypto';
24
34
  import { eq } from 'drizzle-orm';
25
35
  import { getDb } from '../db/client';
@@ -62,17 +72,51 @@ export function failOperation(operationId: string, error: unknown): void {
62
72
  }
63
73
 
64
74
  /**
65
- * True if the OS still has a process with the given pid. `kill(pid, 0)`
66
- * sends no signal but throws ESRCH if the process is gone the standard
67
- * idiom for liveness on POSIX.
75
+ * How long an in_progress row may hold the lock before it is treated as
76
+ * abandoned regardless of what its process appears to be doing.
77
+ *
78
+ * This is not belt-and-braces on the liveness check — it is the only
79
+ * check that survives pid reuse. A pid is a recycled number, not a
80
+ * stable identity: a busy host wraps the whole pid space in days, after
81
+ * which an old row's pid names an unrelated live process and the
82
+ * liveness check happily reports "still running" forever. Ageing the row
83
+ * out is the only thing that ends that.
84
+ *
85
+ * Two hours is longer than any real deploy and short enough that a wedge
86
+ * is an inconvenience rather than an outage.
87
+ */
88
+ export const OPERATION_TTL_MS = 2 * 60 * 60 * 1000;
89
+
90
+ /**
91
+ * True if the process can still make progress on its operation.
92
+ *
93
+ * `kill(pid, 0)` answers only "does this pid exist". A STOPPED process —
94
+ * SIGTSTP from a Ctrl-Z, or a lost controlling terminal — passes that
95
+ * test while being permanently unable to finish, which is exactly how
96
+ * the 20-day wedge happened. `ps -o state=` reports the state itself and
97
+ * is spelled the same on Linux and macOS.
68
98
  */
69
- export function isPidAlive(pid: number): boolean {
70
- try {
71
- process.kill(pid, 0);
72
- return true;
73
- } catch {
74
- return false;
99
+ export function isPidRunnable(pid: number): boolean {
100
+ const result = spawnSync('ps', ['-o', 'state=', '-p', String(pid)], { encoding: 'utf-8' });
101
+
102
+ // No usable `ps`. Fall back to bare existence: a stopped process will
103
+ // still block, which is the old behavior, but we never wrongly release
104
+ // a lock that a live operation is holding.
105
+ if (result.error) {
106
+ try {
107
+ process.kill(pid, 0);
108
+ return true;
109
+ } catch {
110
+ return false;
111
+ }
75
112
  }
113
+
114
+ if (result.status !== 0) return false; // no such process
115
+
116
+ // Linux reports multi-character states ("Tl", "Ss"); the first
117
+ // character is the state proper. T = stopped, Z = zombie.
118
+ const state = result.stdout.trim()[0] ?? '';
119
+ return state !== 'T' && state !== 'Z';
76
120
  }
77
121
 
78
122
  export interface InFlightConflict {
@@ -82,9 +126,10 @@ export interface InFlightConflict {
82
126
  }
83
127
 
84
128
  /**
85
- * Returns rows that genuinely look in-flight: status='in_progress' AND
86
- * the originating process is still alive. Abandoned rows (process gone)
87
- * are excluded so a stale Ctrl-C doesn't block future operations.
129
+ * Returns rows that genuinely look in-flight: status='in_progress', the
130
+ * row is younger than `OPERATION_TTL_MS`, AND the originating process is
131
+ * still able to make progress. Everything else is abandoned and excluded,
132
+ * so a crashed, suspended, or forgotten operation cannot wedge the fleet.
88
133
  *
89
134
  * @param excludeOperationId - operation id to exclude from the check
90
135
  * (so an operation doesn't see itself as a conflict).
@@ -97,10 +142,13 @@ export function checkInFlight(excludeOperationId?: string): InFlightConflict[] {
97
142
  .where(eq(moduleOperations.status, 'in_progress'))
98
143
  .all();
99
144
 
145
+ const now = Date.now();
100
146
  const conflicts: InFlightConflict[] = [];
101
147
  for (const row of rows) {
102
148
  if (excludeOperationId && row.id === excludeOperationId) continue;
103
- if (!isPidAlive(row.pid)) continue;
149
+ // Age first: it costs nothing, where the liveness probe spawns `ps`.
150
+ if (now - row.startedAt.getTime() > OPERATION_TTL_MS) continue;
151
+ if (!isPidRunnable(row.pid)) continue;
104
152
  conflicts.push({
105
153
  operation: row,
106
154
  describe: `${row.operation} of ${row.moduleId} (pid ${row.pid})`,
@@ -117,8 +165,10 @@ export function checkInFlight(excludeOperationId?: string): InFlightConflict[] {
117
165
  export class InFlightError extends Error {
118
166
  constructor(public readonly conflicts: InFlightConflict[]) {
119
167
  const list = conflicts.map((c) => ` • ${c.describe}`).join('\n');
168
+ const hint =
169
+ 'If it is not really running: "celilo module operations" to inspect, "celilo module operations clear" to release.';
120
170
  super(
121
- `Cannot start: another module operation is in progress.\n${list}\nWait for it to complete (or fail) and re-run.`,
171
+ `Cannot start: another module operation is in progress.\n${list}\nWait for it to complete (or fail) and re-run.\n${hint}`,
122
172
  );
123
173
  this.name = 'InFlightError';
124
174
  }
@@ -158,6 +158,34 @@ describe('register / unregister roundtrip', () => {
158
158
  expect(result.registered).toBe(0);
159
159
  });
160
160
 
161
+ it('a module with an on_backup hook arms the scheduled backup sweep', () => {
162
+ // The sweep is a system-level subscriber, not a module one, so it does not
163
+ // count toward `registered`. It appears the moment the fleet has something
164
+ // to back up — including on `module update`, which is how a manifest that
165
+ // newly declares a cadence reaches an already-deployed fleet.
166
+ const result = registerModuleSubscriptions(
167
+ baseManifest({ hooks: { on_backup: { script: 'backup.ts' } } }),
168
+ '/p',
169
+ );
170
+ expect(result.registered).toBe(0);
171
+
172
+ const bus = openBus({ dbPath, events: defineEvents({}) });
173
+ try {
174
+ const row = bus.db
175
+ .query<{ name: string; pattern: string; handler: string }, []>(
176
+ 'SELECT name, pattern, handler FROM subscribers',
177
+ )
178
+ .get();
179
+ expect(row).toEqual({
180
+ name: 'celilo-backup-sweep',
181
+ pattern: 'timer.tick.1h',
182
+ handler: 'celilo backup sweep',
183
+ });
184
+ } finally {
185
+ bus.close();
186
+ }
187
+ });
188
+
161
189
  it('registers each subscription as a row, names scoped to module id', () => {
162
190
  const result = registerModuleSubscriptions(
163
191
  baseManifest({
@@ -358,7 +386,10 @@ describe('build-bus registry-poll wiring (ISS-0139)', () => {
358
386
  expect(poll).toBeDefined();
359
387
  expect(poll?.pattern).toBe('timer.tick.15m');
360
388
  // A literal handler command (the CLI poll), not a hook — see manifest comment.
361
- expect(poll?.handler).toBe('celilo module upgrade');
389
+ // `--poll` is load-bearing: the dispatcher appends the event id to a
390
+ // subprocess handler, and without the flag it lands in the module-name slot
391
+ // ("Module not found: <event_id>" every tick).
392
+ expect(poll?.handler).toBe('celilo module upgrade --poll');
362
393
  expect(poll?.hook).toBeUndefined();
363
394
 
364
395
  const resolved = resolveSubscription(
@@ -368,6 +399,6 @@ describe('build-bus registry-poll wiring (ISS-0139)', () => {
368
399
  '/modules/celilo-mgmt',
369
400
  );
370
401
  expect(resolved.name).toBe('celilo-mgmt.registry-poll');
371
- expect(resolved.handler).toBe('celilo module upgrade');
402
+ expect(resolved.handler).toBe('celilo module upgrade --poll');
372
403
  });
373
404
  });
@@ -21,6 +21,7 @@ import { getEventBusPath, getModuleStoragePath } from '../config/paths';
21
21
  import { getDb } from '../db/client';
22
22
  import { modules } from '../db/schema';
23
23
  import type { ModuleManifest, ModuleSubscription } from '../manifest/schema';
24
+ import { ensureBackupSweepSubscriber } from './backup-sweep';
24
25
 
25
26
  /**
26
27
  * The bus is opened by the celilo CLI without an event registry — the
@@ -90,10 +91,18 @@ export function registerModuleSubscriptions(
90
91
  modulePath: string,
91
92
  ): { registered: number } {
92
93
  const subs = manifest.subscriptions ?? [];
93
- if (subs.length === 0) return { registered: 0 };
94
+ const backupSweep = Boolean(manifest.hooks?.on_backup);
95
+ if (subs.length === 0 && !backupSweep) return { registered: 0 };
94
96
 
95
97
  const bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
96
98
  try {
99
+ // A module that can be backed up is also what switches the scheduled
100
+ // backup sweep on. Registering here rather than at system init means the
101
+ // sweep appears the moment the fleet has something to back up, and — since
102
+ // `module update` comes through here too — a manifest that newly declares a
103
+ // cadence arms the sweep on the same update that declares it. Idempotent.
104
+ if (backupSweep) ensureBackupSweepSubscriber(bus);
105
+
97
106
  for (const sub of subs) {
98
107
  const resolved = resolveSubscription(sub, manifest.id, modulePath);
99
108
  bus.subscribe(resolved);
@@ -43,11 +43,30 @@ describe('checkTypeScriptBuild', () => {
43
43
  }
44
44
  });
45
45
 
46
- test('fail with helpful message when tsconfig present but no node_modules', async () => {
46
+ // A tsconfig at the module ROOT is not the one we run: it lives in scripts/,
47
+ // next to the package.json and node_modules that make @celilo/capabilities
48
+ // resolve. Looking at the wrong level is why this check never fired.
49
+ test('ignores a tsconfig at the module root', async () => {
47
50
  const dir = mkdtempSync(join(tmpdir(), 'celilo-tsc-'));
48
51
  try {
52
+ mkdirSync(join(dir, 'scripts'));
53
+ writeFileSync(join(dir, 'scripts', 'install.ts'), 'export const x = 1;\n');
49
54
  writeFileSync(join(dir, 'tsconfig.json'), '{}');
50
55
  const r = await checkTypeScriptBuild(dir);
56
+ expect(r.status).toBe('warn');
57
+ expect(r.message).toContain('scripts/tsconfig.json');
58
+ } finally {
59
+ rmSync(dir, { recursive: true, force: true });
60
+ }
61
+ });
62
+
63
+ test('fail with helpful message when tsconfig present but no node_modules', async () => {
64
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-tsc-'));
65
+ try {
66
+ mkdirSync(join(dir, 'scripts'));
67
+ writeFileSync(join(dir, 'scripts', 'install.ts'), 'export const x = 1;\n');
68
+ writeFileSync(join(dir, 'scripts', 'tsconfig.json'), '{}');
69
+ const r = await checkTypeScriptBuild(dir);
51
70
  expect(r.status).toBe('fail');
52
71
  expect(r.message).toContain('node_modules');
53
72
  expect(r.message).toContain('bun install');
@@ -59,7 +59,11 @@ export async function checkTypeScriptBuild(
59
59
  };
60
60
  }
61
61
 
62
- const hasTsConfig = existsSync(join(modulePath, 'tsconfig.json'));
62
+ // tsconfig.json, package.json and node_modules all live in scripts/, not at
63
+ // the module root: scripts/ is the standalone package that resolves
64
+ // @celilo/capabilities the way the deployed module does.
65
+ const scriptsDir = join(modulePath, 'scripts');
66
+ const hasTsConfig = existsSync(join(scriptsDir, 'tsconfig.json'));
63
67
  const hasTsFiles = await hasTypeScriptSources(modulePath);
64
68
 
65
69
  if (!hasTsConfig && !hasTsFiles) {
@@ -77,21 +81,21 @@ export async function checkTypeScriptBuild(
77
81
  name: 'tsc --noEmit',
78
82
  status: 'warn',
79
83
  message:
80
- 'module has .ts files but no tsconfig.json; add one so we can typecheck against current @celilo types',
84
+ 'module has .ts files but no scripts/tsconfig.json; add one (extending modules/tsconfig.scripts.base.json) so we can typecheck against current @celilo types',
81
85
  };
82
86
  }
83
87
 
84
- if (!existsSync(join(modulePath, 'node_modules'))) {
88
+ if (!existsSync(join(scriptsDir, 'node_modules'))) {
85
89
  return {
86
90
  category: 'typescript_build',
87
91
  name: 'tsc --noEmit',
88
92
  status: 'fail',
89
- message: 'no node_modules — run `bun install` in the module directory first',
93
+ message: 'no node_modules — run `bun install` in the module scripts/ directory first',
90
94
  };
91
95
  }
92
96
 
93
97
  const r = spawnSync('bunx', ['tsc', '--noEmit'], {
94
- cwd: modulePath,
98
+ cwd: scriptsDir,
95
99
  encoding: 'utf-8',
96
100
  });
97
101
 
@@ -30,7 +30,6 @@ import {
30
30
  readSync,
31
31
  readdirSync,
32
32
  rmSync,
33
- writeFileSync,
34
33
  } from 'node:fs';
35
34
  import { tmpdir } from 'node:os';
36
35
  import { dirname, join } from 'node:path';
@@ -42,9 +41,9 @@ import { modules } from '../db/schema';
42
41
  import { invokeHook } from '../hooks/executor';
43
42
  import { createConsoleLogger } from '../hooks/logger';
44
43
  import type { ModuleManifest } from '../manifest/schema';
45
- import { decryptSecret } from '../secrets/encryption';
46
44
  import { getOrCreateMasterKey } from '../secrets/master-key';
47
45
  import { shellEscape } from '../utils/shell';
46
+ import { decryptFileToFile } from './backup-cipher';
48
47
  import { assertCompatibleSchema, parseManifest } from './backup-manifest';
49
48
  import { applyCrossModuleWriteRoot, moduleHasCrossModuleRead } from './cross-module-read';
50
49
  import { getModuleSystems } from './deployed-systems';
@@ -98,8 +97,8 @@ export async function restoreFromArtifactFile(
98
97
  return { success: false, error: `Artifact not found: ${filePath}` };
99
98
  }
100
99
 
101
- // 1. Decrypt the encrypted envelope (JSON wrapper, identical to the
102
- // shape backup-create.ts writes).
100
+ // 1. Decrypt the artifact into the inner tar (see backup-cipher.ts —
101
+ // handles both the streamed format and the legacy JSON envelope).
103
102
  let masterKey: Buffer;
104
103
  try {
105
104
  masterKey = await getOrCreateMasterKey();
@@ -110,37 +109,23 @@ export async function restoreFromArtifactFile(
110
109
  };
111
110
  }
112
111
 
113
- let encryptedJson: unknown;
114
- try {
115
- encryptedJson = JSON.parse(readFileSync(filePath, 'utf-8'));
116
- } catch (err) {
117
- return {
118
- success: false,
119
- error: `Artifact is not valid JSON: ${err instanceof Error ? err.message : String(err)}. The file may be corrupted or not a celilo backup artifact.`,
120
- };
121
- }
122
-
123
112
  const tempDir = join(tmpdir(), `celilo-restore-from-file-${Date.now()}`);
124
113
  const envelopeDir = join(tempDir, 'envelope');
125
114
 
126
115
  try {
127
116
  mkdirSync(envelopeDir, { recursive: true });
128
117
 
129
- // Decrypt the JSON wrapper into the inner tar bytes.
130
- let tarData: Buffer;
118
+ const tarPath = join(tempDir, 'envelope.tar');
131
119
  try {
132
- const base64 = decryptSecret(encryptedJson as Parameters<typeof decryptSecret>[0], masterKey);
133
- tarData = Buffer.from(base64, 'base64');
120
+ await decryptFileToFile(filePath, tarPath, masterKey);
134
121
  } catch (err) {
135
122
  return {
136
123
  success: false,
137
- error: `Decryption failed: ${err instanceof Error ? err.message : String(err)}. The master key may not match the one used at backup time.`,
124
+ error: `Decryption failed: ${err instanceof Error ? err.message : String(err)}. Either the master key does not match the one used at backup time, or the file is corrupted / not a celilo backup artifact.`,
138
125
  };
139
126
  }
140
127
 
141
128
  // Extract the envelope tar.
142
- const tarPath = join(tempDir, 'envelope.tar');
143
- writeFileSync(tarPath, tarData);
144
129
  execSync(`tar -xf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)}`);
145
130
 
146
131
  // 2. Read + validate the envelope manifest.
@@ -6,6 +6,7 @@ import { type DbClient, createDbClient } from '../db/client';
6
6
  import { capabilities } from '../db/schema';
7
7
  import { upsertModuleConfig } from '../services/module-config';
8
8
  import {
9
+ decideStorage,
9
10
  decideTargetNode,
10
11
  discoverTemplateFiles,
11
12
  generateTemplates,
@@ -13,6 +14,7 @@ import {
13
14
  injectProxmoxDns,
14
15
  isTemplateFile,
15
16
  readTemplateFiles,
17
+ storageFromTfState,
16
18
  targetNodeFromTfState,
17
19
  writeGeneratedFiles,
18
20
  } from './generator';
@@ -845,3 +847,89 @@ describe('decideTargetNode (ISS-0090 — deploy follows reality: Proxmox > state
845
847
  ).toEqual({ node: 'node2', source: 'proxmox' });
846
848
  });
847
849
  });
850
+
851
+ describe("storageFromTfState (terraform state is celilo's storage record)", () => {
852
+ test('reads storage from the proxmox_lxc rootfs block', () => {
853
+ const state = {
854
+ resources: [
855
+ {
856
+ type: 'proxmox_lxc',
857
+ instances: [
858
+ {
859
+ attributes: {
860
+ rootfs: [{ storage: 'local-lvm', volume: 'local-lvm:vm-204-disk-0' }],
861
+ },
862
+ },
863
+ ],
864
+ },
865
+ ],
866
+ };
867
+ expect(storageFromTfState(state)).toBe('local-lvm');
868
+ });
869
+
870
+ test('falls back to the volume prefix when storage is absent', () => {
871
+ const state = {
872
+ resources: [
873
+ {
874
+ type: 'proxmox_lxc',
875
+ instances: [{ attributes: { rootfs: [{ volume: 'local-lvm:vm-204-disk-0' }] } }],
876
+ },
877
+ ],
878
+ };
879
+ expect(storageFromTfState(state)).toBe('local-lvm');
880
+ });
881
+
882
+ test('accepts a bare rootfs object as well as a block list', () => {
883
+ const state = {
884
+ resources: [
885
+ { type: 'proxmox_lxc', instances: [{ attributes: { rootfs: { storage: 'datacenter' } } }] },
886
+ ],
887
+ };
888
+ expect(storageFromTfState(state)).toBe('datacenter');
889
+ });
890
+
891
+ test('reads a VM data disk, ignoring the cloudinit disk (type:vm)', () => {
892
+ const state = {
893
+ resources: [
894
+ {
895
+ type: 'proxmox_vm_qemu',
896
+ instances: [
897
+ {
898
+ attributes: {
899
+ disk: [
900
+ { type: 'cloudinit', storage: 'datacenter' },
901
+ { type: 'disk', storage: 'local-lvm' },
902
+ ],
903
+ },
904
+ },
905
+ ],
906
+ },
907
+ ],
908
+ };
909
+ expect(storageFromTfState(state)).toBe('local-lvm');
910
+ });
911
+
912
+ test('returns null on a fresh/empty state', () => {
913
+ expect(storageFromTfState({ resources: [] })).toBeNull();
914
+ expect(storageFromTfState({})).toBeNull();
915
+ expect(storageFromTfState({ resources: [{ type: 'proxmox_lxc', instances: [] }] })).toBeNull();
916
+ });
917
+ });
918
+
919
+ describe('decideStorage (storage is sticky — a changed default must not force replacement)', () => {
920
+ test('an existing container keeps the storage it was created on', () => {
921
+ // The celilo-registry case: lxc 204 lives on local-lvm, the service default
922
+ // later became datacenter. Emitting the default would plan 1-to-destroy.
923
+ expect(decideStorage({ stateStorage: 'local-lvm', defaultStorage: 'datacenter' })).toEqual({
924
+ storage: 'local-lvm',
925
+ source: 'state',
926
+ });
927
+ });
928
+
929
+ test('first create (no state) uses the service default', () => {
930
+ expect(decideStorage({ stateStorage: null, defaultStorage: 'datacenter' })).toEqual({
931
+ storage: 'datacenter',
932
+ source: 'default',
933
+ });
934
+ });
935
+ });
@@ -248,12 +248,51 @@ export function targetNodeFromTfState(state: {
248
248
  }
249
249
 
250
250
  /**
251
- * Read the node a module's container is currently deployed on, from its terraform
252
- * state file. This is celilo's authoritative record of placement (ISS-0090).
253
- * Returns null when no state exists yet (a first deploy) or it can't be read
254
- * the caller then falls back to the service `default_target_node`.
251
+ * The storage an existing container's root disk actually lives on, from
252
+ * terraform state. Prefers the LXC `rootfs.storage` attribute, falls back to the
253
+ * `<storage>:vm-<vmid>-disk-0` volume prefix, then to a VM's data `disk` block.
254
+ * Returns null when there's no such resource (e.g. an empty/fresh state).
255
255
  */
256
- async function readDeployedTargetNode(moduleId: string): Promise<string | null> {
256
+ export function storageFromTfState(state: {
257
+ resources?: Array<{
258
+ type?: string;
259
+ instances?: Array<{
260
+ attributes?: {
261
+ rootfs?:
262
+ | Array<{ storage?: string; volume?: string }>
263
+ | { storage?: string; volume?: string };
264
+ disk?: Array<{ storage?: string; type?: string }>;
265
+ };
266
+ }>;
267
+ }>;
268
+ }): string | null {
269
+ const guest = state.resources?.find(
270
+ (r) => r.type === 'proxmox_lxc' || r.type === 'proxmox_vm_qemu',
271
+ );
272
+ const attrs = guest?.instances?.[0]?.attributes;
273
+ if (!attrs) {
274
+ return null;
275
+ }
276
+ // The provider models `rootfs` as a single-element block list; older state
277
+ // shapes record it as a bare object.
278
+ const rootfs = Array.isArray(attrs.rootfs) ? attrs.rootfs[0] : attrs.rootfs;
279
+ if (rootfs?.storage) {
280
+ return rootfs.storage;
281
+ }
282
+ // volume format: "<storage>:vm-<vmid>-disk-0"
283
+ const volumeStorage = rootfs?.volume?.split(':')[0];
284
+ if (volumeStorage) {
285
+ return volumeStorage;
286
+ }
287
+ // VMs: the data disk carries the storage that matters (the cloudinit disk
288
+ // rides on the same storage — see the `disk` block ordering note in the
289
+ // vm templates).
290
+ const disks = attrs.disk ?? [];
291
+ return (disks.find((d) => d.type === 'disk') ?? disks[0])?.storage || null;
292
+ }
293
+
294
+ /** Parse a module's terraform state, or null when absent/unreadable. */
295
+ async function readTfState(moduleId: string): Promise<Record<string, unknown> | null> {
257
296
  const statePath = join(
258
297
  getModuleStoragePath(),
259
298
  moduleId,
@@ -265,12 +304,33 @@ async function readDeployedTargetNode(moduleId: string): Promise<string | null>
265
304
  return null;
266
305
  }
267
306
  try {
268
- return targetNodeFromTfState(JSON.parse(await readFile(statePath, 'utf-8')));
307
+ return JSON.parse(await readFile(statePath, 'utf-8'));
269
308
  } catch {
270
309
  return null;
271
310
  }
272
311
  }
273
312
 
313
+ /**
314
+ * Read the node a module's container is currently deployed on, from its terraform
315
+ * state file. This is celilo's authoritative record of placement (ISS-0090).
316
+ * Returns null when no state exists yet (a first deploy) or it can't be read —
317
+ * the caller then falls back to the service `default_target_node`.
318
+ */
319
+ async function readDeployedTargetNode(moduleId: string): Promise<string | null> {
320
+ const state = await readTfState(moduleId);
321
+ return state ? targetNodeFromTfState(state) : null;
322
+ }
323
+
324
+ /**
325
+ * Read the storage a module's container is currently deployed on, from its
326
+ * terraform state file. Returns null on a first deploy — the caller then falls
327
+ * back to the service default storage.
328
+ */
329
+ async function readDeployedStorage(moduleId: string): Promise<string | null> {
330
+ const state = await readTfState(moduleId);
331
+ return state ? storageFromTfState(state) : null;
332
+ }
333
+
274
334
  /**
275
335
  * The node a module's container ACTUALLY lives on, from Proxmox (ISS-0090).
276
336
  * Proxmox is the ultimate source of truth for current location — it sees a
@@ -314,6 +374,27 @@ export function decideTargetNode(opts: {
314
374
  return { node: opts.defaultNode, source: 'default' };
315
375
  }
316
376
 
377
+ /**
378
+ * Decide which storage to target for a deploy. Pure (Rule 10). The sibling of
379
+ * `decideTargetNode`: the service's `storage` governs only a FIRST placement.
380
+ * A container already living on `local-lvm` must keep living there when the
381
+ * service default later changes to `datacenter` — emitting the new default
382
+ * makes terraform plan a destroy-and-recreate ("forces replacement") of a
383
+ * running container and lose its volume. Relocating storage is a deliberate
384
+ * migration, never a side effect of a default drifting.
385
+ *
386
+ * ponytail: terraform state only, no Proxmox-reality tier like decideTargetNode
387
+ * has. A hand `pct move-volume` is invisible until the next apply refreshes
388
+ * state; add a Proxmox read if that ever bites.
389
+ */
390
+ export function decideStorage(opts: {
391
+ stateStorage: string | null;
392
+ defaultStorage: string;
393
+ }): { storage: string; source: 'state' | 'default' } {
394
+ if (opts.stateStorage) return { storage: opts.stateStorage, source: 'state' };
395
+ return { storage: opts.defaultStorage, source: 'default' };
396
+ }
397
+
317
398
  /**
318
399
  * Discover template files in directory recursively
319
400
  *
@@ -784,6 +865,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
784
865
  // Resolved live each generate (ISS-0090) and injected into the context below,
785
866
  // never cached in the DB. undefined for non-Proxmox / machine deploys.
786
867
  let resolvedTargetNode: string | undefined;
868
+ let resolvedStorage: string | undefined;
787
869
  if (isContainerService && isProxmoxService && infrastructureSelection?.serviceId) {
788
870
  const service = await db
789
871
  .select()
@@ -824,11 +906,24 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
824
906
  );
825
907
  }
826
908
 
827
- // Persist only the non-drift provider values. target_node is reality it's
828
- // injected into the resolution context below, never cached (ISS-0090); drop
829
- // any stale __infra_target_node a prior generate left behind.
909
+ // Storage is sticky for the same reason placement is: the service default
910
+ // governs only a FIRST create. Emitting a changed default at an existing
911
+ // container makes terraform plan a destroy-and-recreate.
912
+ const storageDecision = decideStorage({
913
+ stateStorage: await readDeployedStorage(moduleId),
914
+ defaultStorage: providerConfig.storage,
915
+ });
916
+ resolvedStorage = storageDecision.storage;
917
+ if (storageDecision.source === 'state' && resolvedStorage !== providerConfig.storage) {
918
+ log.info(
919
+ `${moduleId} → storage '${resolvedStorage}' (from terraform state; service default is '${providerConfig.storage}'). Moving storage requires a deliberate migration.`,
920
+ );
921
+ }
922
+
923
+ // Persist only the non-drift provider values. target_node and storage are
924
+ // reality — they're injected into the resolution context below, never
925
+ // cached (ISS-0090); drop any stale __infra_* rows a prior generate left.
830
926
  upsertModuleConfig(db, moduleId, '__infra_lxc_template', providerConfig.lxc_template);
831
- upsertModuleConfig(db, moduleId, '__infra_storage', providerConfig.storage);
832
927
  // vm_template is only present once a VM template exists for the service.
833
928
  // Persist it when set so `type: vm` modules can resolve $self:vm_template;
834
929
  // a `type: vm` deploy against a service without one then fails loudly at
@@ -837,8 +932,12 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
837
932
  upsertModuleConfig(db, moduleId, '__infra_vm_template', providerConfig.vm_template);
838
933
  }
839
934
  deleteModuleConfig(db, moduleId, '__infra_target_node');
935
+ deleteModuleConfig(db, moduleId, '__infra_storage');
840
936
 
841
- log.success(`Infrastructure resolved: target_node=${decision.node} (${decision.source})`);
937
+ log.success(
938
+ `Infrastructure resolved: target_node=${decision.node} (${decision.source}), ` +
939
+ `storage=${storageDecision.storage} (${storageDecision.source})`,
940
+ );
842
941
  }
843
942
  }
844
943
 
@@ -870,15 +969,19 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
870
969
  context.selfConfig.target_ip = ipConfig.value!;
871
970
  }
872
971
 
873
- // target_node is the live-resolved reality (ISS-0090) inject it directly,
874
- // never from a cached __infra_target_node row (which drifts).
972
+ // target_node and storage are the live-resolved reality of an existing
973
+ // container (ISS-0090) — inject them directly, never from a cached
974
+ // __infra_* row (which drifts against the service default).
875
975
  if (resolvedTargetNode) {
876
976
  context.selfConfig.target_node = resolvedTargetNode;
877
977
  }
978
+ if (resolvedStorage) {
979
+ context.selfConfig.storage = resolvedStorage;
980
+ }
878
981
 
879
- // lxc_template / storage are provider config (intent, not drift-prone) — read
880
- // them back from the __infra_* rows persisted above.
881
- const infraKeys = ['lxc_template', 'storage', 'vm_template'];
982
+ // lxc_template / vm_template are provider config (intent, not drift-prone) —
983
+ // read them back from the __infra_* rows persisted above.
984
+ const infraKeys = ['lxc_template', 'vm_template'];
882
985
  for (const key of infraKeys) {
883
986
  const infraConfig = db
884
987
  .select()