@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
package/src/manifest/validate.ts
CHANGED
|
@@ -173,6 +173,75 @@ export function validateCapabilityNames(manifest: ModuleManifest): ValidationErr
|
|
|
173
173
|
return null;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Privileged capabilities — framework-granted privileges that only specific
|
|
178
|
+
* modules may declare in `requires.capabilities`. Allow-list is intentionally
|
|
179
|
+
* tiny: adding a module here is a deliberate trust decision, not an
|
|
180
|
+
* automatic side effect of how capabilities work elsewhere.
|
|
181
|
+
*
|
|
182
|
+
* Why this gate lives in `validate.ts` (not in the capability resolver):
|
|
183
|
+
* the privilege is gated at MANIFEST IMPORT time so the operator sees the
|
|
184
|
+
* rejection immediately when they try to `celilo module import` a module
|
|
185
|
+
* that's claiming a privilege it shouldn't have. Catching it later (at
|
|
186
|
+
* hook-invocation time) would let a bad module sit in IMPORTED state.
|
|
187
|
+
*
|
|
188
|
+
* Keys are capability names; values are arrays of module IDs allowed to
|
|
189
|
+
* require that capability. Empty value = no module may require it (acts as
|
|
190
|
+
* an internal-only privilege flag — currently unused, but the shape leaves
|
|
191
|
+
* room for that pattern).
|
|
192
|
+
*/
|
|
193
|
+
const PRIVILEGED_CAPABILITY_ALLOW_LIST: Record<string, readonly string[]> = {
|
|
194
|
+
cross_module_read: ['celilo-mgmt'],
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Whether `name` is a framework-granted privilege rather than a normal
|
|
199
|
+
* provider-backed capability. Privileges are satisfied by the framework
|
|
200
|
+
* (gated by the allow-list above), so the capability-provider resolver
|
|
201
|
+
* must NOT expect a module to "provide" them.
|
|
202
|
+
*/
|
|
203
|
+
export function isPrivilegedCapability(name: string): boolean {
|
|
204
|
+
return name in PRIVILEGED_CAPABILITY_ALLOW_LIST;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Reject `requires.capabilities` declarations for privileged capabilities
|
|
209
|
+
* by modules not in the allow-list. Surfaces a clear actionable error
|
|
210
|
+
* identifying the privilege and the allowed modules.
|
|
211
|
+
*
|
|
212
|
+
* `optional.capabilities` is checked too — a module shouldn't be able to
|
|
213
|
+
* "soft-require" a privilege either (otherwise the privilege flag could be
|
|
214
|
+
* smuggled in via the optional path and granted at hook time).
|
|
215
|
+
*/
|
|
216
|
+
export function validatePrivilegedCapabilities(manifest: ModuleManifest): ValidationError | null {
|
|
217
|
+
const errors: Array<{ path: string; message: string }> = [];
|
|
218
|
+
|
|
219
|
+
for (const required of manifest.requires.capabilities) {
|
|
220
|
+
const allowed = PRIVILEGED_CAPABILITY_ALLOW_LIST[required.name];
|
|
221
|
+
if (allowed && !allowed.includes(manifest.id)) {
|
|
222
|
+
errors.push({
|
|
223
|
+
path: `requires.capabilities.${required.name}`,
|
|
224
|
+
message: `Capability '${required.name}' is a framework-granted privilege; only these modules may require it: ${allowed.join(', ')}. Module '${manifest.id}' is not on the allow-list.`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const opt of manifest.optional?.capabilities ?? []) {
|
|
230
|
+
const allowed = PRIVILEGED_CAPABILITY_ALLOW_LIST[opt.name];
|
|
231
|
+
if (allowed && !allowed.includes(manifest.id)) {
|
|
232
|
+
errors.push({
|
|
233
|
+
path: `optional.capabilities.${opt.name}`,
|
|
234
|
+
message: `Capability '${opt.name}' is a framework-granted privilege; only these modules may declare it (including under optional): ${allowed.join(', ')}. Module '${manifest.id}' is not on the allow-list.`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (errors.length > 0) {
|
|
240
|
+
return { success: false, errors };
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
176
245
|
/**
|
|
177
246
|
* Validate that variable sources are valid
|
|
178
247
|
*
|
package/src/module/import.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
validateDeriveFromSources,
|
|
17
17
|
validateHookContract,
|
|
18
18
|
validateManifest,
|
|
19
|
+
validatePrivilegedCapabilities,
|
|
19
20
|
validateProvidesNoCrossCapabilityRefs,
|
|
20
21
|
validateVariableSources,
|
|
21
22
|
validateZoneRequirements,
|
|
@@ -184,6 +185,17 @@ export async function readModuleManifest(
|
|
|
184
185
|
};
|
|
185
186
|
}
|
|
186
187
|
|
|
188
|
+
const privilegedCapCheck = validatePrivilegedCapabilities(validationResult.data);
|
|
189
|
+
if (privilegedCapCheck) {
|
|
190
|
+
const errorMessages = privilegedCapCheck.errors
|
|
191
|
+
.map((e) => `${e.path}: ${e.message}`)
|
|
192
|
+
.join('\n');
|
|
193
|
+
return {
|
|
194
|
+
success: false,
|
|
195
|
+
error: `Privileged capability validation failed:\n${errorMessages}`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
187
199
|
const variableSourceCheck = validateVariableSources(validationResult.data);
|
|
188
200
|
if (variableSourceCheck) {
|
|
189
201
|
const errorMessages = variableSourceCheck.errors
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { closeDb, getDb } from '../db/client';
|
|
6
|
+
import { runMigrations } from '../db/migrate';
|
|
7
|
+
import { modules } from '../db/schema';
|
|
8
|
+
import type { BaseModuleAspect } from '../manifest/schema';
|
|
9
|
+
import {
|
|
10
|
+
checkAspectApproval,
|
|
11
|
+
computeAspectScopeHash,
|
|
12
|
+
findAspectApproval,
|
|
13
|
+
recordAspectApproval,
|
|
14
|
+
} from './aspect-approvals';
|
|
15
|
+
|
|
16
|
+
const baseAspect: BaseModuleAspect = {
|
|
17
|
+
ansible_role: 'dns-client-config',
|
|
18
|
+
applicable_zones: ['dmz', 'app', 'secure'],
|
|
19
|
+
triggers: ['on_install'],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function insertModule(id: string, version: string) {
|
|
23
|
+
getDb()
|
|
24
|
+
.insert(modules)
|
|
25
|
+
.values({
|
|
26
|
+
id,
|
|
27
|
+
name: id,
|
|
28
|
+
version,
|
|
29
|
+
manifestData: { id, name: id, version, celilo_contract: '1.0' },
|
|
30
|
+
sourcePath: `/tmp/${id}`,
|
|
31
|
+
})
|
|
32
|
+
.run();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('aspect-approvals', () => {
|
|
36
|
+
let dir: string;
|
|
37
|
+
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-aspect-approvals-test-'));
|
|
40
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
41
|
+
await runMigrations(process.env.CELILO_DB_PATH);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
closeDb();
|
|
46
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
47
|
+
try {
|
|
48
|
+
rmSync(dir, { recursive: true, force: true });
|
|
49
|
+
} catch {
|
|
50
|
+
/* ignore */
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('computeAspectScopeHash', () => {
|
|
55
|
+
it('is stable for the same scope', () => {
|
|
56
|
+
const h1 = computeAspectScopeHash(baseAspect);
|
|
57
|
+
const h2 = computeAspectScopeHash(baseAspect);
|
|
58
|
+
expect(h1).toBe(h2);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('is the same regardless of zone or trigger order', () => {
|
|
62
|
+
const a: BaseModuleAspect = {
|
|
63
|
+
ansible_role: 'dns-client-config',
|
|
64
|
+
applicable_zones: ['app', 'dmz', 'secure'],
|
|
65
|
+
triggers: ['on_install', 'on_new_system_in_zone'],
|
|
66
|
+
};
|
|
67
|
+
const b: BaseModuleAspect = {
|
|
68
|
+
ansible_role: 'dns-client-config',
|
|
69
|
+
applicable_zones: ['secure', 'app', 'dmz'],
|
|
70
|
+
triggers: ['on_new_system_in_zone', 'on_install'],
|
|
71
|
+
};
|
|
72
|
+
expect(computeAspectScopeHash(a)).toBe(computeAspectScopeHash(b));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('changes when applicable_zones changes', () => {
|
|
76
|
+
const a: BaseModuleAspect = { ...baseAspect, applicable_zones: ['dmz', 'app', 'secure'] };
|
|
77
|
+
const b: BaseModuleAspect = {
|
|
78
|
+
...baseAspect,
|
|
79
|
+
applicable_zones: ['dmz', 'app', 'secure', 'internal'],
|
|
80
|
+
};
|
|
81
|
+
expect(computeAspectScopeHash(a)).not.toBe(computeAspectScopeHash(b));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('changes when triggers change', () => {
|
|
85
|
+
const a: BaseModuleAspect = { ...baseAspect, triggers: ['on_install'] };
|
|
86
|
+
const b: BaseModuleAspect = {
|
|
87
|
+
...baseAspect,
|
|
88
|
+
triggers: ['on_install', 'on_new_system_in_zone'],
|
|
89
|
+
};
|
|
90
|
+
expect(computeAspectScopeHash(a)).not.toBe(computeAspectScopeHash(b));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('is unaffected by ansible_role changes', () => {
|
|
94
|
+
// ansible_role is intentionally NOT part of the scope hash —
|
|
95
|
+
// module authors evolve their role contents within an approved
|
|
96
|
+
// scope all the time.
|
|
97
|
+
const a: BaseModuleAspect = { ...baseAspect, ansible_role: 'old-role' };
|
|
98
|
+
const b: BaseModuleAspect = { ...baseAspect, ansible_role: 'new-role' };
|
|
99
|
+
expect(computeAspectScopeHash(a)).toBe(computeAspectScopeHash(b));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('recordAspectApproval + findAspectApproval', () => {
|
|
104
|
+
it('persists and reads back an approval', () => {
|
|
105
|
+
insertModule('knot-unbound-internal', '1.0.0');
|
|
106
|
+
const db = getDb();
|
|
107
|
+
const written = recordAspectApproval({
|
|
108
|
+
moduleId: 'knot-unbound-internal',
|
|
109
|
+
version: '1.0.0',
|
|
110
|
+
scopeHash: computeAspectScopeHash(baseAspect),
|
|
111
|
+
approver: 'testuser',
|
|
112
|
+
db,
|
|
113
|
+
});
|
|
114
|
+
expect(written.moduleId).toBe('knot-unbound-internal');
|
|
115
|
+
expect(written.version).toBe('1.0.0');
|
|
116
|
+
expect(written.approver).toBe('testuser');
|
|
117
|
+
|
|
118
|
+
const found = findAspectApproval('knot-unbound-internal', '1.0.0', db);
|
|
119
|
+
expect(found?.id).toBe(written.id);
|
|
120
|
+
expect(found?.scopeHash).toBe(written.scopeHash);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('rejects duplicate (moduleId, version) via unique constraint', () => {
|
|
124
|
+
insertModule('knot-unbound-internal', '1.0.0');
|
|
125
|
+
const db = getDb();
|
|
126
|
+
recordAspectApproval({
|
|
127
|
+
moduleId: 'knot-unbound-internal',
|
|
128
|
+
version: '1.0.0',
|
|
129
|
+
scopeHash: 'abc',
|
|
130
|
+
approver: null,
|
|
131
|
+
db,
|
|
132
|
+
});
|
|
133
|
+
expect(() =>
|
|
134
|
+
recordAspectApproval({
|
|
135
|
+
moduleId: 'knot-unbound-internal',
|
|
136
|
+
version: '1.0.0',
|
|
137
|
+
scopeHash: 'def',
|
|
138
|
+
approver: null,
|
|
139
|
+
db,
|
|
140
|
+
}),
|
|
141
|
+
).toThrow();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('allows two approvals for the same module at different versions', () => {
|
|
145
|
+
insertModule('knot-unbound-internal', '1.0.0');
|
|
146
|
+
insertModule('knot-other', '1.0.0'); // unrelated; just keeping the test surface narrow
|
|
147
|
+
const db = getDb();
|
|
148
|
+
// Same module, but the test isolates versions by inserting a
|
|
149
|
+
// second row in `modules` first. We just want to verify the
|
|
150
|
+
// approvals table doesn't reject two rows with different
|
|
151
|
+
// versions.
|
|
152
|
+
db.insert(modules)
|
|
153
|
+
.values({
|
|
154
|
+
id: 'knot-unbound-internal-v2',
|
|
155
|
+
name: 'knot',
|
|
156
|
+
version: '2.0.0',
|
|
157
|
+
manifestData: { id: 'knot', name: 'knot', version: '2.0.0', celilo_contract: '1.0' },
|
|
158
|
+
sourcePath: '/tmp/knot',
|
|
159
|
+
})
|
|
160
|
+
.run();
|
|
161
|
+
recordAspectApproval({
|
|
162
|
+
moduleId: 'knot-unbound-internal',
|
|
163
|
+
version: '1.0.0',
|
|
164
|
+
scopeHash: 'abc',
|
|
165
|
+
approver: null,
|
|
166
|
+
db,
|
|
167
|
+
});
|
|
168
|
+
recordAspectApproval({
|
|
169
|
+
moduleId: 'knot-unbound-internal-v2',
|
|
170
|
+
version: '2.0.0',
|
|
171
|
+
scopeHash: 'def',
|
|
172
|
+
approver: null,
|
|
173
|
+
db,
|
|
174
|
+
});
|
|
175
|
+
expect(findAspectApproval('knot-unbound-internal', '1.0.0', db)?.scopeHash).toBe('abc');
|
|
176
|
+
expect(findAspectApproval('knot-unbound-internal-v2', '2.0.0', db)?.scopeHash).toBe('def');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('returns undefined when there is no approval', () => {
|
|
180
|
+
const found = findAspectApproval('never-imported', '1.0.0', getDb());
|
|
181
|
+
expect(found).toBeUndefined();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('checkAspectApproval', () => {
|
|
186
|
+
it('returns "no_approval" when nothing is recorded', () => {
|
|
187
|
+
const status = checkAspectApproval('never-imported', '1.0.0', baseAspect, getDb());
|
|
188
|
+
expect(status).toBe('no_approval');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('returns "approved" when an approval matches the current scope hash', () => {
|
|
192
|
+
insertModule('knot-unbound-internal', '1.0.0');
|
|
193
|
+
const db = getDb();
|
|
194
|
+
recordAspectApproval({
|
|
195
|
+
moduleId: 'knot-unbound-internal',
|
|
196
|
+
version: '1.0.0',
|
|
197
|
+
scopeHash: computeAspectScopeHash(baseAspect),
|
|
198
|
+
approver: null,
|
|
199
|
+
db,
|
|
200
|
+
});
|
|
201
|
+
const status = checkAspectApproval('knot-unbound-internal', '1.0.0', baseAspect, db);
|
|
202
|
+
expect(status).toBe('approved');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('returns "scope_changed" when scope diverges from a prior approval', () => {
|
|
206
|
+
insertModule('knot-unbound-internal', '1.0.0');
|
|
207
|
+
const db = getDb();
|
|
208
|
+
const oldAspect: BaseModuleAspect = {
|
|
209
|
+
ansible_role: 'dns-client-config',
|
|
210
|
+
applicable_zones: ['app'],
|
|
211
|
+
triggers: ['on_install'],
|
|
212
|
+
};
|
|
213
|
+
recordAspectApproval({
|
|
214
|
+
moduleId: 'knot-unbound-internal',
|
|
215
|
+
version: '1.0.0',
|
|
216
|
+
scopeHash: computeAspectScopeHash(oldAspect),
|
|
217
|
+
approver: null,
|
|
218
|
+
db,
|
|
219
|
+
});
|
|
220
|
+
// Same version row, but the manifest's scope broadened — this is
|
|
221
|
+
// the D7 upgrade-changes-scope case.
|
|
222
|
+
const newAspect: BaseModuleAspect = {
|
|
223
|
+
ansible_role: 'dns-client-config',
|
|
224
|
+
applicable_zones: ['app', 'dmz', 'secure', 'internal'],
|
|
225
|
+
triggers: ['on_install'],
|
|
226
|
+
};
|
|
227
|
+
const status = checkAspectApproval('knot-unbound-internal', '1.0.0', newAspect, db);
|
|
228
|
+
expect(status).toBe('scope_changed');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aspect approvals — operator consent for a module's base-module aspect.
|
|
3
|
+
*
|
|
4
|
+
* Per v2/CELILO_BASE.md D2: when an operator imports a module that
|
|
5
|
+
* declares a `base_module_aspect`, they consent to that aspect's
|
|
6
|
+
* scope (applicable_zones + triggers) ONCE at import time. The
|
|
7
|
+
* consent is recorded in the `aspect_approvals` table and consulted
|
|
8
|
+
* before any aspect fan-out.
|
|
9
|
+
*
|
|
10
|
+
* D7 adds upgrade-scope detection: if a module version upgrade
|
|
11
|
+
* changes `applicable_zones` or `triggers`, the new version's
|
|
12
|
+
* scope_hash will differ from the prior approval's, signaling the
|
|
13
|
+
* framework that re-approval is required. Aspect-content-only
|
|
14
|
+
* changes (e.g., the Ansible role file got updated) DON'T change
|
|
15
|
+
* the scope_hash; they're allowed without re-prompting.
|
|
16
|
+
*
|
|
17
|
+
* The scope hash is a stable SHA-256 over the sorted JSON of the
|
|
18
|
+
* scope fields. Sorting matters: `[a, b]` and `[b, a]` should yield
|
|
19
|
+
* the same hash because zone order and trigger order don't carry
|
|
20
|
+
* semantic meaning.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
24
|
+
import { and, eq } from 'drizzle-orm';
|
|
25
|
+
import type { getDb } from '../db/client';
|
|
26
|
+
import { aspectApprovals } from '../db/schema';
|
|
27
|
+
import type { BaseModuleAspect } from '../manifest/schema';
|
|
28
|
+
|
|
29
|
+
type DbClient = ReturnType<typeof getDb>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compute a stable SHA-256 hash of the aspect's scope fields.
|
|
33
|
+
*
|
|
34
|
+
* The hash covers `applicable_zones` and `triggers` only — these
|
|
35
|
+
* are the fields the operator consents to. `ansible_role` is NOT
|
|
36
|
+
* part of the scope (a module author updating the role content
|
|
37
|
+
* within the approved zone set is normal evolution, captured by
|
|
38
|
+
* `on_aspect_change` rather than re-approval).
|
|
39
|
+
*
|
|
40
|
+
* Sorted so order doesn't affect the hash.
|
|
41
|
+
*/
|
|
42
|
+
export function computeAspectScopeHash(aspect: BaseModuleAspect): string {
|
|
43
|
+
const sortedZones = [...aspect.applicable_zones].sort();
|
|
44
|
+
const sortedTriggers = [...aspect.triggers].sort();
|
|
45
|
+
const canonical = JSON.stringify({
|
|
46
|
+
applicable_zones: sortedZones,
|
|
47
|
+
triggers: sortedTriggers,
|
|
48
|
+
});
|
|
49
|
+
return createHash('sha256').update(canonical).digest('hex');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Look up an existing approval for a (moduleId, version) pair.
|
|
54
|
+
* Returns the row if present, undefined otherwise.
|
|
55
|
+
*/
|
|
56
|
+
export function findAspectApproval(
|
|
57
|
+
moduleId: string,
|
|
58
|
+
version: string,
|
|
59
|
+
db: DbClient,
|
|
60
|
+
): typeof aspectApprovals.$inferSelect | undefined {
|
|
61
|
+
return db
|
|
62
|
+
.select()
|
|
63
|
+
.from(aspectApprovals)
|
|
64
|
+
.where(and(eq(aspectApprovals.moduleId, moduleId), eq(aspectApprovals.version, version)))
|
|
65
|
+
.get();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Record operator approval for a module version's base-module
|
|
70
|
+
* aspect. Caller is responsible for verifying that an approval
|
|
71
|
+
* doesn't already exist (the unique constraint will reject
|
|
72
|
+
* duplicates, but callers should check first to provide a clear
|
|
73
|
+
* error path).
|
|
74
|
+
*
|
|
75
|
+
* @param approver - operator identifier (e.g., $USER). Null when
|
|
76
|
+
* --accept-aspects is used in a context with no USER, or when
|
|
77
|
+
* approval is automated.
|
|
78
|
+
*/
|
|
79
|
+
export function recordAspectApproval(args: {
|
|
80
|
+
moduleId: string;
|
|
81
|
+
version: string;
|
|
82
|
+
scopeHash: string;
|
|
83
|
+
approver: string | null;
|
|
84
|
+
db: DbClient;
|
|
85
|
+
}): typeof aspectApprovals.$inferSelect {
|
|
86
|
+
const row = {
|
|
87
|
+
id: randomUUID(),
|
|
88
|
+
moduleId: args.moduleId,
|
|
89
|
+
version: args.version,
|
|
90
|
+
scopeHash: args.scopeHash,
|
|
91
|
+
approver: args.approver,
|
|
92
|
+
};
|
|
93
|
+
args.db.insert(aspectApprovals).values(row).run();
|
|
94
|
+
return args.db
|
|
95
|
+
.select()
|
|
96
|
+
.from(aspectApprovals)
|
|
97
|
+
.where(eq(aspectApprovals.id, row.id))
|
|
98
|
+
.get() as typeof aspectApprovals.$inferSelect;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Check whether the current (moduleId, version) has an approval
|
|
103
|
+
* whose scope matches the manifest's declared aspect. Returns:
|
|
104
|
+
* - 'approved': there's an approval and its scope_hash matches
|
|
105
|
+
* - 'scope_changed': there's an approval but the manifest's
|
|
106
|
+
* scope is different (D7 — re-approval required before aspects
|
|
107
|
+
* fan out under the new scope)
|
|
108
|
+
* - 'no_approval': no approval row for this version
|
|
109
|
+
*/
|
|
110
|
+
export function checkAspectApproval(
|
|
111
|
+
moduleId: string,
|
|
112
|
+
version: string,
|
|
113
|
+
aspect: BaseModuleAspect,
|
|
114
|
+
db: DbClient,
|
|
115
|
+
): 'approved' | 'scope_changed' | 'no_approval' {
|
|
116
|
+
const existing = findAspectApproval(moduleId, version, db);
|
|
117
|
+
if (!existing) return 'no_approval';
|
|
118
|
+
const currentHash = computeAspectScopeHash(aspect);
|
|
119
|
+
return existing.scopeHash === currentHash ? 'approved' : 'scope_changed';
|
|
120
|
+
}
|