@celilo/cli 0.14.4 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +19 -2
- package/drizzle/0018_drop_alert_policy_snapshot.sql +46 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- 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-config.test.ts +77 -1
- package/src/cli/commands/module-config.ts +45 -3
- package/src/cli/commands/module-journal.test.ts +47 -0
- package/src/cli/commands/module-journal.ts +98 -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 +3 -30
- package/src/cli/completion.ts +20 -1
- package/src/cli/generate-zsh-completion.ts +4 -0
- package/src/cli/index.ts +14 -0
- package/src/db/schema.ts +5 -3
- package/src/manifest/schema.ts +4 -1
- package/src/module/packaging/build.ts +4 -0
- package/src/services/alerting/builtin-source.ts +17 -2
- 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 +235 -8
- package/src/services/alerting/inbound-poller.ts +95 -34
- package/src/services/alerting/inbound.test.ts +213 -2
- package/src/services/alerting/inbound.ts +161 -32
- package/src/services/alerting/interview-responder.test.ts +0 -32
- package/src/services/alerting/interview-responder.ts +6 -17
- 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/alerting/tokens.ts +39 -1
- 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/module-journal.test.ts +302 -0
- package/src/services/module-journal.ts +160 -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
|
@@ -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
|
+
});
|
|
@@ -248,12 +248,51 @@ export function targetNodeFromTfState(state: {
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
/**
|
|
251
|
-
*
|
|
252
|
-
* state
|
|
253
|
-
*
|
|
254
|
-
*
|
|
251
|
+
* The storage an existing container's root disk actually lives on, from
|
|
252
|
+
* terraform state. Prefers the LXC `rootfs.storage` attribute, falls back to the
|
|
253
|
+
* `<storage>:vm-<vmid>-disk-0` volume prefix, then to a VM's data `disk` block.
|
|
254
|
+
* Returns null when there's no such resource (e.g. an empty/fresh state).
|
|
255
255
|
*/
|
|
256
|
-
|
|
256
|
+
export function storageFromTfState(state: {
|
|
257
|
+
resources?: Array<{
|
|
258
|
+
type?: string;
|
|
259
|
+
instances?: Array<{
|
|
260
|
+
attributes?: {
|
|
261
|
+
rootfs?:
|
|
262
|
+
| Array<{ storage?: string; volume?: string }>
|
|
263
|
+
| { storage?: string; volume?: string };
|
|
264
|
+
disk?: Array<{ storage?: string; type?: string }>;
|
|
265
|
+
};
|
|
266
|
+
}>;
|
|
267
|
+
}>;
|
|
268
|
+
}): string | null {
|
|
269
|
+
const guest = state.resources?.find(
|
|
270
|
+
(r) => r.type === 'proxmox_lxc' || r.type === 'proxmox_vm_qemu',
|
|
271
|
+
);
|
|
272
|
+
const attrs = guest?.instances?.[0]?.attributes;
|
|
273
|
+
if (!attrs) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
// The provider models `rootfs` as a single-element block list; older state
|
|
277
|
+
// shapes record it as a bare object.
|
|
278
|
+
const rootfs = Array.isArray(attrs.rootfs) ? attrs.rootfs[0] : attrs.rootfs;
|
|
279
|
+
if (rootfs?.storage) {
|
|
280
|
+
return rootfs.storage;
|
|
281
|
+
}
|
|
282
|
+
// volume format: "<storage>:vm-<vmid>-disk-0"
|
|
283
|
+
const volumeStorage = rootfs?.volume?.split(':')[0];
|
|
284
|
+
if (volumeStorage) {
|
|
285
|
+
return volumeStorage;
|
|
286
|
+
}
|
|
287
|
+
// VMs: the data disk carries the storage that matters (the cloudinit disk
|
|
288
|
+
// rides on the same storage — see the `disk` block ordering note in the
|
|
289
|
+
// vm templates).
|
|
290
|
+
const disks = attrs.disk ?? [];
|
|
291
|
+
return (disks.find((d) => d.type === 'disk') ?? disks[0])?.storage || null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Parse a module's terraform state, or null when absent/unreadable. */
|
|
295
|
+
async function readTfState(moduleId: string): Promise<Record<string, unknown> | null> {
|
|
257
296
|
const statePath = join(
|
|
258
297
|
getModuleStoragePath(),
|
|
259
298
|
moduleId,
|
|
@@ -265,12 +304,33 @@ async function readDeployedTargetNode(moduleId: string): Promise<string | null>
|
|
|
265
304
|
return null;
|
|
266
305
|
}
|
|
267
306
|
try {
|
|
268
|
-
return
|
|
307
|
+
return JSON.parse(await readFile(statePath, 'utf-8'));
|
|
269
308
|
} catch {
|
|
270
309
|
return null;
|
|
271
310
|
}
|
|
272
311
|
}
|
|
273
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Read the node a module's container is currently deployed on, from its terraform
|
|
315
|
+
* state file. This is celilo's authoritative record of placement (ISS-0090).
|
|
316
|
+
* Returns null when no state exists yet (a first deploy) or it can't be read —
|
|
317
|
+
* the caller then falls back to the service `default_target_node`.
|
|
318
|
+
*/
|
|
319
|
+
async function readDeployedTargetNode(moduleId: string): Promise<string | null> {
|
|
320
|
+
const state = await readTfState(moduleId);
|
|
321
|
+
return state ? targetNodeFromTfState(state) : null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Read the storage a module's container is currently deployed on, from its
|
|
326
|
+
* terraform state file. Returns null on a first deploy — the caller then falls
|
|
327
|
+
* back to the service default storage.
|
|
328
|
+
*/
|
|
329
|
+
async function readDeployedStorage(moduleId: string): Promise<string | null> {
|
|
330
|
+
const state = await readTfState(moduleId);
|
|
331
|
+
return state ? storageFromTfState(state) : null;
|
|
332
|
+
}
|
|
333
|
+
|
|
274
334
|
/**
|
|
275
335
|
* The node a module's container ACTUALLY lives on, from Proxmox (ISS-0090).
|
|
276
336
|
* Proxmox is the ultimate source of truth for current location — it sees a
|
|
@@ -314,6 +374,27 @@ export function decideTargetNode(opts: {
|
|
|
314
374
|
return { node: opts.defaultNode, source: 'default' };
|
|
315
375
|
}
|
|
316
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Decide which storage to target for a deploy. Pure (Rule 10). The sibling of
|
|
379
|
+
* `decideTargetNode`: the service's `storage` governs only a FIRST placement.
|
|
380
|
+
* A container already living on `local-lvm` must keep living there when the
|
|
381
|
+
* service default later changes to `datacenter` — emitting the new default
|
|
382
|
+
* makes terraform plan a destroy-and-recreate ("forces replacement") of a
|
|
383
|
+
* running container and lose its volume. Relocating storage is a deliberate
|
|
384
|
+
* migration, never a side effect of a default drifting.
|
|
385
|
+
*
|
|
386
|
+
* ponytail: terraform state only, no Proxmox-reality tier like decideTargetNode
|
|
387
|
+
* has. A hand `pct move-volume` is invisible until the next apply refreshes
|
|
388
|
+
* state; add a Proxmox read if that ever bites.
|
|
389
|
+
*/
|
|
390
|
+
export function decideStorage(opts: {
|
|
391
|
+
stateStorage: string | null;
|
|
392
|
+
defaultStorage: string;
|
|
393
|
+
}): { storage: string; source: 'state' | 'default' } {
|
|
394
|
+
if (opts.stateStorage) return { storage: opts.stateStorage, source: 'state' };
|
|
395
|
+
return { storage: opts.defaultStorage, source: 'default' };
|
|
396
|
+
}
|
|
397
|
+
|
|
317
398
|
/**
|
|
318
399
|
* Discover template files in directory recursively
|
|
319
400
|
*
|
|
@@ -784,6 +865,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
784
865
|
// Resolved live each generate (ISS-0090) and injected into the context below,
|
|
785
866
|
// never cached in the DB. undefined for non-Proxmox / machine deploys.
|
|
786
867
|
let resolvedTargetNode: string | undefined;
|
|
868
|
+
let resolvedStorage: string | undefined;
|
|
787
869
|
if (isContainerService && isProxmoxService && infrastructureSelection?.serviceId) {
|
|
788
870
|
const service = await db
|
|
789
871
|
.select()
|
|
@@ -824,11 +906,24 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
824
906
|
);
|
|
825
907
|
}
|
|
826
908
|
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
//
|
|
909
|
+
// Storage is sticky for the same reason placement is: the service default
|
|
910
|
+
// governs only a FIRST create. Emitting a changed default at an existing
|
|
911
|
+
// container makes terraform plan a destroy-and-recreate.
|
|
912
|
+
const storageDecision = decideStorage({
|
|
913
|
+
stateStorage: await readDeployedStorage(moduleId),
|
|
914
|
+
defaultStorage: providerConfig.storage,
|
|
915
|
+
});
|
|
916
|
+
resolvedStorage = storageDecision.storage;
|
|
917
|
+
if (storageDecision.source === 'state' && resolvedStorage !== providerConfig.storage) {
|
|
918
|
+
log.info(
|
|
919
|
+
`${moduleId} → storage '${resolvedStorage}' (from terraform state; service default is '${providerConfig.storage}'). Moving storage requires a deliberate migration.`,
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Persist only the non-drift provider values. target_node and storage are
|
|
924
|
+
// reality — they're injected into the resolution context below, never
|
|
925
|
+
// cached (ISS-0090); drop any stale __infra_* rows a prior generate left.
|
|
830
926
|
upsertModuleConfig(db, moduleId, '__infra_lxc_template', providerConfig.lxc_template);
|
|
831
|
-
upsertModuleConfig(db, moduleId, '__infra_storage', providerConfig.storage);
|
|
832
927
|
// vm_template is only present once a VM template exists for the service.
|
|
833
928
|
// Persist it when set so `type: vm` modules can resolve $self:vm_template;
|
|
834
929
|
// a `type: vm` deploy against a service without one then fails loudly at
|
|
@@ -837,8 +932,12 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
837
932
|
upsertModuleConfig(db, moduleId, '__infra_vm_template', providerConfig.vm_template);
|
|
838
933
|
}
|
|
839
934
|
deleteModuleConfig(db, moduleId, '__infra_target_node');
|
|
935
|
+
deleteModuleConfig(db, moduleId, '__infra_storage');
|
|
840
936
|
|
|
841
|
-
log.success(
|
|
937
|
+
log.success(
|
|
938
|
+
`Infrastructure resolved: target_node=${decision.node} (${decision.source}), ` +
|
|
939
|
+
`storage=${storageDecision.storage} (${storageDecision.source})`,
|
|
940
|
+
);
|
|
842
941
|
}
|
|
843
942
|
}
|
|
844
943
|
|
|
@@ -870,15 +969,19 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
870
969
|
context.selfConfig.target_ip = ipConfig.value!;
|
|
871
970
|
}
|
|
872
971
|
|
|
873
|
-
// target_node
|
|
874
|
-
// never from a cached
|
|
972
|
+
// target_node and storage are the live-resolved reality of an existing
|
|
973
|
+
// container (ISS-0090) — inject them directly, never from a cached
|
|
974
|
+
// __infra_* row (which drifts against the service default).
|
|
875
975
|
if (resolvedTargetNode) {
|
|
876
976
|
context.selfConfig.target_node = resolvedTargetNode;
|
|
877
977
|
}
|
|
978
|
+
if (resolvedStorage) {
|
|
979
|
+
context.selfConfig.storage = resolvedStorage;
|
|
980
|
+
}
|
|
878
981
|
|
|
879
|
-
// lxc_template /
|
|
880
|
-
// them back from the __infra_* rows persisted above.
|
|
881
|
-
const infraKeys = ['lxc_template', '
|
|
982
|
+
// lxc_template / vm_template are provider config (intent, not drift-prone) —
|
|
983
|
+
// read them back from the __infra_* rows persisted above.
|
|
984
|
+
const infraKeys = ['lxc_template', 'vm_template'];
|
|
882
985
|
for (const key of infraKeys) {
|
|
883
986
|
const infraConfig = db
|
|
884
987
|
.select()
|