@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.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +18 -2
- package/drizzle/0018_drop_alert_policy_snapshot.sql +46 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +4 -4
- package/src/capabilities/public-web-publish.test.ts +15 -15
- package/src/cli/commands/alerts-list.ts +10 -0
- package/src/cli/commands/alerts-poll.ts +12 -6
- package/src/cli/commands/alerts-sweep.ts +22 -81
- package/src/cli/commands/backup-sweep.ts +65 -0
- package/src/cli/commands/module-operations.test.ts +93 -0
- package/src/cli/commands/module-operations.ts +134 -0
- package/src/cli/commands/module-upgrade.test.ts +32 -20
- package/src/cli/commands/module-upgrade.ts +37 -32
- package/src/cli/commands/monitor.ts +26 -6
- package/src/cli/commands/system-audit.ts +9 -68
- package/src/cli/completion.ts +18 -1
- package/src/cli/index.ts +11 -0
- package/src/db/schema.ts +5 -3
- package/src/hooks/capability-loader.ts +0 -12
- package/src/manifest/schema.ts +4 -1
- package/src/module/packaging/build.ts +4 -0
- package/src/services/alerting/builtin-source.ts +18 -51
- package/src/services/alerting/delivery-loop.test.ts +5 -1
- package/src/services/alerting/format.test.ts +0 -1
- package/src/services/alerting/inbound-poller.test.ts +44 -8
- package/src/services/alerting/inbound-poller.ts +65 -28
- package/src/services/alerting/notify-deps.ts +113 -0
- package/src/services/alerting/run-monitor.ts +0 -1
- package/src/services/alerting/store.test.ts +1 -1
- package/src/services/alerting/store.ts +0 -2
- package/src/services/alerting/sweep-runner.test.ts +11 -2
- package/src/services/alerting/sweep-runner.ts +14 -7
- package/src/services/audit/backup-source.ts +54 -0
- package/src/services/audit/backups.test.ts +7 -2
- package/src/services/audit/backups.ts +10 -18
- package/src/services/backup-cipher.test.ts +188 -0
- package/src/services/backup-cipher.ts +178 -0
- package/src/services/backup-create.ts +20 -30
- package/src/services/backup-envelope-roundtrip.test.ts +6 -26
- package/src/services/backup-restore.ts +10 -16
- package/src/services/backup-schedule.ts +35 -0
- package/src/services/backup-sweep.test.ts +148 -0
- package/src/services/backup-sweep.ts +124 -0
- package/src/services/deploy-posture.ts +15 -2
- package/src/services/machine-probe.test.ts +50 -0
- package/src/services/machine-probe.ts +73 -0
- package/src/services/module-operations.test.ts +67 -6
- package/src/services/module-operations.ts +69 -19
- package/src/services/module-subscriptions.test.ts +33 -2
- package/src/services/module-subscriptions.ts +10 -1
- package/src/services/module-validator/typescript-build.test.ts +20 -1
- package/src/services/module-validator/typescript-build.ts +9 -5
- package/src/services/restore-from-file.ts +6 -21
- package/src/templates/generator.test.ts +88 -0
- 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
|
-
|
|
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(
|
|
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('
|
|
145
|
+
describe('isPidRunnable', () => {
|
|
145
146
|
it('returns true for the current process', () => {
|
|
146
|
-
expect(
|
|
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(
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
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
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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'
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
|
102
|
-
//
|
|
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
|
-
|
|
130
|
-
let tarData: Buffer;
|
|
118
|
+
const tarPath = join(tempDir, 'envelope.tar');
|
|
131
119
|
try {
|
|
132
|
-
|
|
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)}.
|
|
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
|
+
});
|