@celilo/cli 0.3.30-alpha.0 → 0.4.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/drizzle/0005_module_operations.sql +12 -0
- package/drizzle/0006_base_module_aspects.sql +15 -0
- package/drizzle/0007_module_systems.sql +17 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +3 -3
- package/schemas/system_config.json +14 -28
- package/src/ansible/inventory.test.ts +46 -62
- package/src/ansible/inventory.ts +48 -25
- package/src/capabilities/registration.ts +25 -7
- package/src/capabilities/validation.test.ts +30 -0
- package/src/capabilities/validation.ts +8 -0
- package/src/cli/backup-rename.test.ts +95 -0
- package/src/cli/cli.test.ts +17 -23
- package/src/cli/command-registry.ts +199 -0
- package/src/cli/commands/backup-list.ts +1 -1
- package/src/cli/commands/events.ts +96 -0
- package/src/cli/commands/machine-add.ts +103 -59
- package/src/cli/commands/module-import.ts +153 -4
- package/src/cli/commands/module-remove.ts +86 -17
- package/src/cli/commands/module-status.ts +6 -2
- package/src/cli/commands/publish/alpha.test.ts +185 -0
- package/src/cli/commands/publish/alpha.ts +226 -0
- package/src/cli/commands/publish/changesets.test.ts +89 -0
- package/src/cli/commands/publish/changesets.ts +144 -0
- package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
- package/src/cli/commands/publish/consumer-pins.ts +149 -0
- package/src/cli/commands/publish/execute.ts +131 -0
- package/src/cli/commands/publish/global-install.test.ts +154 -0
- package/src/cli/commands/publish/global-install.ts +171 -0
- package/src/cli/commands/publish/helpers.ts +227 -0
- package/src/cli/commands/publish/index.ts +365 -0
- package/src/cli/commands/publish/module-registry.test.ts +40 -0
- package/src/cli/commands/publish/module-registry.ts +64 -0
- package/src/cli/commands/publish/plan.ts +107 -0
- package/src/cli/commands/publish/preflight.ts +238 -0
- package/src/cli/commands/publish/types.ts +264 -0
- package/src/cli/commands/publish/workspace.test.ts +323 -0
- package/src/cli/commands/publish/workspace.ts +596 -0
- package/src/cli/commands/restore.ts +126 -0
- package/src/cli/commands/storage-add-local.ts +1 -1
- package/src/cli/commands/storage-add-s3.ts +1 -1
- package/src/cli/commands/subscribers-add.ts +68 -0
- package/src/cli/commands/subscribers-list.ts +48 -0
- package/src/cli/commands/subscribers-remove.ts +38 -0
- package/src/cli/commands/subscribers-serve.ts +77 -0
- package/src/cli/commands/subscribers-status.ts +33 -0
- package/src/cli/commands/subscribers-test.ts +71 -0
- package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
- package/src/cli/commands/system-apply-config.test.ts +70 -0
- package/src/cli/commands/system-apply-config.ts +130 -0
- package/src/cli/commands/system-audit.ts +2 -1
- package/src/cli/commands/system-init-deprecation.test.ts +90 -0
- package/src/cli/commands/system-init.ts +36 -70
- package/src/cli/commands/system-update.ts +3 -2
- package/src/cli/completion.ts +22 -1
- package/src/cli/index.ts +214 -6
- package/src/cli/interactive-config.test.ts +19 -0
- package/src/cli/restore-command.test.ts +131 -0
- package/src/db/client.ts +42 -0
- package/src/db/schema.test.ts +13 -16
- package/src/db/schema.ts +161 -9
- package/src/hooks/capability-loader-firewall.test.ts +6 -15
- package/src/hooks/capability-loader.test.ts +2 -3
- package/src/hooks/capability-loader.ts +36 -2
- package/src/hooks/define-hook.test.ts +4 -0
- package/src/hooks/executor.test.ts +18 -0
- package/src/hooks/executor.ts +21 -2
- package/src/hooks/load-hook-config.test.ts +26 -24
- package/src/hooks/load-hook-config.ts +11 -2
- package/src/hooks/run-named-hook.ts +16 -0
- package/src/hooks/types.ts +9 -1
- package/src/manifest/contracts/v1.ts +70 -0
- package/src/manifest/schema.ts +262 -16
- package/src/manifest/validate-privileged.test.ts +84 -0
- package/src/manifest/validate.test.ts +156 -0
- package/src/manifest/validate.ts +69 -0
- package/src/module/import.ts +12 -0
- package/src/services/aspect-approvals.test.ts +231 -0
- package/src/services/aspect-approvals.ts +120 -0
- package/src/services/aspect-runner.test.ts +493 -0
- package/src/services/aspect-runner.ts +438 -0
- package/src/services/aspect-template-resolver.test.ts +101 -0
- package/src/services/aspect-template-resolver.ts +122 -0
- package/src/services/backup-create.ts +104 -25
- package/src/services/backup-envelope-roundtrip.test.ts +199 -0
- package/src/services/backup-in-flight-refusal.test.ts +163 -0
- package/src/services/backup-manifest.test.ts +115 -0
- package/src/services/backup-manifest.ts +163 -0
- package/src/services/backup-restore.ts +154 -19
- package/src/services/build-bus/delivery-events.ts +92 -0
- package/src/services/build-bus/event-factory.ts +54 -0
- package/src/services/build-bus/fan-out.test.ts +279 -0
- package/src/services/build-bus/fan-out.ts +161 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
- package/src/services/build-bus/hook-dispatch.test.ts +207 -0
- package/src/services/build-bus/hook-dispatch.ts +198 -0
- package/src/services/build-bus/hook-dispatcher.ts +115 -0
- package/src/services/build-bus/index.ts +41 -0
- package/src/services/build-bus/receiver-server.test.ts +179 -0
- package/src/services/build-bus/receiver-server.ts +159 -0
- package/src/services/build-bus/status.test.ts +212 -0
- package/src/services/build-bus/status.ts +213 -0
- package/src/services/build-bus/subscriber-store.ts +113 -0
- package/src/services/celilo-events.test.ts +70 -0
- package/src/services/celilo-events.ts +92 -0
- package/src/services/celilo-mgmt-hooks.test.ts +296 -0
- package/src/services/config-interview.ts +13 -95
- package/src/services/cross-module-data-manager.ts +2 -31
- package/src/services/cross-module-read.test.ts +250 -0
- package/src/services/cross-module-read.ts +232 -0
- package/src/services/deploy-validation.ts +7 -0
- package/src/services/deployed-systems.test.ts +235 -0
- package/src/services/deployed-systems.ts +308 -0
- package/src/services/dns-provider-backfill.ts +75 -0
- package/src/services/health-runner.ts +19 -3
- package/src/services/infrastructure-variable-resolver.test.ts +6 -32
- package/src/services/infrastructure-variable-resolver.ts +3 -13
- package/src/services/machine-detector.ts +104 -48
- package/src/services/machine-pool.ts +145 -2
- package/src/services/module-config.ts +78 -120
- package/src/services/module-deploy.ts +113 -40
- package/src/services/module-operations.test.ts +154 -0
- package/src/services/module-operations.ts +154 -0
- package/src/services/module-subscriptions.test.ts +58 -0
- package/src/services/module-subscriptions.ts +24 -1
- package/src/services/module-types-generator.test.ts +3 -3
- package/src/services/module-types-generator.ts +7 -2
- package/src/services/proxmox-reconcile.test.ts +333 -0
- package/src/services/proxmox-reconcile.ts +156 -0
- package/src/services/proxmox-state-recovery.ts +3 -24
- package/src/services/restore-from-file.test.ts +177 -0
- package/src/services/restore-from-file.ts +355 -0
- package/src/services/restore-preflight.test.ts +127 -0
- package/src/services/restore-preflight.ts +118 -0
- package/src/services/storage-providers/s3.ts +10 -2
- package/src/services/system-identity.ts +30 -0
- package/src/services/system-init.test.ts +64 -21
- package/src/services/system-init.ts +28 -26
- package/src/templates/generator.test.ts +7 -16
- package/src/templates/generator.ts +28 -115
- package/src/test-utils/integration.ts +5 -2
- package/src/types/infrastructure.ts +8 -0
- package/src/variables/computed/computed-integration.test.ts +191 -0
- package/src/variables/computed/computed.test.ts +177 -0
- package/src/variables/computed/evaluate.ts +271 -0
- package/src/variables/computed/marker.ts +53 -0
- package/src/variables/computed/parse.ts +262 -0
- package/src/variables/computed/provider-lookup.ts +130 -0
- package/src/variables/context.test.ts +89 -28
- package/src/variables/context.ts +196 -191
- package/src/variables/parser.ts +3 -3
- package/src/variables/resolver.test.ts +61 -0
- package/src/variables/resolver.ts +81 -0
- package/src/variables/types.ts +23 -1
- package/src/services/dns-auto-register.ts +0 -211
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for cross-module-read materialization + atomic write-back.
|
|
3
|
+
*
|
|
4
|
+
* Read side: verify that materializeCrossModuleRoot mirrors every
|
|
5
|
+
* OTHER deployed module's terraform/ dir + writes an index.json.
|
|
6
|
+
* Write side: verify applyCrossModuleWriteRoot atomically rotates
|
|
7
|
+
* staged dirs into live storage, and rolls back on mid-loop failure.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
11
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { closeDb, getDb } from '../db/client';
|
|
15
|
+
import { runMigrations } from '../db/migrate';
|
|
16
|
+
import { modules } from '../db/schema';
|
|
17
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
18
|
+
import {
|
|
19
|
+
type CrossModuleIndex,
|
|
20
|
+
applyCrossModuleWriteRoot,
|
|
21
|
+
materializeCrossModuleRoot,
|
|
22
|
+
moduleHasCrossModuleRead,
|
|
23
|
+
} from './cross-module-read';
|
|
24
|
+
|
|
25
|
+
function buildManifest(caps: Array<{ name: string; version: string }>): ModuleManifest {
|
|
26
|
+
return {
|
|
27
|
+
celilo_contract: '1.0',
|
|
28
|
+
id: 'm',
|
|
29
|
+
name: 'm',
|
|
30
|
+
version: '0.0.1',
|
|
31
|
+
requires: { capabilities: caps },
|
|
32
|
+
} as ModuleManifest;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('moduleHasCrossModuleRead', () => {
|
|
36
|
+
it('returns false for manifests with no capabilities', () => {
|
|
37
|
+
expect(moduleHasCrossModuleRead(buildManifest([]))).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns false for manifests with other capabilities', () => {
|
|
41
|
+
expect(moduleHasCrossModuleRead(buildManifest([{ name: 'public_web', version: '^3.0' }]))).toBe(
|
|
42
|
+
false,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns true when cross_module_read is declared in requires', () => {
|
|
47
|
+
expect(
|
|
48
|
+
moduleHasCrossModuleRead(buildManifest([{ name: 'cross_module_read', version: '^1.0' }])),
|
|
49
|
+
).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns true when cross_module_read is declared in optional', () => {
|
|
53
|
+
const manifest = {
|
|
54
|
+
...buildManifest([]),
|
|
55
|
+
optional: { capabilities: [{ name: 'cross_module_read', version: '^1.0' }] },
|
|
56
|
+
} as ModuleManifest;
|
|
57
|
+
expect(moduleHasCrossModuleRead(manifest)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('materializeCrossModuleRoot', () => {
|
|
62
|
+
let dir: string;
|
|
63
|
+
|
|
64
|
+
beforeEach(async () => {
|
|
65
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-xmr-test-'));
|
|
66
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
67
|
+
process.env.CELILO_DATA_DIR = dir;
|
|
68
|
+
await runMigrations(process.env.CELILO_DB_PATH);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
closeDb();
|
|
73
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
74
|
+
process.env.CELILO_DATA_DIR = undefined;
|
|
75
|
+
try {
|
|
76
|
+
rmSync(dir, { recursive: true, force: true });
|
|
77
|
+
} catch {
|
|
78
|
+
/* ignore */
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
function plantDeployedModule(id: string, version: string, tfstateContent: string): void {
|
|
83
|
+
const db = getDb();
|
|
84
|
+
db.insert(modules)
|
|
85
|
+
.values({
|
|
86
|
+
id,
|
|
87
|
+
name: id,
|
|
88
|
+
version,
|
|
89
|
+
sourcePath: join(dir, 'sources', id),
|
|
90
|
+
manifestData: { id, name: id, version, celilo_contract: '1.0', requires: {} } as Record<
|
|
91
|
+
string,
|
|
92
|
+
unknown
|
|
93
|
+
>,
|
|
94
|
+
})
|
|
95
|
+
.run();
|
|
96
|
+
const tfDir = join(dir, 'modules', id, 'generated', 'terraform');
|
|
97
|
+
mkdirSync(tfDir, { recursive: true });
|
|
98
|
+
writeFileSync(join(tfDir, 'terraform.tfstate'), tfstateContent);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
it("mirrors every other deployed module's terraform/ + writes index.json", () => {
|
|
102
|
+
plantDeployedModule('homebridge', '0.1.0', '{"state":"homebridge"}');
|
|
103
|
+
plantDeployedModule('caddy', '0.2.0', '{"state":"caddy"}');
|
|
104
|
+
plantDeployedModule('celilo-mgmt', '0.3.0', '{"state":"mgmt"}');
|
|
105
|
+
|
|
106
|
+
const root = join(dir, 'cross-module-root');
|
|
107
|
+
materializeCrossModuleRoot(root, 'celilo-mgmt');
|
|
108
|
+
|
|
109
|
+
// index.json lists the OTHER modules, not celilo-mgmt
|
|
110
|
+
const index: CrossModuleIndex = JSON.parse(readFileSync(join(root, 'index.json'), 'utf-8'));
|
|
111
|
+
expect(index.schemaVersion).toBe('1.0');
|
|
112
|
+
expect(index.modules.map((m) => m.id).sort()).toEqual(['caddy', 'homebridge']);
|
|
113
|
+
|
|
114
|
+
// Mirrored TF state is reachable + matches the live content.
|
|
115
|
+
const mirroredHomebridge = readFileSync(
|
|
116
|
+
join(root, 'modules', 'homebridge', 'terraform', 'terraform.tfstate'),
|
|
117
|
+
'utf-8',
|
|
118
|
+
);
|
|
119
|
+
expect(mirroredHomebridge).toBe('{"state":"homebridge"}');
|
|
120
|
+
expect(existsSync(join(root, 'modules', 'celilo-mgmt'))).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('mirrors state + lock file but EXCLUDES the .terraform provider cache (ISS-0015)', () => {
|
|
124
|
+
plantDeployedModule('caddy', '0.2.0', '{"state":"caddy"}');
|
|
125
|
+
// Plant a realistic terraform dir: state + lock file (must travel) plus a
|
|
126
|
+
// .terraform/providers/ binary (must NOT — re-fetchable, ~19 MB each).
|
|
127
|
+
const tfDir = join(dir, 'modules', 'caddy', 'generated', 'terraform');
|
|
128
|
+
writeFileSync(join(tfDir, '.terraform.lock.hcl'), 'provider "telmate/proxmox" {}');
|
|
129
|
+
const providerDir = join(tfDir, '.terraform', 'providers', 'registry.terraform.io', 'telmate');
|
|
130
|
+
mkdirSync(providerDir, { recursive: true });
|
|
131
|
+
writeFileSync(join(providerDir, 'terraform-provider-proxmox'), 'BINARY'.repeat(1000));
|
|
132
|
+
|
|
133
|
+
const root = join(dir, 'cross-module-root');
|
|
134
|
+
materializeCrossModuleRoot(root, 'celilo-mgmt');
|
|
135
|
+
|
|
136
|
+
const mirrored = join(root, 'modules', 'caddy', 'terraform');
|
|
137
|
+
expect(existsSync(join(mirrored, 'terraform.tfstate'))).toBe(true);
|
|
138
|
+
expect(existsSync(join(mirrored, '.terraform.lock.hcl'))).toBe(true);
|
|
139
|
+
// The provider cache must not be in the envelope.
|
|
140
|
+
expect(existsSync(join(mirrored, '.terraform'))).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('skips modules with no terraform.tfstate (not deployed)', () => {
|
|
144
|
+
plantDeployedModule('caddy', '0.2.0', '{"state":"caddy"}');
|
|
145
|
+
// imported-but-not-deployed: row exists but no tf state on disk
|
|
146
|
+
const db = getDb();
|
|
147
|
+
db.insert(modules)
|
|
148
|
+
.values({
|
|
149
|
+
id: 'half-installed',
|
|
150
|
+
name: 'half-installed',
|
|
151
|
+
version: '0.0.1',
|
|
152
|
+
sourcePath: join(dir, 'sources', 'half-installed'),
|
|
153
|
+
manifestData: {} as Record<string, unknown>,
|
|
154
|
+
})
|
|
155
|
+
.run();
|
|
156
|
+
|
|
157
|
+
const root = join(dir, 'cross-module-root');
|
|
158
|
+
materializeCrossModuleRoot(root, 'celilo-mgmt');
|
|
159
|
+
|
|
160
|
+
const index: CrossModuleIndex = JSON.parse(readFileSync(join(root, 'index.json'), 'utf-8'));
|
|
161
|
+
expect(index.modules.map((m) => m.id)).toEqual(['caddy']);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('produces an empty index when no other modules are deployed', () => {
|
|
165
|
+
plantDeployedModule('celilo-mgmt', '0.3.0', '{}');
|
|
166
|
+
const root = join(dir, 'cross-module-root');
|
|
167
|
+
materializeCrossModuleRoot(root, 'celilo-mgmt');
|
|
168
|
+
const index: CrossModuleIndex = JSON.parse(readFileSync(join(root, 'index.json'), 'utf-8'));
|
|
169
|
+
expect(index.modules).toHaveLength(0);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('applyCrossModuleWriteRoot', () => {
|
|
174
|
+
let dir: string;
|
|
175
|
+
|
|
176
|
+
beforeEach(() => {
|
|
177
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-xmw-test-'));
|
|
178
|
+
process.env.CELILO_DATA_DIR = dir;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
afterEach(() => {
|
|
182
|
+
process.env.CELILO_DATA_DIR = undefined;
|
|
183
|
+
try {
|
|
184
|
+
rmSync(dir, { recursive: true, force: true });
|
|
185
|
+
} catch {
|
|
186
|
+
/* ignore */
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
function stage(stagingRoot: string, moduleId: string, fileName: string, content: string): void {
|
|
191
|
+
const tfDir = join(stagingRoot, 'modules', moduleId, 'terraform');
|
|
192
|
+
mkdirSync(tfDir, { recursive: true });
|
|
193
|
+
writeFileSync(join(tfDir, fileName), content);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function plantLive(moduleId: string, fileName: string, content: string): void {
|
|
197
|
+
const tfDir = join(dir, 'modules', moduleId, 'generated', 'terraform');
|
|
198
|
+
mkdirSync(tfDir, { recursive: true });
|
|
199
|
+
writeFileSync(join(tfDir, fileName), content);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function readLive(moduleId: string, fileName: string): string | null {
|
|
203
|
+
const p = join(dir, 'modules', moduleId, 'generated', 'terraform', fileName);
|
|
204
|
+
if (!existsSync(p)) return null;
|
|
205
|
+
return readFileSync(p, 'utf-8');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
it('replaces live tf state with staged content', () => {
|
|
209
|
+
plantLive('caddy', 'terraform.tfstate', 'old-state');
|
|
210
|
+
const stagingRoot = join(dir, 'staging');
|
|
211
|
+
mkdirSync(stagingRoot, { recursive: true });
|
|
212
|
+
stage(stagingRoot, 'caddy', 'terraform.tfstate', 'new-state');
|
|
213
|
+
|
|
214
|
+
const result = applyCrossModuleWriteRoot(stagingRoot);
|
|
215
|
+
expect(result.applied).toEqual(['caddy']);
|
|
216
|
+
expect(result.skipped).toEqual([]);
|
|
217
|
+
expect(readLive('caddy', 'terraform.tfstate')).toBe('new-state');
|
|
218
|
+
|
|
219
|
+
// The .cross-module-restore-old sibling should be cleaned up.
|
|
220
|
+
expect(
|
|
221
|
+
existsSync(join(dir, 'modules', 'caddy', 'generated', 'terraform.cross-module-restore-old')),
|
|
222
|
+
).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('creates a fresh live dir when none existed before', () => {
|
|
226
|
+
const stagingRoot = join(dir, 'staging');
|
|
227
|
+
mkdirSync(stagingRoot, { recursive: true });
|
|
228
|
+
stage(stagingRoot, 'new-module', 'terraform.tfstate', 'fresh');
|
|
229
|
+
|
|
230
|
+
const result = applyCrossModuleWriteRoot(stagingRoot);
|
|
231
|
+
expect(result.applied).toEqual(['new-module']);
|
|
232
|
+
expect(readLive('new-module', 'terraform.tfstate')).toBe('fresh');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('reports skipped modules whose staged subtree has no terraform/ dir', () => {
|
|
236
|
+
const stagingRoot = join(dir, 'staging');
|
|
237
|
+
mkdirSync(join(stagingRoot, 'modules', 'orphan'), { recursive: true });
|
|
238
|
+
const result = applyCrossModuleWriteRoot(stagingRoot);
|
|
239
|
+
expect(result.skipped).toEqual(['orphan']);
|
|
240
|
+
expect(result.applied).toEqual([]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('is a no-op when staging root has no modules/ dir', () => {
|
|
244
|
+
const stagingRoot = join(dir, 'empty-staging');
|
|
245
|
+
mkdirSync(stagingRoot, { recursive: true });
|
|
246
|
+
const result = applyCrossModuleWriteRoot(stagingRoot);
|
|
247
|
+
expect(result.applied).toEqual([]);
|
|
248
|
+
expect(result.skipped).toEqual([]);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Materialization helpers for the `cross_module_read` privilege.
|
|
3
|
+
*
|
|
4
|
+
* Phase 2 of v2/SYSTEM_BACKUP_TERRAFORM_STATE.md. When an allow-listed
|
|
5
|
+
* module (today: only celilo-mgmt) declares `cross_module_read` in its
|
|
6
|
+
* `requires.capabilities`, its on_backup hook receives a
|
|
7
|
+
* `cross_module_root` input pointing at a directory mirroring OTHER
|
|
8
|
+
* modules' generated/terraform/ trees plus an index.json enumerating
|
|
9
|
+
* deployed modules. On restore, the symmetric `cross_module_write_root`
|
|
10
|
+
* input gives the hook a staging dir that the framework atomically
|
|
11
|
+
* applies back onto live storage.
|
|
12
|
+
*
|
|
13
|
+
* The privilege is enforced in two places:
|
|
14
|
+
* 1. validatePrivilegedCapabilities (manifest/validate.ts) — refuses
|
|
15
|
+
* modules outside the allow-list at import time.
|
|
16
|
+
* 2. moduleHasCrossModuleRead (here) — returns true only for manifests
|
|
17
|
+
* that actually declared the requirement. Belt-and-suspenders so a
|
|
18
|
+
* bug in import-time validation can't silently grant the privilege
|
|
19
|
+
* to a module that didn't ask.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
copyFileSync,
|
|
24
|
+
existsSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
readdirSync,
|
|
27
|
+
renameSync,
|
|
28
|
+
rmSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
} from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import { getModuleStoragePath } from '../config/paths';
|
|
33
|
+
import { getDb } from '../db/client';
|
|
34
|
+
import { modules } from '../db/schema';
|
|
35
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
36
|
+
|
|
37
|
+
const CROSS_MODULE_READ_NAME = 'cross_module_read';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* True if the manifest declares cross_module_read (in requires or optional).
|
|
41
|
+
* Note: this DOES NOT enforce the allow-list — that's the validator's job
|
|
42
|
+
* at import time. By the time we're calling this at backup/restore time,
|
|
43
|
+
* the module is in the DB, which means it passed validation.
|
|
44
|
+
*/
|
|
45
|
+
export function moduleHasCrossModuleRead(manifest: ModuleManifest): boolean {
|
|
46
|
+
const declaresIn = (caps: { name: string }[] | undefined): boolean =>
|
|
47
|
+
(caps ?? []).some((c) => c.name === CROSS_MODULE_READ_NAME);
|
|
48
|
+
return declaresIn(manifest.requires?.capabilities) || declaresIn(manifest.optional?.capabilities);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CrossModuleIndexEntry {
|
|
52
|
+
id: string;
|
|
53
|
+
version: string;
|
|
54
|
+
terraformStateDir: string; // relative path inside cross_module_root
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CrossModuleIndex {
|
|
58
|
+
/** Schema version of the index.json shape — bumps independently of the envelope schema. */
|
|
59
|
+
schemaVersion: '1.0';
|
|
60
|
+
generatedAt: string; // ISO 8601
|
|
61
|
+
modules: CrossModuleIndexEntry[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Populate a read-only mirror of every deployed module's
|
|
66
|
+
* generated/terraform/ tree under `<rootDir>/modules/<id>/terraform/`,
|
|
67
|
+
* plus an index.json describing them. Excludes the calling module
|
|
68
|
+
* itself (the hook reads ITS OWN data via the normal backup_dir
|
|
69
|
+
* mechanism, not via this mirror).
|
|
70
|
+
*
|
|
71
|
+
* Returns the path to the root directory — callers pass this to the
|
|
72
|
+
* hook as the `cross_module_root` input.
|
|
73
|
+
*/
|
|
74
|
+
export function materializeCrossModuleRoot(rootDir: string, excludeModuleId: string): string {
|
|
75
|
+
mkdirSync(rootDir, { recursive: true });
|
|
76
|
+
const modulesDir = join(rootDir, 'modules');
|
|
77
|
+
mkdirSync(modulesDir, { recursive: true });
|
|
78
|
+
|
|
79
|
+
const db = getDb();
|
|
80
|
+
const allModules = db.select().from(modules).all();
|
|
81
|
+
const storageRoot = getModuleStoragePath();
|
|
82
|
+
|
|
83
|
+
const entries: CrossModuleIndexEntry[] = [];
|
|
84
|
+
for (const mod of allModules) {
|
|
85
|
+
if (mod.id === excludeModuleId) continue;
|
|
86
|
+
|
|
87
|
+
const liveTfDir = join(storageRoot, mod.id, 'generated', 'terraform');
|
|
88
|
+
if (!existsSync(liveTfDir)) continue; // not deployed (no TF state to mirror)
|
|
89
|
+
|
|
90
|
+
const mirroredTfDir = join(modulesDir, mod.id, 'terraform');
|
|
91
|
+
mkdirSync(mirroredTfDir, { recursive: true });
|
|
92
|
+
copyDirShallow(liveTfDir, mirroredTfDir);
|
|
93
|
+
|
|
94
|
+
entries.push({
|
|
95
|
+
id: mod.id,
|
|
96
|
+
version: mod.version,
|
|
97
|
+
terraformStateDir: `modules/${mod.id}/terraform`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const index: CrossModuleIndex = {
|
|
102
|
+
schemaVersion: '1.0',
|
|
103
|
+
generatedAt: new Date().toISOString(),
|
|
104
|
+
modules: entries,
|
|
105
|
+
};
|
|
106
|
+
writeFileSync(join(rootDir, 'index.json'), JSON.stringify(index, null, 2));
|
|
107
|
+
|
|
108
|
+
return rootDir;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* After on_restore returns successfully, atomically apply each module's
|
|
113
|
+
* staged terraform/ subtree back to its live storage path.
|
|
114
|
+
*
|
|
115
|
+
* Atomicity strategy:
|
|
116
|
+
* - Each module's restore happens as a rename(live → live.old) + rename(staged → live).
|
|
117
|
+
* - On any error mid-loop, rename(live.old → live) for the modules already touched.
|
|
118
|
+
* - At the end of a successful sweep, rm -rf the live.old siblings.
|
|
119
|
+
*
|
|
120
|
+
* If the staging dir is empty or doesn't exist, this is a no-op.
|
|
121
|
+
*/
|
|
122
|
+
export interface ApplyResult {
|
|
123
|
+
applied: string[]; // module IDs whose state was replaced
|
|
124
|
+
skipped: string[]; // module IDs whose staged dir was empty/missing
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function applyCrossModuleWriteRoot(stagingRootDir: string): ApplyResult {
|
|
128
|
+
const stagingModulesDir = join(stagingRootDir, 'modules');
|
|
129
|
+
if (!existsSync(stagingModulesDir)) {
|
|
130
|
+
return { applied: [], skipped: [] };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const storageRoot = getModuleStoragePath();
|
|
134
|
+
const applied: string[] = [];
|
|
135
|
+
const skipped: string[] = [];
|
|
136
|
+
const rollback: Array<{ liveDir: string; backupDir: string }> = [];
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
for (const moduleIdEntry of readdirSync(stagingModulesDir, { withFileTypes: true })) {
|
|
140
|
+
if (!moduleIdEntry.isDirectory()) continue;
|
|
141
|
+
const moduleId = moduleIdEntry.name;
|
|
142
|
+
const stagedTfDir = join(stagingModulesDir, moduleId, 'terraform');
|
|
143
|
+
|
|
144
|
+
if (!existsSync(stagedTfDir)) {
|
|
145
|
+
skipped.push(moduleId);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const liveGeneratedDir = join(storageRoot, moduleId, 'generated');
|
|
150
|
+
mkdirSync(liveGeneratedDir, { recursive: true });
|
|
151
|
+
const liveTfDir = join(liveGeneratedDir, 'terraform');
|
|
152
|
+
const backupTfDir = `${liveTfDir}.cross-module-restore-old`;
|
|
153
|
+
|
|
154
|
+
// Move the current live dir aside (if any) so we can roll back on
|
|
155
|
+
// failure. The two renames + the staged-to-live rename together are
|
|
156
|
+
// as close to atomic as POSIX gives us.
|
|
157
|
+
if (existsSync(liveTfDir)) {
|
|
158
|
+
renameSync(liveTfDir, backupTfDir);
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
renameSync(stagedTfDir, liveTfDir);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
// staged → live failed; restore the old dir if we moved it aside.
|
|
164
|
+
if (existsSync(backupTfDir)) {
|
|
165
|
+
renameSync(backupTfDir, liveTfDir);
|
|
166
|
+
}
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
rollback.push({ liveDir: liveTfDir, backupDir: backupTfDir });
|
|
170
|
+
applied.push(moduleId);
|
|
171
|
+
}
|
|
172
|
+
} catch (err) {
|
|
173
|
+
// Roll back every successful module first, then rethrow.
|
|
174
|
+
for (const entry of rollback) {
|
|
175
|
+
try {
|
|
176
|
+
// Remove the freshly-restored live dir
|
|
177
|
+
rmSync(entry.liveDir, { recursive: true, force: true });
|
|
178
|
+
if (existsSync(entry.backupDir)) {
|
|
179
|
+
renameSync(entry.backupDir, entry.liveDir);
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
// Best-effort rollback; the underlying error matters more.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Success — clean up the backup dirs.
|
|
189
|
+
for (const entry of rollback) {
|
|
190
|
+
try {
|
|
191
|
+
if (existsSync(entry.backupDir)) {
|
|
192
|
+
rmSync(entry.backupDir, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// Stale .cross-module-restore-old dir is harmless; will be cleaned
|
|
196
|
+
// on next restore.
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { applied, skipped };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Recursive copy of every file in `srcDir` into `destDir`, EXCEPT the
|
|
205
|
+
* `.terraform/` cache directory. Mirrors the exact tree shape (no flattening).
|
|
206
|
+
*
|
|
207
|
+
* What travels: the state itself — terraform.tfstate, terraform.tfstate.backup,
|
|
208
|
+
* and the provider lock file `.terraform.lock.hcl` (a top-level FILE, not inside
|
|
209
|
+
* `.terraform/`). What does NOT: `.terraform/` — the provider binary cache
|
|
210
|
+
* (~19 MB per proxmox provider) and the module cache, both reconstructible with
|
|
211
|
+
* `terraform init` on restore. Including it bloated the backup envelope by ~95 MB
|
|
212
|
+
* across the fleet and dragged the S3 upload past 12 minutes (ISS-0015). Restore
|
|
213
|
+
* re-fetches providers via `terraform init`.
|
|
214
|
+
*/
|
|
215
|
+
function copyDirShallow(srcDir: string, destDir: string): void {
|
|
216
|
+
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
|
217
|
+
// Skip the terraform provider/module cache — re-fetchable, not state.
|
|
218
|
+
if (entry.isDirectory() && entry.name === '.terraform') continue;
|
|
219
|
+
|
|
220
|
+
const srcPath = join(srcDir, entry.name);
|
|
221
|
+
const destPath = join(destDir, entry.name);
|
|
222
|
+
if (entry.isDirectory()) {
|
|
223
|
+
mkdirSync(destPath, { recursive: true });
|
|
224
|
+
copyDirShallow(srcPath, destPath);
|
|
225
|
+
} else if (entry.isFile()) {
|
|
226
|
+
copyFileSync(srcPath, destPath);
|
|
227
|
+
}
|
|
228
|
+
// Symlinks/sockets/etc are skipped intentionally — TF state should
|
|
229
|
+
// never contain them, and silently following symlinks across a
|
|
230
|
+
// privilege boundary is a footgun.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -11,6 +11,7 @@ import { and, eq } from 'drizzle-orm';
|
|
|
11
11
|
import type { DbClient } from '../db/client';
|
|
12
12
|
import { capabilities, moduleConfigs, modules } from '../db/schema';
|
|
13
13
|
import type { ModuleManifest } from '../manifest/schema';
|
|
14
|
+
import { isPrivilegedCapability } from '../manifest/validate';
|
|
14
15
|
import { generateTemplates } from '../templates/generator';
|
|
15
16
|
import { findMissingSecrets } from './config-interview';
|
|
16
17
|
import { buildModuleFromSource, getModuleBuildStatus, verifyArtifactsExist } from './module-build';
|
|
@@ -184,6 +185,12 @@ async function findMissingCapabilities(
|
|
|
184
185
|
const missing: string[] = [];
|
|
185
186
|
|
|
186
187
|
for (const cap of required) {
|
|
188
|
+
// Framework-granted privileges (e.g. cross_module_read) aren't
|
|
189
|
+
// provider-backed — they're gated by the allow-list at import time,
|
|
190
|
+
// not satisfied by deploying another module. Skip them here.
|
|
191
|
+
if (isPrivilegedCapability(cap.name)) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
187
194
|
const isProvided = await isCapabilityProvided(cap.name, db);
|
|
188
195
|
if (!isProvided) {
|
|
189
196
|
missing.push(cap.name);
|