@celilo/cli 0.4.0 → 0.5.0-alpha.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/package.json +1 -1
- package/src/cli/commands/module-show.ts +3 -3
- package/src/cli/commands/restore.ts +5 -0
- package/src/cli/commands/status.ts +2 -2
- package/src/hooks/capability-loader.ts +16 -5
- package/src/manifest/schema.ts +26 -15
- package/src/manifest/template-validator.test.ts +8 -8
- package/src/manifest/template-validator.ts +3 -3
- package/src/manifest/validate.test.ts +18 -17
- package/src/manifest/validate.ts +6 -6
- package/src/module/import.ts +12 -7
- package/src/services/celilo-events.test.ts +35 -5
- package/src/services/celilo-events.ts +17 -3
- package/src/services/celilo-mgmt-hooks.test.ts +32 -2
- package/src/services/deploy-preflight.ts +4 -3
- package/src/services/deployed-systems.test.ts +2 -2
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/infrastructure-selector.test.ts +14 -14
- package/src/services/infrastructure-selector.ts +11 -12
- package/src/services/module-deploy.ts +4 -4
- package/src/services/restore-from-file.test.ts +46 -0
- package/src/services/restore-from-file.ts +103 -5
- package/src/services/terraform-safety.ts +8 -2
- package/src/services/zone-policy.ts +2 -2
- package/src/templates/generator.test.ts +13 -7
- package/src/templates/generator.ts +20 -6
- package/src/variables/context.test.ts +19 -42
- package/src/variables/context.ts +14 -16
- package/src/variables/lxc-nameserver.test.ts +1 -1
- package/tsconfig.json +1 -1
|
@@ -150,7 +150,7 @@ function asZone(value: string | undefined): NetworkZone | null {
|
|
|
150
150
|
* single sink the old scattered `target_ip` writes collapse into.
|
|
151
151
|
*
|
|
152
152
|
* Transition: every current module declares exactly one system (via
|
|
153
|
-
* `requires.
|
|
153
|
+
* `requires.system`, normalized to name `main`, or one `requires.systems`
|
|
154
154
|
* entry), so this records that single host from `hostname` config + the
|
|
155
155
|
* resolved IP. Returns the systems it recorded ([] for an API-only module with
|
|
156
156
|
* no host — e.g. namecheap). See v2/MODULE_SYSTEMS_ADDRESSING.md.
|
|
@@ -61,7 +61,7 @@ describe('infrastructure-selector', () => {
|
|
|
61
61
|
version: '1.0.0',
|
|
62
62
|
manifestData: {
|
|
63
63
|
requires: {
|
|
64
|
-
|
|
64
|
+
system: {
|
|
65
65
|
cpu: 2,
|
|
66
66
|
memory: 2048,
|
|
67
67
|
disk: 20,
|
|
@@ -119,7 +119,7 @@ describe('infrastructure-selector', () => {
|
|
|
119
119
|
version: '1.0.0',
|
|
120
120
|
manifestData: {
|
|
121
121
|
requires: {
|
|
122
|
-
|
|
122
|
+
system: {
|
|
123
123
|
cpu: 2,
|
|
124
124
|
memory: 2048,
|
|
125
125
|
disk: 20,
|
|
@@ -162,7 +162,7 @@ describe('infrastructure-selector', () => {
|
|
|
162
162
|
version: '1.0.0',
|
|
163
163
|
manifestData: {
|
|
164
164
|
requires: {
|
|
165
|
-
|
|
165
|
+
system: {
|
|
166
166
|
cpu: 2,
|
|
167
167
|
memory: 2048,
|
|
168
168
|
disk: 20,
|
|
@@ -197,7 +197,7 @@ describe('infrastructure-selector', () => {
|
|
|
197
197
|
const module = (await db.select().from(modules).limit(1))[0] as Module;
|
|
198
198
|
|
|
199
199
|
await expect(selectInfrastructure(module)).rejects.toThrow(InfrastructureError);
|
|
200
|
-
await expect(selectInfrastructure(module)).rejects.toThrow(/missing requires.
|
|
200
|
+
await expect(selectInfrastructure(module)).rejects.toThrow(/missing requires.system/);
|
|
201
201
|
});
|
|
202
202
|
|
|
203
203
|
it('throws error when module manifest missing zone', async () => {
|
|
@@ -210,7 +210,7 @@ describe('infrastructure-selector', () => {
|
|
|
210
210
|
version: '1.0.0',
|
|
211
211
|
manifestData: {
|
|
212
212
|
requires: {
|
|
213
|
-
|
|
213
|
+
system: {
|
|
214
214
|
cpu: 2,
|
|
215
215
|
memory: 2048,
|
|
216
216
|
disk: 20,
|
|
@@ -224,20 +224,20 @@ describe('infrastructure-selector', () => {
|
|
|
224
224
|
const module = (await db.select().from(modules).limit(1))[0] as Module;
|
|
225
225
|
|
|
226
226
|
await expect(selectInfrastructure(module)).rejects.toThrow(InfrastructureError);
|
|
227
|
-
await expect(selectInfrastructure(module)).rejects.toThrow(/missing requires.
|
|
227
|
+
await expect(selectInfrastructure(module)).rejects.toThrow(/missing requires.system.zone/);
|
|
228
228
|
});
|
|
229
229
|
|
|
230
|
-
it('supports requires.
|
|
230
|
+
it('supports requires.system format', async () => {
|
|
231
231
|
const db = createDbClient({ path: testDbPath });
|
|
232
232
|
|
|
233
|
-
// Create test module with requires.
|
|
233
|
+
// Create test module with requires.system format
|
|
234
234
|
await db.insert(modules).values({
|
|
235
235
|
id: 'test-module',
|
|
236
236
|
name: 'Test Module',
|
|
237
237
|
version: '1.0.0',
|
|
238
238
|
manifestData: {
|
|
239
239
|
requires: {
|
|
240
|
-
|
|
240
|
+
system: {
|
|
241
241
|
cpu: 2,
|
|
242
242
|
memory: 2048,
|
|
243
243
|
disk: 20,
|
|
@@ -284,7 +284,7 @@ describe('infrastructure-selector', () => {
|
|
|
284
284
|
version: '1.0.0',
|
|
285
285
|
manifestData: {
|
|
286
286
|
requires: {
|
|
287
|
-
|
|
287
|
+
system: {
|
|
288
288
|
cpu: 2,
|
|
289
289
|
memory: 2048,
|
|
290
290
|
disk: 20,
|
|
@@ -326,7 +326,7 @@ describe('infrastructure-selector', () => {
|
|
|
326
326
|
version: '1.0.0',
|
|
327
327
|
manifestData: {
|
|
328
328
|
requires: {
|
|
329
|
-
|
|
329
|
+
system: {
|
|
330
330
|
cpu: 2,
|
|
331
331
|
memory: 2048,
|
|
332
332
|
disk: 20,
|
|
@@ -366,7 +366,7 @@ describe('infrastructure-selector', () => {
|
|
|
366
366
|
version: '1.0.0',
|
|
367
367
|
manifestData: {
|
|
368
368
|
requires: {
|
|
369
|
-
|
|
369
|
+
system: {
|
|
370
370
|
cpu: 2,
|
|
371
371
|
memory: 2048,
|
|
372
372
|
disk: 20,
|
|
@@ -406,7 +406,7 @@ describe('infrastructure-selector', () => {
|
|
|
406
406
|
version: '1.0.0',
|
|
407
407
|
manifestData: {
|
|
408
408
|
requires: {
|
|
409
|
-
|
|
409
|
+
system: {
|
|
410
410
|
cpu: 2,
|
|
411
411
|
memory: 2048,
|
|
412
412
|
disk: 20,
|
|
@@ -449,7 +449,7 @@ describe('infrastructure-selector', () => {
|
|
|
449
449
|
version: '1.0.0',
|
|
450
450
|
manifestData: {
|
|
451
451
|
requires: {
|
|
452
|
-
|
|
452
|
+
system: {
|
|
453
453
|
cpu: 4,
|
|
454
454
|
memory: 4096,
|
|
455
455
|
disk: 128,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Module } from '../db/schema';
|
|
2
|
-
import type
|
|
2
|
+
import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
|
|
3
3
|
import type {
|
|
4
4
|
InfrastructureSelection,
|
|
5
5
|
Machine,
|
|
@@ -25,27 +25,26 @@ export class InfrastructureError extends Error {
|
|
|
25
25
|
function getResourceRequirements(module: Module): ResourceRequirements {
|
|
26
26
|
const manifest = module.manifestData as ModuleManifest;
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
const machine = manifest.requires?.machine;
|
|
28
|
+
const system = getSingularSystemSpec(manifest);
|
|
30
29
|
|
|
31
|
-
if (!
|
|
30
|
+
if (!system) {
|
|
32
31
|
throw new InfrastructureError(
|
|
33
|
-
`Module ${module.id} manifest missing requires.
|
|
32
|
+
`Module ${module.id} manifest missing requires.system configuration`,
|
|
34
33
|
);
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
if (!
|
|
36
|
+
if (!system.zone) {
|
|
38
37
|
throw new InfrastructureError(
|
|
39
|
-
`Module ${module.id} manifest missing requires.
|
|
38
|
+
`Module ${module.id} manifest missing requires.system.zone field`,
|
|
40
39
|
);
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
return {
|
|
44
|
-
cpu:
|
|
45
|
-
memory:
|
|
46
|
-
disk:
|
|
47
|
-
storage:
|
|
48
|
-
zone:
|
|
43
|
+
cpu: system.cpu ?? 1,
|
|
44
|
+
memory: system.memory ?? 1024,
|
|
45
|
+
disk: system.disk ?? 10,
|
|
46
|
+
storage: system.storage,
|
|
47
|
+
zone: system.zone,
|
|
49
48
|
};
|
|
50
49
|
}
|
|
51
50
|
|
|
@@ -16,7 +16,7 @@ import { loadCapabilityFunctions } from '../hooks/capability-loader';
|
|
|
16
16
|
import { invokeHook } from '../hooks/executor';
|
|
17
17
|
import { createGaugeLogger } from '../hooks/logger';
|
|
18
18
|
import type { HookDefinition, HookLogger, HookResult } from '../hooks/types';
|
|
19
|
-
import type
|
|
19
|
+
import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
|
|
20
20
|
import { decryptSecret } from '../secrets/encryption';
|
|
21
21
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
22
22
|
import { buildResolutionContext } from '../variables/context';
|
|
@@ -416,7 +416,7 @@ async function deployModuleImpl(
|
|
|
416
416
|
);
|
|
417
417
|
if (machineDerivable.length > 0) {
|
|
418
418
|
const isFirewall = manifest.provides?.capabilities?.some((cap) => cap.name === 'firewall');
|
|
419
|
-
const moduleZone = manifest
|
|
419
|
+
const moduleZone = getSingularSystemSpec(manifest)?.zone;
|
|
420
420
|
const matchedMachine = await findMachineForModule(
|
|
421
421
|
moduleId,
|
|
422
422
|
moduleZone,
|
|
@@ -473,7 +473,7 @@ async function deployModuleImpl(
|
|
|
473
473
|
if (regularConfig.length > 0) {
|
|
474
474
|
// Look up machine for $machine: derivation (earmarked or best match from pool)
|
|
475
475
|
const isFirewall = manifest.provides?.capabilities?.some((cap) => cap.name === 'firewall');
|
|
476
|
-
const moduleZone = manifest
|
|
476
|
+
const moduleZone = getSingularSystemSpec(manifest)?.zone;
|
|
477
477
|
const matchedMachine = await findMachineForModule(
|
|
478
478
|
moduleId,
|
|
479
479
|
moduleZone,
|
|
@@ -668,7 +668,7 @@ async function deployModuleImpl(
|
|
|
668
668
|
}
|
|
669
669
|
|
|
670
670
|
// Config-only modules (no infrastructure requirements) don't need terraform/ansible
|
|
671
|
-
const isConfigOnly = !manifest
|
|
671
|
+
const isConfigOnly = !getSingularSystemSpec(manifest);
|
|
672
672
|
if (isConfigOnly) {
|
|
673
673
|
log.success('Config-only module — no infrastructure deployment needed');
|
|
674
674
|
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* via this service) is an e2e concern, not exercised at the unit level.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { Database } from 'bun:sqlite';
|
|
11
12
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
12
13
|
import {
|
|
13
14
|
existsSync,
|
|
@@ -38,11 +39,14 @@ describe('applyStagedSystemFiles', () => {
|
|
|
38
39
|
keyPath = join(dir, 'master.key');
|
|
39
40
|
process.env.CELILO_DB_PATH = livePath;
|
|
40
41
|
process.env.CELILO_MASTER_KEY_PATH = keyPath;
|
|
42
|
+
process.env.CELILO_DATA_DIR = dir; // getModuleStoragePath() = dir/modules
|
|
41
43
|
});
|
|
42
44
|
|
|
43
45
|
afterEach(() => {
|
|
46
|
+
closeDb();
|
|
44
47
|
process.env.CELILO_DB_PATH = undefined;
|
|
45
48
|
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
49
|
+
process.env.CELILO_DATA_DIR = undefined;
|
|
46
50
|
try {
|
|
47
51
|
rmSync(dir, { recursive: true, force: true });
|
|
48
52
|
} catch {
|
|
@@ -124,6 +128,48 @@ describe('applyStagedSystemFiles', () => {
|
|
|
124
128
|
expect(result.dbApplied).toBe(false);
|
|
125
129
|
expect(result.keyApplied).toBe(false);
|
|
126
130
|
});
|
|
131
|
+
|
|
132
|
+
it('lays down staged module source dirs at the modules storage path', () => {
|
|
133
|
+
const stagedSrc = join(stagingDir, 'module_src');
|
|
134
|
+
mkdirSync(join(stagedSrc, 'caddy', 'scripts'), { recursive: true });
|
|
135
|
+
writeFileSync(join(stagedSrc, 'caddy', 'manifest.yml'), 'id: caddy');
|
|
136
|
+
writeFileSync(join(stagedSrc, 'caddy', 'scripts', 'hook.ts'), '// hook');
|
|
137
|
+
|
|
138
|
+
const result = applyStagedSystemFiles(stagingDir);
|
|
139
|
+
expect(result.moduleSourcesApplied).toBe(1);
|
|
140
|
+
// getModuleStoragePath() = <CELILO_DATA_DIR>/modules = dir/modules.
|
|
141
|
+
const laid = join(dir, 'modules', 'caddy');
|
|
142
|
+
expect(readFileSync(join(laid, 'manifest.yml'), 'utf-8')).toBe('id: caddy');
|
|
143
|
+
expect(readFileSync(join(laid, 'scripts', 'hook.ts'), 'utf-8')).toBe('// hook');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("rewrites every module's source_path to this box on the staged DB before swap", () => {
|
|
147
|
+
// A real staged SQLite DB whose module points at a FOREIGN (macOS) path —
|
|
148
|
+
// exactly the macOS→Linux cross-host case ISS-0051 broke on. Build a minimal
|
|
149
|
+
// self-contained DB (no runMigrations, which would hold a connection and
|
|
150
|
+
// lock the journal-mode switch the rewrite needs).
|
|
151
|
+
const stagedDbPath = join(stagingDir, 'celilo.db');
|
|
152
|
+
const seed = new Database(stagedDbPath);
|
|
153
|
+
seed.run(
|
|
154
|
+
'CREATE TABLE modules (id TEXT PRIMARY KEY, name TEXT, version TEXT, manifest_data TEXT, source_path TEXT)',
|
|
155
|
+
);
|
|
156
|
+
seed.run(
|
|
157
|
+
"INSERT INTO modules (id, name, version, manifest_data, source_path) VALUES ('caddy', 'caddy', '2.0.0', '{}', '/Users/someone/Library/Application Support/celilo/modules/caddy')",
|
|
158
|
+
);
|
|
159
|
+
seed.close();
|
|
160
|
+
|
|
161
|
+
const result = applyStagedSystemFiles(stagingDir);
|
|
162
|
+
expect(result.dbApplied).toBe(true);
|
|
163
|
+
|
|
164
|
+
// The swapped-in live DB must now point at THIS box's modules dir, not the
|
|
165
|
+
// source box's dead macOS path.
|
|
166
|
+
const live = new Database(livePath);
|
|
167
|
+
const row = live.query("SELECT source_path AS sp FROM modules WHERE id = 'caddy'").get() as {
|
|
168
|
+
sp: string;
|
|
169
|
+
};
|
|
170
|
+
live.close();
|
|
171
|
+
expect(row.sp).toBe(join(dir, 'modules', 'caddy'));
|
|
172
|
+
});
|
|
127
173
|
});
|
|
128
174
|
|
|
129
175
|
describe('restoreFromArtifactFile error paths', () => {
|
|
@@ -16,13 +16,18 @@
|
|
|
16
16
|
* of the restore the hook itself cannot do (live DB is open).
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { Database } from 'bun:sqlite';
|
|
19
20
|
import { execSync } from 'node:child_process';
|
|
20
21
|
import {
|
|
21
22
|
chmodSync,
|
|
23
|
+
closeSync,
|
|
22
24
|
copyFileSync,
|
|
25
|
+
cpSync,
|
|
23
26
|
existsSync,
|
|
24
27
|
mkdirSync,
|
|
28
|
+
openSync,
|
|
25
29
|
readFileSync,
|
|
30
|
+
readSync,
|
|
26
31
|
readdirSync,
|
|
27
32
|
rmSync,
|
|
28
33
|
writeFileSync,
|
|
@@ -30,7 +35,7 @@ import {
|
|
|
30
35
|
import { tmpdir } from 'node:os';
|
|
31
36
|
import { dirname, join } from 'node:path';
|
|
32
37
|
import { eq } from 'drizzle-orm';
|
|
33
|
-
import { getDbPath, getMasterKeyPath } from '../config/paths';
|
|
38
|
+
import { getDbPath, getMasterKeyPath, getModuleStoragePath } from '../config/paths';
|
|
34
39
|
import { closeDb, getDb } from '../db/client';
|
|
35
40
|
import { runMigrations } from '../db/migrate';
|
|
36
41
|
import { modules } from '../db/schema';
|
|
@@ -61,6 +66,8 @@ export interface RestoreFromFileResult {
|
|
|
61
66
|
masterKeyApplied?: boolean;
|
|
62
67
|
/** Was the fleet SSH keypair restored to <dataDir>/.ssh/? */
|
|
63
68
|
sshKeyApplied?: boolean;
|
|
69
|
+
/** How many module source dirs were laid down at the target's modules dir. */
|
|
70
|
+
moduleSourcesApplied?: number;
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
export interface RestoreFromFileOptions {
|
|
@@ -201,10 +208,21 @@ export async function restoreFromArtifactFile(
|
|
|
201
208
|
hookInputs.cross_module_write_root = crossModuleWriteRoot;
|
|
202
209
|
}
|
|
203
210
|
|
|
204
|
-
// 5. Invoke the hook.
|
|
211
|
+
// 5. Invoke the hook. Resolve the module path from getModuleStoragePath()
|
|
212
|
+
// rather than the stored mod.sourcePath (ISS-0052): on a box whose DB was
|
|
213
|
+
// produced by a prior restore from ANOTHER box, source_path is that box's
|
|
214
|
+
// absolute path (e.g. a macOS /Users/... path on a Linux box), and the
|
|
215
|
+
// hook executor's screenshot-dir mkdir then EACCES'es on it. By
|
|
216
|
+
// construction (import.ts) the code always lives at
|
|
217
|
+
// ${getModuleStoragePath()}/<id>, so resolve there. Falls back to the
|
|
218
|
+
// stored path only if the canonical dir is absent (e.g. a custom layout).
|
|
219
|
+
const canonicalModulePath = join(getModuleStoragePath(), mod.id);
|
|
220
|
+
const onRestoreModulePath = existsSync(canonicalModulePath)
|
|
221
|
+
? canonicalModulePath
|
|
222
|
+
: mod.sourcePath;
|
|
205
223
|
const logger = createConsoleLogger(mod.id, 'on_restore');
|
|
206
224
|
const hookResult = await invokeHook(
|
|
207
|
-
|
|
225
|
+
onRestoreModulePath,
|
|
208
226
|
'on_restore',
|
|
209
227
|
manifest.celilo_contract,
|
|
210
228
|
hookDef,
|
|
@@ -258,6 +276,7 @@ export async function restoreFromArtifactFile(
|
|
|
258
276
|
systemDbApplied: apply.dbApplied,
|
|
259
277
|
masterKeyApplied: apply.keyApplied,
|
|
260
278
|
sshKeyApplied: apply.sshApplied,
|
|
279
|
+
moduleSourcesApplied: apply.moduleSourcesApplied,
|
|
261
280
|
};
|
|
262
281
|
} finally {
|
|
263
282
|
try {
|
|
@@ -272,6 +291,61 @@ export interface StagedSystemApplyResult {
|
|
|
272
291
|
dbApplied: boolean;
|
|
273
292
|
keyApplied: boolean;
|
|
274
293
|
sshApplied: boolean;
|
|
294
|
+
/** How many module source dirs were laid down at the target's modules dir. */
|
|
295
|
+
moduleSourcesApplied: number;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Cheap check for the SQLite file magic without reading the whole DB. */
|
|
299
|
+
function looksLikeSqlite(path: string): boolean {
|
|
300
|
+
let fd: number;
|
|
301
|
+
try {
|
|
302
|
+
fd = openSync(path, 'r');
|
|
303
|
+
} catch {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
const buf = Buffer.alloc(16);
|
|
308
|
+
readSync(fd, buf, 0, 16, 0);
|
|
309
|
+
// Magic header is "SQLite format 3" (15 bytes) + a NUL terminator.
|
|
310
|
+
return buf.subarray(0, 15).toString('utf-8') === 'SQLite format 3' && buf[15] === 0;
|
|
311
|
+
} finally {
|
|
312
|
+
closeSync(fd);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Recompute every module's source_path to THIS box's modules dir, on the
|
|
318
|
+
* STAGED DB file (pre-swap). The artifact carries the SOURCE box's absolute
|
|
319
|
+
* source_path (e.g. macOS `~/Library/Application Support/celilo/modules/<id>`),
|
|
320
|
+
* which doesn't exist on a different box/OS — so a restored DB references module
|
|
321
|
+
* code at a dead path and no deploy can generate. By construction (import.ts)
|
|
322
|
+
* source_path is always `${dataDir}/modules/<id>`, so it's safe to recompute for
|
|
323
|
+
* the target; a no-op when the paths already match (same-OS restore).
|
|
324
|
+
*
|
|
325
|
+
* Done on the staged file (a fresh connection that's about to be swapped in),
|
|
326
|
+
* never the live DB, so there's no post-swap in-process reopen (ISS-0037). The
|
|
327
|
+
* PRAGMA forces commits into the main file (no -wal sibling) so the copy below
|
|
328
|
+
* captures the rewrite; the live DB re-enters WAL when celilo reopens it.
|
|
329
|
+
*/
|
|
330
|
+
function rewriteStagedModuleSourcePaths(stagedDbPath: string): void {
|
|
331
|
+
// Tolerate non-SQLite staged files: some callers/tests stage placeholder
|
|
332
|
+
// bytes to exercise the file-copy path. A real artifact DB always opens
|
|
333
|
+
// with the "SQLite format 3\0" magic header — skip anything that doesn't
|
|
334
|
+
// (a valid-header-but-corrupt DB still surfaces by throwing below).
|
|
335
|
+
if (!looksLikeSqlite(stagedDbPath)) return;
|
|
336
|
+
|
|
337
|
+
const moduleStorageBase = getModuleStoragePath();
|
|
338
|
+
const staged = new Database(stagedDbPath);
|
|
339
|
+
try {
|
|
340
|
+
staged.query('PRAGMA journal_mode = DELETE').run();
|
|
341
|
+
const rows = staged.query('SELECT id FROM modules').all() as Array<{ id: string }>;
|
|
342
|
+
const update = staged.query('UPDATE modules SET source_path = ? WHERE id = ?');
|
|
343
|
+
for (const { id } of rows) {
|
|
344
|
+
update.run(join(moduleStorageBase, id), id);
|
|
345
|
+
}
|
|
346
|
+
} finally {
|
|
347
|
+
staged.close();
|
|
348
|
+
}
|
|
275
349
|
}
|
|
276
350
|
|
|
277
351
|
/**
|
|
@@ -291,14 +365,16 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
|
|
|
291
365
|
let dbApplied = false;
|
|
292
366
|
let keyApplied = false;
|
|
293
367
|
let sshApplied = false;
|
|
368
|
+
let moduleSourcesApplied = 0;
|
|
294
369
|
|
|
295
370
|
if (!existsSync(systemStagingDir)) {
|
|
296
|
-
return { dbApplied, keyApplied, sshApplied };
|
|
371
|
+
return { dbApplied, keyApplied, sshApplied, moduleSourcesApplied };
|
|
297
372
|
}
|
|
298
373
|
|
|
299
374
|
const stagedDb = join(systemStagingDir, 'celilo.db');
|
|
300
375
|
const stagedKey = join(systemStagingDir, 'master.key');
|
|
301
376
|
const stagedSsh = join(systemStagingDir, 'ssh');
|
|
377
|
+
const stagedModuleSrc = join(systemStagingDir, 'module_src');
|
|
302
378
|
|
|
303
379
|
// master.key first: it's not load-bearing for the running process
|
|
304
380
|
// (already in memory if the daemon's running). Safe to swap before
|
|
@@ -329,9 +405,31 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
|
|
|
329
405
|
sshApplied = true;
|
|
330
406
|
}
|
|
331
407
|
|
|
408
|
+
// Module SOURCE code → lay down at THIS box's modules dir so the restored
|
|
409
|
+
// box has the code for EVERY module (incl. non-registry ones like lunacycle).
|
|
410
|
+
// on_backup captured each module's source; without this the restored DB
|
|
411
|
+
// references modules whose code isn't on disk and no deploy can generate.
|
|
412
|
+
// The artifact carries no generated/ subtree, so any existing generated/
|
|
413
|
+
// under a module dir (e.g. restored TF state) is preserved by the merge.
|
|
414
|
+
if (existsSync(stagedModuleSrc)) {
|
|
415
|
+
const moduleStorageBase = getModuleStoragePath();
|
|
416
|
+
mkdirSync(moduleStorageBase, { recursive: true });
|
|
417
|
+
for (const entry of readdirSync(stagedModuleSrc, { withFileTypes: true })) {
|
|
418
|
+
if (!entry.isDirectory()) continue;
|
|
419
|
+
cpSync(join(stagedModuleSrc, entry.name), join(moduleStorageBase, entry.name), {
|
|
420
|
+
recursive: true,
|
|
421
|
+
});
|
|
422
|
+
moduleSourcesApplied += 1;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
332
426
|
// celilo.db: must close the live DB connection before replacing
|
|
333
427
|
// the file or SQLite gets confused (macOS holds a vnode handle).
|
|
334
428
|
if (existsSync(stagedDb)) {
|
|
429
|
+
// Reconcile module source_paths to THIS box on the staged file BEFORE the
|
|
430
|
+
// swap (and before closeDb) — fixes cross-host / cross-OS restores. See
|
|
431
|
+
// rewriteStagedModuleSourcePaths.
|
|
432
|
+
rewriteStagedModuleSourcePaths(stagedDb);
|
|
335
433
|
closeDb();
|
|
336
434
|
const livePath = getDbPath();
|
|
337
435
|
mkdirSync(join(livePath, '..'), { recursive: true });
|
|
@@ -348,7 +446,7 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
|
|
|
348
446
|
dbApplied = true;
|
|
349
447
|
}
|
|
350
448
|
|
|
351
|
-
return { dbApplied, keyApplied, sshApplied };
|
|
449
|
+
return { dbApplied, keyApplied, sshApplied, moduleSourcesApplied };
|
|
352
450
|
}
|
|
353
451
|
|
|
354
452
|
/**
|
|
@@ -57,8 +57,14 @@ export function validateTerraformPlanSafety(planOutput: string): PlanSafetyResul
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// Rule 2:
|
|
61
|
-
|
|
60
|
+
// Rule 2: CREATE and in-place UPDATE are allowed; REPLACE and DELETE are
|
|
61
|
+
// refused. (ISS-0055) An in-place update — the lifecycle-ignored
|
|
62
|
+
// computed-attribute redeploy, or a deliberate memory/cpu resize — is safe
|
|
63
|
+
// and must go through, so the management plane can update containers in
|
|
64
|
+
// place. Only destroy+recreate (replace) and delete are data-loss
|
|
65
|
+
// operations we hard-block. The parser matches `replace` before `update`,
|
|
66
|
+
// so a "must be replaced" plan is still classified as replace and refused.
|
|
67
|
+
if (action.action !== 'create' && action.action !== 'update') {
|
|
62
68
|
return {
|
|
63
69
|
safe: false,
|
|
64
70
|
error: formatUnsafeOperationError(action),
|
|
@@ -101,12 +101,12 @@ export function validateModuleZoneRequirements(
|
|
|
101
101
|
* Policy function - determines if zone validation applies
|
|
102
102
|
*
|
|
103
103
|
* Zone is required if:
|
|
104
|
-
* - Module specifies requires.
|
|
104
|
+
* - Module specifies requires.system (infrastructure provisioning)
|
|
105
105
|
*
|
|
106
106
|
* Zone is optional if:
|
|
107
107
|
* - Module has no infrastructure requirements (e.g., VPS-based modules)
|
|
108
108
|
*
|
|
109
|
-
* @param hasInfrastructureSpec - Whether module has requires.
|
|
109
|
+
* @param hasInfrastructureSpec - Whether module has requires.system
|
|
110
110
|
* @returns True if zone field is required
|
|
111
111
|
*/
|
|
112
112
|
export function isZoneRequired(hasInfrastructureSpec: boolean): boolean {
|
|
@@ -495,7 +495,7 @@ resource "proxmox_lxc" "container" {
|
|
|
495
495
|
provides: { capabilities: [] },
|
|
496
496
|
requires: {
|
|
497
497
|
capabilities: [],
|
|
498
|
-
|
|
498
|
+
system: {
|
|
499
499
|
cpu: 1,
|
|
500
500
|
memory: 1024,
|
|
501
501
|
disk: 10,
|
|
@@ -650,7 +650,9 @@ resource "proxmox_lxc" "container" {
|
|
|
650
650
|
const out = injectProxmoxLxcDns(LXC, true);
|
|
651
651
|
expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
|
|
652
652
|
expect(out).toContain(' lifecycle {');
|
|
653
|
-
expect(out).toContain(
|
|
653
|
+
expect(out).toContain(
|
|
654
|
+
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]',
|
|
655
|
+
);
|
|
654
656
|
// Injected immediately after the opening line, before author attributes.
|
|
655
657
|
const lines = out.split('\n');
|
|
656
658
|
expect(lines[0]).toBe('resource "proxmox_lxc" "caddy" {');
|
|
@@ -665,7 +667,9 @@ resource "proxmox_lxc" "container" {
|
|
|
665
667
|
const out = injectProxmoxLxcDns(LXC, false);
|
|
666
668
|
expect(out).not.toContain('nameserver = "$self:lxc_nameserver"');
|
|
667
669
|
expect(out).toContain(' lifecycle {');
|
|
668
|
-
expect(out).toContain(
|
|
670
|
+
expect(out).toContain(
|
|
671
|
+
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]',
|
|
672
|
+
);
|
|
669
673
|
});
|
|
670
674
|
|
|
671
675
|
test('is idempotent — already-injected content is returned unchanged', () => {
|
|
@@ -687,8 +691,8 @@ resource "proxmox_lxc" "container" {
|
|
|
687
691
|
const out = injectProxmoxLxcDns(stale, true);
|
|
688
692
|
// Exactly one nameserver attribute survives.
|
|
689
693
|
expect(out.match(/nameserver\s*=/g)?.length).toBe(1);
|
|
690
|
-
// The lifecycle guard is still added.
|
|
691
|
-
expect(out).toContain('ignore_changes = [nameserver
|
|
694
|
+
// The lifecycle guard is still added (ISS-0055 extended the ignore list).
|
|
695
|
+
expect(out).toContain('ignore_changes = [nameserver,');
|
|
692
696
|
});
|
|
693
697
|
|
|
694
698
|
test('leaves non-proxmox_lxc resources untouched', () => {
|
|
@@ -699,7 +703,7 @@ resource "proxmox_lxc" "container" {
|
|
|
699
703
|
test('injects into every proxmox_lxc block in a multi-resource file', () => {
|
|
700
704
|
const two = `${LXC}\n\n${LXC.replace('"caddy"', '"forgejo"')}`;
|
|
701
705
|
const out = injectProxmoxLxcDns(two, true);
|
|
702
|
-
expect(out.match(/ignore_changes = \[nameserver
|
|
706
|
+
expect(out.match(/ignore_changes = \[nameserver,/g)?.length).toBe(2);
|
|
703
707
|
});
|
|
704
708
|
|
|
705
709
|
test('matches the indentation of the resource opening line', () => {
|
|
@@ -707,7 +711,9 @@ resource "proxmox_lxc" "container" {
|
|
|
707
711
|
const out = injectProxmoxLxcDns(indented, true);
|
|
708
712
|
expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
|
|
709
713
|
expect(out).toContain(' lifecycle {');
|
|
710
|
-
expect(out).toContain(
|
|
714
|
+
expect(out).toContain(
|
|
715
|
+
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]',
|
|
716
|
+
);
|
|
711
717
|
});
|
|
712
718
|
});
|
|
713
719
|
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
moduleInfrastructure,
|
|
16
16
|
modules,
|
|
17
17
|
} from '../db/schema';
|
|
18
|
+
import { getSingularSystemSpec } from '../manifest/schema';
|
|
18
19
|
import type { AnsibleCollection, ModuleManifest } from '../manifest/schema';
|
|
19
20
|
import { validateZoneRequirements } from '../manifest/validate';
|
|
20
21
|
import { selectInfrastructure } from '../services/infrastructure-selector';
|
|
@@ -161,7 +162,10 @@ export function getOutputFilename(templateFilename: string): string {
|
|
|
161
162
|
*/
|
|
162
163
|
export function injectProxmoxLxcDns(content: string, hasNameserver: boolean): string {
|
|
163
164
|
// Already injected (idempotent) or author opted into the lifecycle — done.
|
|
164
|
-
|
|
165
|
+
// Match the `[nameserver` prefix (no closing bracket) so this stays true
|
|
166
|
+
// whether the list is the original `[nameserver]` or the ISS-0055-extended
|
|
167
|
+
// `[nameserver, network[0].hwaddr, …]` — otherwise re-generate double-injects.
|
|
168
|
+
if (content.includes('ignore_changes = [nameserver')) {
|
|
165
169
|
return content;
|
|
166
170
|
}
|
|
167
171
|
|
|
@@ -181,7 +185,17 @@ export function injectProxmoxLxcDns(content: string, hasNameserver: boolean): st
|
|
|
181
185
|
injected.push(`${inner}nameserver = "$self:lxc_nameserver"`);
|
|
182
186
|
}
|
|
183
187
|
injected.push(`${inner}lifecycle {`);
|
|
184
|
-
|
|
188
|
+
// ISS-0055: ignore the ForceNew attributes Proxmox assigns at create time —
|
|
189
|
+
// the MAC (network hwaddr) and the rootfs volume path. telmate marks these
|
|
190
|
+
// ForceNew, so leaving them out of the config makes every re-deploy plan a
|
|
191
|
+
// destructive REPLACE. Ignoring just these two makes an unchanged re-deploy a
|
|
192
|
+
// no-op (the computed network id/type stay stable once the block is no longer
|
|
193
|
+
// being replaced) and a real change (e.g. a memory bump) an in-place UPDATE.
|
|
194
|
+
// We deliberately do NOT list network[0].id / network[0].type — they aren't
|
|
195
|
+
// schema attributes in telmate ~>2.9 and `terraform validate` rejects them.
|
|
196
|
+
// `nameserver` stays for the original DNS reason; `rootfs.size` is NOT ignored
|
|
197
|
+
// so a disk grow still applies in place.
|
|
198
|
+
injected.push(`${inner} ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]`);
|
|
185
199
|
injected.push(`${inner}}`);
|
|
186
200
|
return injected.join('\n');
|
|
187
201
|
});
|
|
@@ -507,9 +521,9 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
507
521
|
|
|
508
522
|
// Infrastructure Selection
|
|
509
523
|
// Select infrastructure for this module and store in database
|
|
510
|
-
// Only required for modules that specify requires.
|
|
524
|
+
// Only required for modules that specify requires.system (container-based or machine-pool-based)
|
|
511
525
|
let infrastructureSelection: InfrastructureSelection | undefined;
|
|
512
|
-
const hasResourcesSpec = manifest
|
|
526
|
+
const hasResourcesSpec = getSingularSystemSpec(manifest);
|
|
513
527
|
|
|
514
528
|
if (hasResourcesSpec) {
|
|
515
529
|
// Check if infrastructure already selected (from previous generation)
|
|
@@ -559,7 +573,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
559
573
|
// IPAM Auto-Allocation
|
|
560
574
|
// Only allocate for Proxmox container services (not Digital Ocean or machines)
|
|
561
575
|
// Digital Ocean gets IPs from Terraform outputs; Proxmox needs local IPAM
|
|
562
|
-
const zone = manifest
|
|
576
|
+
const zone = getSingularSystemSpec(manifest)?.zone;
|
|
563
577
|
const hasManualVmid = manifest.variables?.owns?.some((v) => v.name === 'vmid');
|
|
564
578
|
const isContainerService = infrastructureSelection?.type === 'container_service';
|
|
565
579
|
|
|
@@ -1094,7 +1108,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
1094
1108
|
// Build infrastructure info for CLI output
|
|
1095
1109
|
let infrastructureInfo: import('./types').InfrastructureInfo | undefined;
|
|
1096
1110
|
if (infrastructureSelection) {
|
|
1097
|
-
const zone = manifest
|
|
1111
|
+
const zone = getSingularSystemSpec(manifest)?.zone || 'unknown';
|
|
1098
1112
|
|
|
1099
1113
|
if (infrastructureSelection.type === 'machine' && infrastructureSelection.machineId) {
|
|
1100
1114
|
const machine = await db
|