@celilo/cli 0.14.2 → 0.15.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.
Files changed (56) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +18 -2
  3. package/drizzle/0018_drop_alert_policy_snapshot.sql +46 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +4 -4
  6. package/src/capabilities/public-web-publish.test.ts +15 -15
  7. package/src/cli/commands/alerts-list.ts +10 -0
  8. package/src/cli/commands/alerts-poll.ts +12 -6
  9. package/src/cli/commands/alerts-sweep.ts +22 -81
  10. package/src/cli/commands/backup-sweep.ts +65 -0
  11. package/src/cli/commands/module-operations.test.ts +93 -0
  12. package/src/cli/commands/module-operations.ts +134 -0
  13. package/src/cli/commands/module-upgrade.test.ts +32 -20
  14. package/src/cli/commands/module-upgrade.ts +37 -32
  15. package/src/cli/commands/monitor.ts +26 -6
  16. package/src/cli/commands/system-audit.ts +9 -68
  17. package/src/cli/completion.ts +18 -1
  18. package/src/cli/index.ts +11 -0
  19. package/src/db/schema.ts +5 -3
  20. package/src/hooks/capability-loader.ts +0 -12
  21. package/src/manifest/schema.ts +4 -1
  22. package/src/module/packaging/build.ts +4 -0
  23. package/src/services/alerting/builtin-source.ts +18 -51
  24. package/src/services/alerting/delivery-loop.test.ts +5 -1
  25. package/src/services/alerting/format.test.ts +0 -1
  26. package/src/services/alerting/inbound-poller.test.ts +44 -8
  27. package/src/services/alerting/inbound-poller.ts +65 -28
  28. package/src/services/alerting/notify-deps.ts +113 -0
  29. package/src/services/alerting/run-monitor.ts +0 -1
  30. package/src/services/alerting/store.test.ts +1 -1
  31. package/src/services/alerting/store.ts +0 -2
  32. package/src/services/alerting/sweep-runner.test.ts +11 -2
  33. package/src/services/alerting/sweep-runner.ts +14 -7
  34. package/src/services/audit/backup-source.ts +54 -0
  35. package/src/services/audit/backups.test.ts +7 -2
  36. package/src/services/audit/backups.ts +10 -18
  37. package/src/services/backup-cipher.test.ts +188 -0
  38. package/src/services/backup-cipher.ts +178 -0
  39. package/src/services/backup-create.ts +20 -30
  40. package/src/services/backup-envelope-roundtrip.test.ts +6 -26
  41. package/src/services/backup-restore.ts +10 -16
  42. package/src/services/backup-schedule.ts +35 -0
  43. package/src/services/backup-sweep.test.ts +148 -0
  44. package/src/services/backup-sweep.ts +124 -0
  45. package/src/services/deploy-posture.ts +15 -2
  46. package/src/services/machine-probe.test.ts +50 -0
  47. package/src/services/machine-probe.ts +73 -0
  48. package/src/services/module-operations.test.ts +67 -6
  49. package/src/services/module-operations.ts +69 -19
  50. package/src/services/module-subscriptions.test.ts +33 -2
  51. package/src/services/module-subscriptions.ts +10 -1
  52. package/src/services/module-validator/typescript-build.test.ts +20 -1
  53. package/src/services/module-validator/typescript-build.ts +9 -5
  54. package/src/services/restore-from-file.ts +6 -21
  55. package/src/templates/generator.test.ts +88 -0
  56. package/src/templates/generator.ts +119 -16
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Reachability probe for the machine pool.
3
+ *
4
+ * One implementation, deliberately. This SSH probe previously existed twice —
5
+ * once in `alerting/builtin-source.ts` for the monitor sweep and once in
6
+ * `cli/commands/system-audit.ts` for `celilo system audit` — as identical
7
+ * copy-pasted blocks. Both carried the same bug, and fixing one would have left
8
+ * the other reporting the management server as unreachable forever.
9
+ */
10
+
11
+ import { execFile } from 'node:child_process';
12
+ import { promisify } from 'node:util';
13
+ import type { MachineReachableResult } from './audit/machines-reachable';
14
+ import { listMachines } from './machine-pool';
15
+ import { LOCAL_MACHINE_IP } from './ssh-key-manager';
16
+
17
+ const execFileAsync = promisify(execFile);
18
+
19
+ /**
20
+ * Probe every machine in the pool.
21
+ *
22
+ * `BatchMode=yes` prevents a password prompt from hanging the probe forever,
23
+ * and `ConnectTimeout` bounds the wait on an unresponsive host — the exact
24
+ * condition this check exists to detect must not be the one that wedges it.
25
+ *
26
+ * The local management box is reported reachable WITHOUT probing it. celilo
27
+ * runs there as the `celilo` user and deliberately does not materialize an SSH
28
+ * key for itself, so `ssh root@127.0.0.1` fails with `Permission denied
29
+ * (publickey)` on a perfectly healthy host. Left unhandled that produced a
30
+ * permanently firing `machines_reachable` alert against celilo-mgr — and since
31
+ * that monitor is unsuppressible by design, nothing could explain it away.
32
+ *
33
+ * The check is also meaningless there: if the box running this code were
34
+ * unreachable, this code would not be running.
35
+ */
36
+ export async function probeMachines(): Promise<MachineReachableResult[]> {
37
+ const machines = await listMachines();
38
+ return Promise.all(
39
+ machines.map(async (m): Promise<MachineReachableResult> => {
40
+ if (m.ipAddress === LOCAL_MACHINE_IP) {
41
+ return { id: m.id, hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
42
+ }
43
+ try {
44
+ await execFileAsync(
45
+ 'ssh',
46
+ [
47
+ '-o',
48
+ 'BatchMode=yes',
49
+ '-o',
50
+ 'ConnectTimeout=5',
51
+ '-o',
52
+ 'StrictHostKeyChecking=no',
53
+ '-o',
54
+ 'UserKnownHostsFile=/dev/null',
55
+ `${m.sshUser}@${m.ipAddress}`,
56
+ 'true',
57
+ ],
58
+ { timeout: 8000 },
59
+ );
60
+ return { id: m.id, hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
61
+ } catch (err) {
62
+ const e = err as { stderr?: string; message?: string };
63
+ return {
64
+ id: m.id,
65
+ hostname: m.hostname,
66
+ ipAddress: m.ipAddress,
67
+ reachable: false,
68
+ message: e.stderr?.trim() || e.message || 'SSH probe failed',
69
+ };
70
+ }
71
+ }),
72
+ );
73
+ }
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
- import { spawnSync } from 'node:child_process';
2
+ import { spawn, spawnSync } from 'node:child_process';
3
3
  import { mkdtempSync, rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
@@ -7,10 +7,11 @@ import { closeDb } from '../db/client';
7
7
  import { runMigrations } from '../db/migrate';
8
8
  import {
9
9
  InFlightError,
10
+ OPERATION_TTL_MS,
10
11
  checkInFlight,
11
12
  completeOperation,
12
13
  failOperation,
13
- isPidAlive,
14
+ isPidRunnable,
14
15
  refuseIfInFlight,
15
16
  startOperation,
16
17
  } from './module-operations';
@@ -95,7 +96,7 @@ describe('module-operations', () => {
95
96
  const child = spawnSync('node', ['-e', 'process.exit(0)']);
96
97
  const deadPid = child.pid;
97
98
  expect(deadPid).toBeGreaterThan(0);
98
- expect(isPidAlive(deadPid)).toBe(false);
99
+ expect(isPidRunnable(deadPid)).toBe(false);
99
100
 
100
101
  // Insert a fake row with the dead pid via raw SQL (bypasses pid=process.pid in startOperation).
101
102
  const { getDb } = require('../db/client');
@@ -141,14 +142,74 @@ describe('module-operations', () => {
141
142
  });
142
143
  });
143
144
 
144
- describe('isPidAlive', () => {
145
+ describe('isPidRunnable', () => {
145
146
  it('returns true for the current process', () => {
146
- expect(isPidAlive(process.pid)).toBe(true);
147
+ expect(isPidRunnable(process.pid)).toBe(true);
147
148
  });
148
149
 
149
150
  it('returns false for a dead pid', () => {
150
151
  const child = spawnSync('node', ['-e', 'process.exit(0)']);
151
- expect(isPidAlive(child.pid as number)).toBe(false);
152
+ expect(isPidRunnable(child.pid as number)).toBe(false);
153
+ });
154
+
155
+ // The bug this whole module exists to prevent: a Ctrl-Z'd `module deploy`
156
+ // is still "alive" by kill(pid, 0) and held the backup lock for 20 days.
157
+ it('returns false for a STOPPED process, which kill(pid, 0) calls alive', () => {
158
+ const child = spawn('sleep', ['60'], { stdio: 'ignore' });
159
+ const pid = child.pid as number;
160
+ try {
161
+ expect(isPidRunnable(pid)).toBe(true);
162
+
163
+ child.kill('SIGSTOP');
164
+ // Wait for the state change to land in the process table.
165
+ for (let i = 0; i < 100 && isPidRunnable(pid); i++) spawnSync('sleep', ['0.01']);
166
+
167
+ // Still passes the old liveness test...
168
+ let existsByKill = true;
169
+ try {
170
+ process.kill(pid, 0);
171
+ } catch {
172
+ existsByKill = false;
173
+ }
174
+ expect(existsByKill).toBe(true);
175
+
176
+ // ...but is correctly reported as unable to make progress.
177
+ expect(isPidRunnable(pid)).toBe(false);
178
+ } finally {
179
+ child.kill('SIGCONT');
180
+ child.kill('SIGKILL');
181
+ }
182
+ });
183
+ });
184
+
185
+ describe('abandonment by age', () => {
186
+ function insertRow(id: string, pid: number, startedAt: Date): void {
187
+ const { getDb } = require('../db/client');
188
+ const { moduleOperations } = require('../db/schema');
189
+ getDb()
190
+ .insert(moduleOperations)
191
+ .values({
192
+ id,
193
+ moduleId: 'ancient',
194
+ operation: 'deploy',
195
+ status: 'in_progress',
196
+ pid,
197
+ startedAt,
198
+ })
199
+ .run();
200
+ }
201
+
202
+ it('ignores a row older than the TTL even though its process is alive', () => {
203
+ // process.pid is unquestionably running, so age is the only thing that
204
+ // can release this row. This is the pid-reuse case: an old row whose
205
+ // number now belongs to some unrelated healthy process.
206
+ insertRow('ancient-row', process.pid, new Date(Date.now() - OPERATION_TTL_MS - 60_000));
207
+ expect(checkInFlight()).toHaveLength(0);
208
+ });
209
+
210
+ it('still blocks on a young row whose process is alive', () => {
211
+ insertRow('fresh-row', process.pid, new Date(Date.now() - 60_000));
212
+ expect(checkInFlight()).toHaveLength(1);
152
213
  });
153
214
  });
154
215
  });
@@ -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
+ });