@celilo/cli 0.16.2 → 0.18.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 (64) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +39 -9
  3. package/drizzle/0019_backup_pid.sql +18 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +5 -5
  6. package/schemas/system_config.json +1 -1
  7. package/src/cli/command-tree-parser.ts +0 -1
  8. package/src/cli/commands/alerts-poll.ts +26 -1
  9. package/src/cli/commands/backup-sweep.ts +62 -0
  10. package/src/cli/commands/module-operations.test.ts +45 -1
  11. package/src/cli/commands/module-operations.ts +35 -12
  12. package/src/cli/commands/module-show.ts +1 -0
  13. package/src/cli/commands/storage-set-path.test.ts +281 -0
  14. package/src/cli/commands/storage-set-path.ts +190 -0
  15. package/src/cli/commands/system-audit.ts +14 -0
  16. package/src/cli/commands/system-migrate.ts +40 -0
  17. package/src/cli/commands/system-update.ts +6 -0
  18. package/src/cli/completion.ts +24 -3
  19. package/src/cli/fuel-gauge.ts +0 -1
  20. package/src/cli/generate-zsh-completion.ts +1 -1
  21. package/src/cli/index.ts +12 -0
  22. package/src/cli/tui/audit-state.test.ts +15 -1
  23. package/src/cli/tui/audit-state.ts +6 -0
  24. package/src/cli/tui/audit-tui.test.tsx +0 -1
  25. package/src/db/schema.ts +53 -9
  26. package/src/hooks/capability-loader.ts +30 -1
  27. package/src/ipam/allocator.ts +13 -3
  28. package/src/services/alerting/builtin-monitors.test.ts +42 -0
  29. package/src/services/alerting/builtin-monitors.ts +3 -0
  30. package/src/services/alerting/builtin-source.ts +15 -0
  31. package/src/services/alerting/inbound-poller.test.ts +63 -1
  32. package/src/services/alerting/inbound-poller.ts +42 -0
  33. package/src/services/alerting/read-records.ts +85 -0
  34. package/src/services/audit/abandoned-operations.test.ts +73 -0
  35. package/src/services/audit/abandoned-operations.ts +0 -0
  36. package/src/services/audit/disk-space.test.ts +111 -0
  37. package/src/services/audit/disk-space.ts +114 -0
  38. package/src/services/audit/index.test.ts +2 -0
  39. package/src/services/audit/index.ts +12 -0
  40. package/src/services/audit/transport-reads.test.ts +113 -0
  41. package/src/services/audit/transport-reads.ts +120 -0
  42. package/src/services/audit/types.ts +3 -0
  43. package/src/services/backup-create.ts +4 -4
  44. package/src/services/backup-in-flight-refusal.test.ts +2 -0
  45. package/src/services/backup-metadata.ts +4 -0
  46. package/src/services/backup-staging.test.ts +134 -0
  47. package/src/services/backup-staging.ts +192 -0
  48. package/src/services/backup-storage.ts +29 -0
  49. package/src/services/backup-sweep.test.ts +68 -0
  50. package/src/services/backup-sweep.ts +62 -0
  51. package/src/services/config-interview.ts +1 -1
  52. package/src/services/deploy-ansible.ts +0 -1
  53. package/src/services/disk-probe.test.ts +74 -0
  54. package/src/services/disk-probe.ts +145 -0
  55. package/src/services/fleet-checks.ts +15 -0
  56. package/src/services/module-operations.test.ts +22 -0
  57. package/src/services/module-operations.ts +48 -1
  58. package/src/services/module-subscriptions.test.ts +39 -6
  59. package/src/services/module-subscriptions.ts +6 -4
  60. package/src/services/module-types-generator.test.ts +6 -3
  61. package/src/services/module-types-generator.ts +12 -7
  62. package/src/services/storage-providers/local.ts +2 -1
  63. package/src/services/update/orchestrator.test.ts +2 -0
  64. package/src/variables/context.ts +6 -1
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Disk-usage probe for the machine pool.
3
+ *
4
+ * Structurally a sibling of `machine-probe.ts` — same pool, same SSH bounding —
5
+ * with one deliberate difference that is the whole reason this file has its own
6
+ * comment.
7
+ *
8
+ * ⚠️ THE LOCAL BOX IS MEASURED, NOT EXEMPTED.
9
+ *
10
+ * `probeMachines()` reports the management server reachable WITHOUT probing it,
11
+ * and that is correct there: celilo runs as a user with no SSH key for itself,
12
+ * so `ssh root@127.0.0.1` fails on a perfectly healthy host — and the question
13
+ * is meaningless anyway, since if this box were unreachable this code would not
14
+ * be running.
15
+ *
16
+ * None of that transfers to disk. The management server stages backups, caches
17
+ * modules, holds the celilo DB and writes the logs; it is the likeliest host in
18
+ * the fleet to fill, and it is the host that DID fill. A disk check that copied
19
+ * the probe's structure and inherited its local shortcut would skip the only
20
+ * machine the check exists to protect — reporting "all clear" about a
21
+ * filesystem it never looked at.
22
+ *
23
+ * So the local box reads `statfs` directly. There is no reachability question
24
+ * to answer about the machine running the code, only a usage one.
25
+ */
26
+
27
+ import { execFile } from 'node:child_process';
28
+ import { statfs } from 'node:fs/promises';
29
+ import { promisify } from 'node:util';
30
+ import type { DiskUsageResult } from './audit/disk-space';
31
+ import { listMachines } from './machine-pool';
32
+ import { LOCAL_MACHINE_IP } from './ssh-key-manager';
33
+
34
+ const execFileAsync = promisify(execFile);
35
+
36
+ /**
37
+ * Percent-used the way `df` reports it.
38
+ *
39
+ * Deliberately not `1 - bavail/blocks`. A filesystem reserves blocks for root,
40
+ * so free-to-root and free-to-everyone-else differ; `df` computes capacity
41
+ * against the space an ordinary process can actually use, and an operator
42
+ * comparing this alert to their own `df` output must see the same number.
43
+ */
44
+ export function percentUsed(totalBlocks: number, freeBlocks: number, availBlocks: number): number {
45
+ const used = totalBlocks - freeBlocks;
46
+ const usable = used + availBlocks;
47
+ if (usable <= 0) return 0;
48
+ return Math.round((used / usable) * 100);
49
+ }
50
+
51
+ /** Parse the data row of `df -P /`. Returns null when the output is unusable. */
52
+ export function parseDfOutput(
53
+ stdout: string,
54
+ ): { usedPercent: number; availableBytes: number } | null {
55
+ // -P guarantees one line per filesystem, so the row we want is the second.
56
+ const line = stdout.trim().split('\n')[1];
57
+ if (!line) return null;
58
+
59
+ // Filesystem 1024-blocks Used Available Capacity Mounted-on
60
+ const fields = line.trim().split(/\s+/);
61
+ if (fields.length < 5) return null;
62
+
63
+ const available = Number(fields[3]);
64
+ const percent = Number(fields[4]?.replace('%', ''));
65
+ if (!Number.isFinite(available) || !Number.isFinite(percent)) return null;
66
+
67
+ return { usedPercent: percent, availableBytes: available * 1024 };
68
+ }
69
+
70
+ async function probeLocal(hostname: string, ipAddress: string): Promise<DiskUsageResult> {
71
+ try {
72
+ const stats = await statfs('/');
73
+ return {
74
+ hostname,
75
+ ipAddress,
76
+ usedPercent: percentUsed(stats.blocks, stats.bfree, stats.bavail),
77
+ availableBytes: stats.bavail * stats.bsize,
78
+ };
79
+ } catch (err) {
80
+ return {
81
+ hostname,
82
+ ipAddress,
83
+ usedPercent: null,
84
+ message: err instanceof Error ? err.message : String(err),
85
+ };
86
+ }
87
+ }
88
+
89
+ async function probeRemote(
90
+ hostname: string,
91
+ ipAddress: string,
92
+ sshUser: string,
93
+ ): Promise<DiskUsageResult> {
94
+ try {
95
+ // Same bounding as machine-probe: BatchMode so a password prompt can never
96
+ // hang the probe, ConnectTimeout so an unresponsive host — the condition
97
+ // this check exists to notice — cannot wedge it.
98
+ const { stdout } = await execFileAsync(
99
+ 'ssh',
100
+ [
101
+ '-o',
102
+ 'BatchMode=yes',
103
+ '-o',
104
+ 'ConnectTimeout=5',
105
+ '-o',
106
+ 'StrictHostKeyChecking=no',
107
+ '-o',
108
+ 'UserKnownHostsFile=/dev/null',
109
+ `${sshUser}@${ipAddress}`,
110
+ 'df -P /',
111
+ ],
112
+ { timeout: 8000 },
113
+ );
114
+
115
+ const parsed = parseDfOutput(stdout);
116
+ if (!parsed) {
117
+ return {
118
+ hostname,
119
+ ipAddress,
120
+ usedPercent: null,
121
+ message: `unparseable df output: ${stdout}`,
122
+ };
123
+ }
124
+ return { hostname, ipAddress, ...parsed };
125
+ } catch (err) {
126
+ const e = err as { stderr?: string; message?: string };
127
+ return {
128
+ hostname,
129
+ ipAddress,
130
+ usedPercent: null,
131
+ message: e.stderr?.trim() || e.message || 'df probe failed',
132
+ };
133
+ }
134
+ }
135
+
136
+ export async function probeDiskUsage(): Promise<DiskUsageResult[]> {
137
+ const machines = await listMachines();
138
+ return Promise.all(
139
+ machines.map((m) =>
140
+ m.ipAddress === LOCAL_MACHINE_IP
141
+ ? probeLocal(m.hostname, m.ipAddress)
142
+ : probeRemote(m.hostname, m.ipAddress, m.sshUser),
143
+ ),
144
+ );
145
+ }
@@ -208,6 +208,21 @@ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): Fl
208
208
  remediations.push('`celilo events repair` to sweep stuck deliveries');
209
209
  }
210
210
 
211
+ // (1b) sole — a duplicate dispatcher can no longer START (it refuses), but one
212
+ // stranded before that shipped keeps running, and it makes every other check
213
+ // here ambiguous: `hb` is whichever of them wrote last. Fail rather than warn —
214
+ // celilo-mgr ran two for 40 days precisely because nothing reported it (#580).
215
+ if (health.dispatcherCount > 1) {
216
+ statuses.push('fail');
217
+ const pids = health.dispatchers.map((d) => d.pid).join(', ');
218
+ detail.push(
219
+ `${health.dispatcherCount} dispatchers are live on this bus (pids ${pids}) — only one may run`,
220
+ );
221
+ remediations.push(
222
+ 'stop the unsupervised one: compare `systemctl show celilo-events.service -p MainPID` against those pids and kill the pid systemd does not own',
223
+ );
224
+ }
225
+
211
226
  // (2) supervised — a unit file exists (user or system scope). A
212
227
  // running dispatcher with NO unit is the orphan case: works now, gone
213
228
  // after reboot.
@@ -7,9 +7,12 @@ import { closeDb } from '../db/client';
7
7
  import { runMigrations } from '../db/migrate';
8
8
  import {
9
9
  InFlightError,
10
+ OPERATIONS_SWEEP_PATTERN,
11
+ OPERATIONS_SWEEP_SUBSCRIBER,
10
12
  OPERATION_TTL_MS,
11
13
  checkInFlight,
12
14
  completeOperation,
15
+ ensureOperationsSweepSubscriber,
13
16
  failOperation,
14
17
  isPidRunnable,
15
18
  refuseIfInFlight,
@@ -213,3 +216,22 @@ describe('module-operations', () => {
213
216
  });
214
217
  });
215
218
  });
219
+
220
+ describe('ensureOperationsSweepSubscriber', () => {
221
+ it('registers the hourly reclaim against the existing clear command', () => {
222
+ const calls: Array<{ name: string; pattern: string; handler: string; registeredBy?: string }> =
223
+ [];
224
+ ensureOperationsSweepSubscriber({ subscribe: (options) => calls.push(options) });
225
+
226
+ expect(calls).toEqual([
227
+ {
228
+ name: OPERATIONS_SWEEP_SUBSCRIBER,
229
+ pattern: OPERATIONS_SWEEP_PATTERN,
230
+ handler: 'celilo module operations clear',
231
+ registeredBy: 'celilo-module-operations',
232
+ },
233
+ ]);
234
+ // Finer than the TTL, so a wedged row never survives long.
235
+ expect(OPERATIONS_SWEEP_PATTERN).toBe('timer.tick.1h');
236
+ });
237
+ });
@@ -26,7 +26,8 @@
26
26
  * lock for 20 days and blocked every backup on the fleet.
27
27
  *
28
28
  * Stale rows are ignored rather than deleted; `celilo module operations`
29
- * lists them and `... clear` sweeps them.
29
+ * lists them and `... clear` sweeps them — on an hourly bus tick, not
30
+ * only when a human remembers (see `ensureOperationsSweepSubscriber`).
30
31
  */
31
32
 
32
33
  import { spawnSync } from 'node:child_process';
@@ -119,6 +120,52 @@ export function isPidRunnable(pid: number): boolean {
119
120
  return state !== 'T' && state !== 'Z';
120
121
  }
121
122
 
123
+ /**
124
+ * The `errorMessage` written when a row is released as abandoned.
125
+ *
126
+ * Load-bearing, not cosmetic: releasing marks rows `failed` rather than
127
+ * deleting them, and this exact string is what later distinguishes "the
128
+ * operation reported a failure" from "the operation never reported
129
+ * anything and the sweep reclaimed it". The abandoned-operations audit
130
+ * counts rows by it (`services/audit/abandoned-operations.ts`).
131
+ */
132
+ export const ABANDONED_RELEASE_MESSAGE = 'abandoned — released by "celilo module operations clear"';
133
+
134
+ /**
135
+ * Reclaim abandoned rows on a schedule instead of when a human remembers.
136
+ *
137
+ * Registered as an ordinary bus subscriber whose handler is the existing
138
+ * `celilo module operations clear`, exactly like the backup sweep
139
+ * (`services/backup-sweep.ts`) — no new command and no new scheduler.
140
+ * Hourly is far finer than the two-hour TTL, so a wedge never survives
141
+ * long, and clearing is idempotent: a pass with nothing abandoned is a
142
+ * single read.
143
+ *
144
+ * `clear` without `--all` only touches rows that `checkInFlight` already
145
+ * ignores, so the sweep can never release a lock a live operation holds.
146
+ */
147
+ export const OPERATIONS_SWEEP_SUBSCRIBER = 'celilo-operations-sweep';
148
+ export const OPERATIONS_SWEEP_PATTERN = 'timer.tick.1h';
149
+
150
+ export interface SubscriberRegistrar {
151
+ subscribe(options: {
152
+ name: string;
153
+ pattern: string;
154
+ handler: string;
155
+ registeredBy?: string;
156
+ }): unknown;
157
+ }
158
+
159
+ /** Idempotent: `bus.subscribe` upserts by name. */
160
+ export function ensureOperationsSweepSubscriber(bus: SubscriberRegistrar): void {
161
+ bus.subscribe({
162
+ name: OPERATIONS_SWEEP_SUBSCRIBER,
163
+ pattern: OPERATIONS_SWEEP_PATTERN,
164
+ handler: 'celilo module operations clear',
165
+ registeredBy: 'celilo-module-operations',
166
+ });
167
+ }
168
+
122
169
  export interface InFlightConflict {
123
170
  operation: ModuleOperation;
124
171
  /** A short, operator-readable description: "deploy of homebridge (pid 12345)". */
@@ -173,7 +173,7 @@ describe('register / unregister roundtrip', () => {
173
173
  try {
174
174
  const row = bus.db
175
175
  .query<{ name: string; pattern: string; handler: string }, []>(
176
- 'SELECT name, pattern, handler FROM subscribers',
176
+ "SELECT name, pattern, handler FROM subscribers WHERE name = 'celilo-backup-sweep'",
177
177
  )
178
178
  .get();
179
179
  expect(row).toEqual({
@@ -186,6 +186,28 @@ describe('register / unregister roundtrip', () => {
186
186
  }
187
187
  });
188
188
 
189
+ // Any module can hold the operation lock, so registering ANY module — even
190
+ // one with no subscriptions and no backup hook — is enough to arm the sweep
191
+ // that reclaims abandoned rows (#581).
192
+ it('arms the abandoned-operations sweep for any module', () => {
193
+ registerModuleSubscriptions(baseManifest({}), '/p');
194
+
195
+ const bus = openBus({ dbPath, events: defineEvents({}) });
196
+ try {
197
+ const row = bus.db
198
+ .query<{ pattern: string; handler: string }, []>(
199
+ "SELECT pattern, handler FROM subscribers WHERE name = 'celilo-operations-sweep'",
200
+ )
201
+ .get();
202
+ expect(row).toEqual({
203
+ pattern: 'timer.tick.1h',
204
+ handler: 'celilo module operations clear',
205
+ });
206
+ } finally {
207
+ bus.close();
208
+ }
209
+ });
210
+
189
211
  it('registers each subscription as a row, names scoped to module id', () => {
190
212
  const result = registerModuleSubscriptions(
191
213
  baseManifest({
@@ -210,7 +232,7 @@ describe('register / unregister roundtrip', () => {
210
232
  try {
211
233
  const rows = bus.db
212
234
  .query<{ name: string; pattern: string; handler: string }, []>(
213
- 'SELECT name, pattern, handler FROM subscribers ORDER BY name',
235
+ "SELECT name, pattern, handler FROM subscribers WHERE name LIKE '%.%' ORDER BY name",
214
236
  )
215
237
  .all();
216
238
  expect(rows).toEqual([
@@ -243,10 +265,14 @@ describe('register / unregister roundtrip', () => {
243
265
  const bus = openBus({ dbPath, events: defineEvents({}) });
244
266
  try {
245
267
  const rows = bus.db
246
- .query<{ count: number }, []>('SELECT COUNT(*) AS count FROM subscribers')
268
+ .query<{ count: number }, []>(
269
+ "SELECT COUNT(*) AS count FROM subscribers WHERE name LIKE '%.%'",
270
+ )
247
271
  .get();
248
272
  expect(rows?.count).toBe(1);
249
- const row = bus.db.query<{ handler: string }, []>('SELECT handler FROM subscribers').get();
273
+ const row = bus.db
274
+ .query<{ handler: string }, []>("SELECT handler FROM subscribers WHERE name LIKE '%.%'")
275
+ .get();
250
276
  expect(row?.handler).toBe('echo second');
251
277
  } finally {
252
278
  bus.close();
@@ -279,7 +305,9 @@ describe('register / unregister roundtrip', () => {
279
305
  const bus = openBus({ dbPath, events: defineEvents({}) });
280
306
  try {
281
307
  const rows = bus.db
282
- .query<{ name: string }, []>('SELECT name FROM subscribers ORDER BY name')
308
+ .query<{ name: string }, []>(
309
+ "SELECT name FROM subscribers WHERE name LIKE '%.%' ORDER BY name",
310
+ )
283
311
  .all();
284
312
  expect(rows).toEqual([{ name: 'authentik.a' }]);
285
313
  } finally {
@@ -337,8 +365,13 @@ describe('resyncAllSubscriptions (ISS-0088)', () => {
337
365
  function subscriberNames(): string[] {
338
366
  const bus = openBus({ dbPath: busPath, events: defineEvents({}) });
339
367
  try {
368
+ // Module subscriptions are dot-scoped (`<module-id>.<sub-name>`); celilo's
369
+ // own housekeeping subscribers (the backup and operations sweeps) are not,
370
+ // and are not what these tests are about.
340
371
  return bus.db
341
- .query<{ name: string }, []>('SELECT name FROM subscribers ORDER BY name')
372
+ .query<{ name: string }, []>(
373
+ "SELECT name FROM subscribers WHERE name LIKE '%.%' ORDER BY name",
374
+ )
342
375
  .all()
343
376
  .map((r) => r.name);
344
377
  } finally {
@@ -22,6 +22,7 @@ import { getDb } from '../db/client';
22
22
  import { modules } from '../db/schema';
23
23
  import type { ModuleManifest, ModuleSubscription } from '../manifest/schema';
24
24
  import { ensureBackupSweepSubscriber } from './backup-sweep';
25
+ import { ensureOperationsSweepSubscriber } from './module-operations';
25
26
 
26
27
  /**
27
28
  * The bus is opened by the celilo CLI without an event registry — the
@@ -82,9 +83,6 @@ function resolveHandler(sub: ModuleSubscription, moduleId: string, modulePath: s
82
83
  /**
83
84
  * Register all of a module's subscriptions on the bus. Idempotent —
84
85
  * re-running with the same manifest updates existing rows in place.
85
- *
86
- * If the module's manifest declares no subscriptions, this is a
87
- * cheap no-op (the bus DB isn't even touched).
88
86
  */
89
87
  export function registerModuleSubscriptions(
90
88
  manifest: ModuleManifest,
@@ -92,10 +90,14 @@ export function registerModuleSubscriptions(
92
90
  ): { registered: number } {
93
91
  const subs = manifest.subscriptions ?? [];
94
92
  const backupSweep = Boolean(manifest.hooks?.on_backup);
95
- if (subs.length === 0 && !backupSweep) return { registered: 0 };
96
93
 
97
94
  const bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
98
95
  try {
96
+ // Unconditional: every module can hold the operation lock (a deploy at
97
+ // minimum), so the first module on a fleet is what arms the sweep that
98
+ // reclaims abandoned rows. Idempotent.
99
+ ensureOperationsSweepSubscriber(bus);
100
+
99
101
  // A module that can be backed up is also what switches the scheduled
100
102
  // backup sweep on. Registering here rather than at system init means the
101
103
  // sweep appears the moment the fleet has something to back up, and — since
@@ -63,17 +63,20 @@ describe('variableTypeToTs', () => {
63
63
  });
64
64
 
65
65
  describe('generateModuleTypes', () => {
66
- test('emits a file header and empty interface for a manifest with no variables', () => {
66
+ test('emits a file header and a no-keys alias for a manifest with no variables', () => {
67
67
  const out = generateModuleTypes(baseManifest({ id: 'empty', name: 'Empty Module' }));
68
68
  expect(out).toContain('// Generated from manifest.yml');
69
69
  expect(out).toContain('Do not edit by hand');
70
- expect(out).toContain('export type EmptyConfig = {');
70
+ // `= {}` means "any non-nullish value", which is the opposite of an empty
71
+ // config surface — and biome bans it (lint/complexity/noBannedTypes).
72
+ expect(out).toContain('export type EmptyConfig = Record<string, never>;');
73
+ expect(out).not.toContain('export type EmptyConfig = {');
71
74
  expect(out).toContain('(No variables declared — module has no typed config surface)');
72
75
  });
73
76
 
74
77
  test('produces the right type-alias name from a kebab-case module ID', () => {
75
78
  const out = generateModuleTypes(baseManifest({ id: 'dns-external', name: 'DNS External' }));
76
- expect(out).toContain('export type DnsExternalConfig = {');
79
+ expect(out).toContain('export type DnsExternalConfig =');
77
80
  });
78
81
 
79
82
  test('renders required fields as non-optional', () => {
@@ -145,8 +145,6 @@ export function generateModuleTypes(manifest: ModuleManifest): string {
145
145
  lines.push(' * gives type aliases an implicit index signature but withholds one from');
146
146
  lines.push(' * interfaces (which can be declaration-merged). See v2/issues.');
147
147
  lines.push(' */');
148
- lines.push(`export type ${typeName} = {`);
149
-
150
148
  const ownsFields: string[] = [];
151
149
  const importsFields: string[] = [];
152
150
 
@@ -172,6 +170,17 @@ export function generateModuleTypes(manifest: ModuleManifest): string {
172
170
  importsFields.push(...rendered);
173
171
  }
174
172
 
173
+ if (ownsFields.length === 0 && importsFields.length === 0) {
174
+ // `= {}` is the banned "any non-nullish value" type, not "no keys" — and an
175
+ // empty config surface means exactly no keys.
176
+ lines.push('// (No variables declared — module has no typed config surface)');
177
+ lines.push(`export type ${typeName} = Record<string, never>;`);
178
+ lines.push('');
179
+ return lines.map((line) => line.trimEnd()).join('\n');
180
+ }
181
+
182
+ lines.push(`export type ${typeName} = {`);
183
+
175
184
  if (ownsFields.length > 0) {
176
185
  lines.push(' // Module-owned variables (from variables.owns)');
177
186
  lines.push(...ownsFields);
@@ -183,12 +192,8 @@ export function generateModuleTypes(manifest: ModuleManifest): string {
183
192
  lines.push(...importsFields);
184
193
  }
185
194
 
186
- if (ownsFields.length === 0 && importsFields.length === 0) {
187
- lines.push(' // (No variables declared — module has no typed config surface)');
188
- }
189
-
190
195
  lines.push('};');
191
196
  lines.push('');
192
197
 
193
- return lines.join('\n');
198
+ return lines.map((line) => line.trimEnd()).join('\n');
194
199
  }
@@ -14,7 +14,8 @@ import {
14
14
  import { dirname, join, relative } from 'node:path';
15
15
  import type { StorageProvider, StorageVerifyResult } from './types';
16
16
 
17
- const BACKUP_PREFIX = 'celilo-backups';
17
+ /** Subdirectory of the configured path that actually holds archives. */
18
+ export const BACKUP_PREFIX = 'celilo-backups';
18
19
 
19
20
  export interface LocalStorageConfig {
20
21
  path: string;
@@ -76,12 +76,14 @@ const cleanAudit: AuditDeps = {
76
76
  moduleConfigs: { modules: [] },
77
77
  health: { results: [] },
78
78
  backups: { modules: [] },
79
+ abandonedOperations: { records: [] },
79
80
  undeployedModules: { modules: [] },
80
81
  unconfiguredModules: { modules: [] },
81
82
  servicesCredentials: { results: [] },
82
83
  secretsDecryptable: { results: [] },
83
84
  servicesReachable: { results: [] },
84
85
  machinesReachable: { results: [] },
86
+ transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
85
87
  trustedSources: { firewalls: [] },
86
88
  };
87
89
 
@@ -5,6 +5,7 @@ import type { DbClient } from '../db/client';
5
5
  import {
6
6
  capabilities,
7
7
  containerServices,
8
+ isAllocatableZone,
8
9
  machines,
9
10
  moduleConfigs,
10
11
  moduleInfrastructure,
@@ -391,7 +392,11 @@ export async function buildResolutionContext(
391
392
  .get();
392
393
  isProxmox = svc?.providerName === 'proxmox';
393
394
  }
394
- if (isProxmox && zone !== 'external') {
395
+ // `isAllocatableZone`, not `zone !== 'external'`: that check was written
396
+ // when `external` was the only zone celilo does not address, and it
397
+ // silently became wrong the moment `vpn` joined it — a VPN client subnet
398
+ // is assigned by the tunnel module, so allocating into it would collide.
399
+ if (isProxmox && isAllocatableZone(zone)) {
395
400
  await db.transaction(async (tx) => {
396
401
  const existing = await getAllocation(moduleId, tx);
397
402
  const allocation = existing ?? (await allocateResources(moduleId, zone, tx));