@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.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +39 -9
- package/drizzle/0019_backup_pid.sql +18 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -5
- package/schemas/system_config.json +1 -1
- package/src/cli/command-tree-parser.ts +0 -1
- package/src/cli/commands/alerts-poll.ts +26 -1
- package/src/cli/commands/backup-sweep.ts +62 -0
- package/src/cli/commands/module-operations.test.ts +45 -1
- package/src/cli/commands/module-operations.ts +35 -12
- package/src/cli/commands/module-show.ts +1 -0
- package/src/cli/commands/storage-set-path.test.ts +281 -0
- package/src/cli/commands/storage-set-path.ts +190 -0
- package/src/cli/commands/system-audit.ts +14 -0
- package/src/cli/commands/system-migrate.ts +40 -0
- package/src/cli/commands/system-update.ts +6 -0
- package/src/cli/completion.ts +24 -3
- package/src/cli/fuel-gauge.ts +0 -1
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +12 -0
- package/src/cli/tui/audit-state.test.ts +15 -1
- package/src/cli/tui/audit-state.ts +6 -0
- package/src/cli/tui/audit-tui.test.tsx +0 -1
- package/src/db/schema.ts +53 -9
- package/src/hooks/capability-loader.ts +30 -1
- package/src/ipam/allocator.ts +13 -3
- package/src/services/alerting/builtin-monitors.test.ts +42 -0
- package/src/services/alerting/builtin-monitors.ts +3 -0
- package/src/services/alerting/builtin-source.ts +15 -0
- package/src/services/alerting/inbound-poller.test.ts +63 -1
- package/src/services/alerting/inbound-poller.ts +42 -0
- package/src/services/alerting/read-records.ts +85 -0
- package/src/services/audit/abandoned-operations.test.ts +73 -0
- package/src/services/audit/abandoned-operations.ts +0 -0
- package/src/services/audit/disk-space.test.ts +111 -0
- package/src/services/audit/disk-space.ts +114 -0
- package/src/services/audit/index.test.ts +2 -0
- package/src/services/audit/index.ts +12 -0
- package/src/services/audit/transport-reads.test.ts +113 -0
- package/src/services/audit/transport-reads.ts +120 -0
- package/src/services/audit/types.ts +3 -0
- package/src/services/backup-create.ts +4 -4
- package/src/services/backup-in-flight-refusal.test.ts +2 -0
- package/src/services/backup-metadata.ts +4 -0
- package/src/services/backup-staging.test.ts +134 -0
- package/src/services/backup-staging.ts +192 -0
- package/src/services/backup-storage.ts +29 -0
- package/src/services/backup-sweep.test.ts +68 -0
- package/src/services/backup-sweep.ts +62 -0
- package/src/services/config-interview.ts +1 -1
- package/src/services/deploy-ansible.ts +0 -1
- package/src/services/disk-probe.test.ts +74 -0
- package/src/services/disk-probe.ts +145 -0
- package/src/services/fleet-checks.ts +15 -0
- package/src/services/module-operations.test.ts +22 -0
- package/src/services/module-operations.ts +48 -1
- package/src/services/module-subscriptions.test.ts +39 -6
- package/src/services/module-subscriptions.ts +6 -4
- package/src/services/module-types-generator.test.ts +6 -3
- package/src/services/module-types-generator.ts +12 -7
- package/src/services/storage-providers/local.ts +2 -1
- package/src/services/update/orchestrator.test.ts +2 -0
- 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
|
-
|
|
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
|
-
|
|
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 }, []>(
|
|
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
|
|
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 }, []>(
|
|
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 }, []>(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/variables/context.ts
CHANGED
|
@@ -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
|
-
|
|
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));
|