@celilo/cli 0.3.28 → 0.3.30
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-upgrade.test.ts +59 -0
- package/src/cli/commands/system-audit.ts +3 -0
- package/src/cli/commands/system-update.ts +105 -1
- package/src/services/audit/backups.test.ts +24 -1
- package/src/services/audit/backups.ts +10 -0
- package/src/services/audit/capability-abi.test.ts +4 -1
- package/src/services/audit/capability-abi.ts +18 -2
- package/src/services/audit/module-configs.test.ts +47 -1
- package/src/services/audit/module-configs.ts +41 -23
package/package.json
CHANGED
|
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
|
8
8
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
|
+
import { eq } from 'drizzle-orm';
|
|
11
12
|
import { type DbClient, getDb } from '../../db/client';
|
|
12
13
|
import { modules } from '../../db/schema';
|
|
13
14
|
import { classifyVersionChange, upgradeOne } from './module-upgrade';
|
|
@@ -153,4 +154,62 @@ description: fixture
|
|
|
153
154
|
if (quietResult.status !== 'success') return;
|
|
154
155
|
expect(quietResult.newVersion).toBe('1.0.0');
|
|
155
156
|
});
|
|
157
|
+
|
|
158
|
+
// Regression for the namecheap stale-findings problem: the upgrade
|
|
159
|
+
// path MUST overwrite manifestData with the new manifest — otherwise
|
|
160
|
+
// any subsequent audit (or any code path that reads from the DB)
|
|
161
|
+
// sees the OLD manifest's required vars even after the operator
|
|
162
|
+
// upgraded to a version that removed them. The user hit this on
|
|
163
|
+
// celilo-mgmt: namecheap@3.1.1+6 dropped the `domains` variable, but
|
|
164
|
+
// post-upgrade the audit still complained "required config 'domains'
|
|
165
|
+
// is not set" because the DB's manifestData hadn't been refreshed.
|
|
166
|
+
test('persists new manifest to modules.manifestData (not just .version)', async () => {
|
|
167
|
+
// Write a brand-new manifest.yml in srcDir that REMOVES a
|
|
168
|
+
// variable the old DB record had. After upgrade, the modules
|
|
169
|
+
// row's manifestData should reflect the removal.
|
|
170
|
+
writeFileSync(
|
|
171
|
+
join(srcDir, 'manifest.yml'),
|
|
172
|
+
`celilo_contract: "1.0"
|
|
173
|
+
id: testmod
|
|
174
|
+
name: Test Module Renamed
|
|
175
|
+
version: 2.0.0
|
|
176
|
+
description: fixture v2
|
|
177
|
+
variables:
|
|
178
|
+
owns: []
|
|
179
|
+
imports: []
|
|
180
|
+
`,
|
|
181
|
+
);
|
|
182
|
+
// Simulate the DB starting in a state where manifestData has a
|
|
183
|
+
// `variables.owns` array — the namecheap-3.1.0 → 3.1.1 case.
|
|
184
|
+
db.update(modules)
|
|
185
|
+
.set({
|
|
186
|
+
manifestData: {
|
|
187
|
+
celilo_contract: '1.0',
|
|
188
|
+
id: 'testmod',
|
|
189
|
+
name: 'Test Module',
|
|
190
|
+
version: '1.0.0',
|
|
191
|
+
variables: { owns: [{ name: 'domains', type: 'array', required: true }], imports: [] },
|
|
192
|
+
},
|
|
193
|
+
})
|
|
194
|
+
.where(eq(modules.id, 'testmod'))
|
|
195
|
+
.run();
|
|
196
|
+
|
|
197
|
+
const result = await upgradeOne(srcDir, db, {}, { quiet: true, displayVersion: '2.0.0+1' });
|
|
198
|
+
expect(result.status).toBe('success');
|
|
199
|
+
|
|
200
|
+
const row = db.select().from(modules).all()[0];
|
|
201
|
+
// version field updates (this part already worked):
|
|
202
|
+
expect(row.version).toBe('2.0.0+1');
|
|
203
|
+
expect(row.name).toBe('Test Module Renamed');
|
|
204
|
+
// manifestData reflects the NEW manifest — the dropped variable
|
|
205
|
+
// is gone. This is the regression assertion.
|
|
206
|
+
const manifest = row.manifestData as {
|
|
207
|
+
version: string;
|
|
208
|
+
name: string;
|
|
209
|
+
variables?: { owns: unknown[] };
|
|
210
|
+
};
|
|
211
|
+
expect(manifest.version).toBe('2.0.0');
|
|
212
|
+
expect(manifest.name).toBe('Test Module Renamed');
|
|
213
|
+
expect(manifest.variables?.owns ?? []).toEqual([]);
|
|
214
|
+
});
|
|
156
215
|
});
|
|
@@ -189,12 +189,14 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
|
|
|
189
189
|
|
|
190
190
|
const installedConfigs = deployedModules.map((m) => ({
|
|
191
191
|
id: m.id,
|
|
192
|
+
state: m.state,
|
|
192
193
|
manifest: m.manifestData as ModuleManifest,
|
|
193
194
|
configs: configsByModule.get(m.id) ?? {},
|
|
194
195
|
}));
|
|
195
196
|
|
|
196
197
|
const installedBackupInfo = deployedModules.map((m) => ({
|
|
197
198
|
id: m.id,
|
|
199
|
+
state: m.state,
|
|
198
200
|
manifest: m.manifestData as ModuleManifest,
|
|
199
201
|
lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
|
|
200
202
|
}));
|
|
@@ -402,6 +404,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
|
|
|
402
404
|
capabilityAbi: {
|
|
403
405
|
modules: deployedModules.map((m) => ({
|
|
404
406
|
id: m.id,
|
|
407
|
+
state: m.state,
|
|
405
408
|
manifest: m.manifestData as ModuleManifest,
|
|
406
409
|
})),
|
|
407
410
|
},
|
|
@@ -386,7 +386,12 @@ function buildOps(registry: RegistryClient, wasDeployed: Set<string>): Orchestra
|
|
|
386
386
|
function formatResult(result: SystemUpdateResult): string {
|
|
387
387
|
const lines: string[] = [];
|
|
388
388
|
lines.push('');
|
|
389
|
-
|
|
389
|
+
// The updateId is for audit / journal correlation (see the
|
|
390
|
+
// `backups` table's updateId FK); operators don't read it during
|
|
391
|
+
// normal use, only when debugging a specific run via --json or
|
|
392
|
+
// `celilo backup list`. Surfacing it in human output added noise
|
|
393
|
+
// without paying for itself.
|
|
394
|
+
lines.push(`System update ${result.ok ? 'completed' : 'FAILED'}`);
|
|
390
395
|
lines.push(` audit verdict: ${result.audit.verdict}`);
|
|
391
396
|
lines.push(
|
|
392
397
|
` self-update: ${result.selfUpdate.performed ? `${result.selfUpdate.from} → ${result.selfUpdate.to}` : `(${result.selfUpdate.reason})`}`,
|
|
@@ -523,6 +528,7 @@ export async function handleSystemUpdate(
|
|
|
523
528
|
capabilityAbi: {
|
|
524
529
|
modules: upgradableModules.map((m) => ({
|
|
525
530
|
id: m.id,
|
|
531
|
+
state: m.state,
|
|
526
532
|
manifest: m.manifestData as ModuleManifest,
|
|
527
533
|
})),
|
|
528
534
|
},
|
|
@@ -546,6 +552,7 @@ export async function handleSystemUpdate(
|
|
|
546
552
|
moduleConfigs: {
|
|
547
553
|
modules: upgradableModules.map((m) => ({
|
|
548
554
|
id: m.id,
|
|
555
|
+
state: m.state,
|
|
549
556
|
manifest: m.manifestData as ModuleManifest,
|
|
550
557
|
configs: configsByModule.get(m.id) ?? {},
|
|
551
558
|
})),
|
|
@@ -554,6 +561,7 @@ export async function handleSystemUpdate(
|
|
|
554
561
|
backups: {
|
|
555
562
|
modules: upgradableModules.map((m) => ({
|
|
556
563
|
id: m.id,
|
|
564
|
+
state: m.state,
|
|
557
565
|
manifest: m.manifestData as ModuleManifest,
|
|
558
566
|
lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
|
|
559
567
|
})),
|
|
@@ -677,6 +685,21 @@ export async function handleSystemUpdate(
|
|
|
677
685
|
onlyModule,
|
|
678
686
|
});
|
|
679
687
|
|
|
688
|
+
// The audit baked into `result.audit` ran BEFORE the orchestrator
|
|
689
|
+
// upgraded any modules. Its findings are now stale for whatever
|
|
690
|
+
// we just upgraded — `module_versions` drift, `module_configs`
|
|
691
|
+
// referencing the OLD manifest's required vars, `capability_abi`
|
|
692
|
+
// checking pre-upgrade providers. Re-query the modules table and
|
|
693
|
+
// re-run the audit so the displayed findings reflect post-upgrade
|
|
694
|
+
// reality. Only do this on a successful run (a partial-failure
|
|
695
|
+
// run keeps its original audit so the operator sees what the
|
|
696
|
+
// orchestrator was reacting to).
|
|
697
|
+
const successfulModuleSteps = result.modules.filter((m) => m.step === 'done');
|
|
698
|
+
if (result.ok && successfulModuleSteps.length > 0) {
|
|
699
|
+
const refreshedAudit = await runAudit(rebuildAuditDepsForRerun(auditDeps, db));
|
|
700
|
+
result.audit = refreshedAudit;
|
|
701
|
+
}
|
|
702
|
+
|
|
680
703
|
if (json) {
|
|
681
704
|
if (result.ok) {
|
|
682
705
|
return { success: true, message: JSON.stringify(result, null, 2), rawOutput: true };
|
|
@@ -692,3 +715,84 @@ export async function handleSystemUpdate(
|
|
|
692
715
|
error: `${formatResult(result)}\n\none or more modules failed; see output above`,
|
|
693
716
|
};
|
|
694
717
|
}
|
|
718
|
+
|
|
719
|
+
type AuditDeps = Parameters<typeof runAudit>[0];
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Build a fresh AuditDeps object whose module-state-dependent
|
|
723
|
+
* sub-deps are re-queried from the DB. The non-module deps
|
|
724
|
+
* (cliVersion fetcher, migrations, terraform plan runner, registry
|
|
725
|
+
* fetcher, etc.) are reused from the original deps because they
|
|
726
|
+
* don't change during a single system-update run.
|
|
727
|
+
*
|
|
728
|
+
* Used by the post-orchestrator re-audit so displayed findings
|
|
729
|
+
* reflect post-upgrade reality (e.g., a module_versions drift
|
|
730
|
+
* finding for a module we just upgraded is no longer reported).
|
|
731
|
+
*/
|
|
732
|
+
export function rebuildAuditDepsForRerun(
|
|
733
|
+
original: AuditDeps,
|
|
734
|
+
db: ReturnType<typeof getDb>,
|
|
735
|
+
): AuditDeps {
|
|
736
|
+
const installed = db.select().from(modules).all();
|
|
737
|
+
const upgradeEligibleStates = new Set(['INSTALLED', 'VERIFIED', 'IMPORTED']);
|
|
738
|
+
const upgradable = installed.filter((m) => upgradeEligibleStates.has(m.state));
|
|
739
|
+
|
|
740
|
+
const allConfigs = db.select().from(moduleConfigsTbl).all();
|
|
741
|
+
const configsByModule = new Map<string, Record<string, unknown>>();
|
|
742
|
+
for (const c of allConfigs) {
|
|
743
|
+
const m = configsByModule.get(c.moduleId) ?? {};
|
|
744
|
+
m[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
|
|
745
|
+
configsByModule.set(c.moduleId, m);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Backup recency is unchanged across an orchestrator run (only
|
|
749
|
+
// celilo-DB snapshots happen, not per-module backup writes), so
|
|
750
|
+
// we look up each module's prior lastSuccessfulBackupAt by id
|
|
751
|
+
// rather than re-querying the backups table.
|
|
752
|
+
const priorBackupByModule = new Map<string, number | null>();
|
|
753
|
+
for (const b of original.backups.modules) {
|
|
754
|
+
priorBackupByModule.set(b.id, b.lastSuccessfulBackupAt);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return {
|
|
758
|
+
...original,
|
|
759
|
+
capabilityAbi: {
|
|
760
|
+
modules: upgradable.map((m) => ({
|
|
761
|
+
id: m.id,
|
|
762
|
+
state: m.state,
|
|
763
|
+
manifest: m.manifestData as ModuleManifest,
|
|
764
|
+
})),
|
|
765
|
+
},
|
|
766
|
+
moduleVersions: {
|
|
767
|
+
...original.moduleVersions,
|
|
768
|
+
installed: upgradable.map((m) => ({ id: m.id, version: m.version })),
|
|
769
|
+
},
|
|
770
|
+
moduleConfigs: {
|
|
771
|
+
modules: upgradable.map((m) => ({
|
|
772
|
+
id: m.id,
|
|
773
|
+
state: m.state,
|
|
774
|
+
manifest: m.manifestData as ModuleManifest,
|
|
775
|
+
configs: configsByModule.get(m.id) ?? {},
|
|
776
|
+
})),
|
|
777
|
+
},
|
|
778
|
+
backups: {
|
|
779
|
+
...original.backups,
|
|
780
|
+
modules: upgradable.map((m) => ({
|
|
781
|
+
id: m.id,
|
|
782
|
+
state: m.state,
|
|
783
|
+
manifest: m.manifestData as ModuleManifest,
|
|
784
|
+
lastSuccessfulBackupAt: priorBackupByModule.get(m.id) ?? null,
|
|
785
|
+
})),
|
|
786
|
+
},
|
|
787
|
+
undeployedModules: {
|
|
788
|
+
modules: installed.map((m) => ({ id: m.id, state: m.state })),
|
|
789
|
+
},
|
|
790
|
+
unconfiguredModules: {
|
|
791
|
+
modules: installed.map((m) => ({
|
|
792
|
+
id: m.id,
|
|
793
|
+
state: m.state,
|
|
794
|
+
configCount: Object.keys(configsByModule.get(m.id) ?? {}).length,
|
|
795
|
+
})),
|
|
796
|
+
},
|
|
797
|
+
};
|
|
798
|
+
}
|
|
@@ -24,7 +24,10 @@ function makeModule(
|
|
|
24
24
|
hooks: opts.hasBackupHook ? { on_backup: { script: './backup.ts' } } : {},
|
|
25
25
|
backup: opts.schedule ? { schedule: opts.schedule } : undefined,
|
|
26
26
|
} as unknown as ModuleManifest;
|
|
27
|
-
|
|
27
|
+
// Default to INSTALLED — existing tests assert backup findings
|
|
28
|
+
// fire, which is the deployed-module behavior. Tests for the
|
|
29
|
+
// non-deployed-skip behavior override this explicitly.
|
|
30
|
+
return { id, state: 'INSTALLED', manifest, lastSuccessfulBackupAt: opts.lastSuccessfulBackupAt };
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
describe('auditBackups', () => {
|
|
@@ -230,4 +233,24 @@ describe('auditBackups', () => {
|
|
|
230
233
|
expect(result).toHaveLength(2);
|
|
231
234
|
expect(result.map((f) => f.subject).sort()).toEqual(['authentik', 'homebridge']);
|
|
232
235
|
});
|
|
236
|
+
|
|
237
|
+
// The regression: backups were complaining about IMPORTED modules
|
|
238
|
+
// having no successful backup. There's no live state on an
|
|
239
|
+
// IMPORTED module, so there's nothing to back up — skip entirely
|
|
240
|
+
// rather than emit a misleading finding.
|
|
241
|
+
test('skips non-deployed (IMPORTED) modules entirely', async () => {
|
|
242
|
+
const importedModule = {
|
|
243
|
+
...makeModule('authentik', {
|
|
244
|
+
hasBackupHook: true,
|
|
245
|
+
lastSuccessfulBackupAt: null,
|
|
246
|
+
schedule: 'daily',
|
|
247
|
+
}),
|
|
248
|
+
state: 'IMPORTED',
|
|
249
|
+
};
|
|
250
|
+
const result = await auditBackups({
|
|
251
|
+
modules: [importedModule],
|
|
252
|
+
now: () => NOW,
|
|
253
|
+
});
|
|
254
|
+
expect(result).toEqual([]);
|
|
255
|
+
});
|
|
233
256
|
});
|
|
@@ -20,6 +20,8 @@ import type { DriftFinding } from './types';
|
|
|
20
20
|
|
|
21
21
|
export interface InstalledModuleBackupInfo {
|
|
22
22
|
id: string;
|
|
23
|
+
/** Lifecycle state — non-deployed modules have nothing to back up. */
|
|
24
|
+
state: string;
|
|
23
25
|
manifest: ModuleManifest;
|
|
24
26
|
/** Most recent successful backup timestamp (ms since epoch), or null. */
|
|
25
27
|
lastSuccessfulBackupAt: number | null;
|
|
@@ -87,12 +89,20 @@ function formatAge(ms: number): string {
|
|
|
87
89
|
return `${mins}m`;
|
|
88
90
|
}
|
|
89
91
|
|
|
92
|
+
const DEPLOYED_STATES = new Set(['INSTALLED', 'VERIFIED']);
|
|
93
|
+
|
|
90
94
|
export async function auditBackups(deps: BackupsAuditDeps): Promise<DriftFinding[]> {
|
|
91
95
|
const now = (deps.now ?? Date.now)();
|
|
92
96
|
const findings: DriftFinding[] = [];
|
|
93
97
|
|
|
94
98
|
for (const m of deps.modules) {
|
|
95
99
|
if (!moduleHasBackupHook(m.manifest)) continue;
|
|
100
|
+
// Non-deployed modules have no live state to back up. Surfacing
|
|
101
|
+
// a "no successful backup recorded" finding for an IMPORTED
|
|
102
|
+
// module is just noise — the operator hasn't deployed yet, so
|
|
103
|
+
// there's nothing to lose. Skip them entirely from the backup
|
|
104
|
+
// audit.
|
|
105
|
+
if (!DEPLOYED_STATES.has(m.state)) continue;
|
|
96
106
|
|
|
97
107
|
if (m.lastSuccessfulBackupAt === null) {
|
|
98
108
|
findings.push({
|
|
@@ -23,7 +23,10 @@ function makeModule(
|
|
|
23
23
|
requires: opts.requires ? { capabilities: opts.requires } : { capabilities: [] },
|
|
24
24
|
optional: opts.optional ? { capabilities: opts.optional } : undefined,
|
|
25
25
|
} as unknown as ModuleManifest;
|
|
26
|
-
|
|
26
|
+
// Default INSTALLED so existing assertions keep their `blocked`
|
|
27
|
+
// semantics. Tests that exercise the IMPORTED-demotion path
|
|
28
|
+
// override the state explicitly.
|
|
29
|
+
return { id, state: 'INSTALLED', manifest };
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
const RUNTIME = {
|
|
@@ -26,9 +26,19 @@ import type { DriftFinding } from './types';
|
|
|
26
26
|
|
|
27
27
|
export interface InstalledCapabilityModule {
|
|
28
28
|
id: string;
|
|
29
|
+
/**
|
|
30
|
+
* Lifecycle state — used to demote ABI mismatches on IMPORTED-but-
|
|
31
|
+
* not-deployed modules from `blocked` to `todo`. The mismatch is
|
|
32
|
+
* still real (the operator will hit it at deploy time) but it
|
|
33
|
+
* doesn't block other module work, and gating system update on it
|
|
34
|
+
* makes refreshing unrelated modules harder than it should be.
|
|
35
|
+
*/
|
|
36
|
+
state: string;
|
|
29
37
|
manifest: ModuleManifest;
|
|
30
38
|
}
|
|
31
39
|
|
|
40
|
+
const DEPLOYED_STATES = new Set(['INSTALLED', 'VERIFIED']);
|
|
41
|
+
|
|
32
42
|
export interface CapabilityAbiAuditDeps {
|
|
33
43
|
modules: InstalledCapabilityModule[];
|
|
34
44
|
/** Override the framework registry — for tests. Defaults to `CAPABILITY_CONTRACT_VERSIONS`. */
|
|
@@ -120,7 +130,11 @@ export async function auditCapabilityAbi(deps: CapabilityAbiAuditDeps): Promise<
|
|
|
120
130
|
|
|
121
131
|
findings.push({
|
|
122
132
|
category: 'capability_abi',
|
|
123
|
-
|
|
133
|
+
// Demoted to `todo` for non-deployed modules: the mismatch
|
|
134
|
+
// matters at deploy time, but blocking system update on it
|
|
135
|
+
// means the operator can't refresh unrelated modules until
|
|
136
|
+
// they fix the ABI for a module they haven't deployed yet.
|
|
137
|
+
severity: DEPLOYED_STATES.has(m.state) ? 'blocked' : 'todo',
|
|
124
138
|
code: 'capability_abi_provider_mismatch',
|
|
125
139
|
message: `${m.id} provides ${p.name}@${p.version} but framework runtime expects ${runtimeVersion}`,
|
|
126
140
|
details,
|
|
@@ -190,7 +204,9 @@ export async function auditCapabilityAbi(deps: CapabilityAbiAuditDeps): Promise<
|
|
|
190
204
|
|
|
191
205
|
findings.push({
|
|
192
206
|
category: 'capability_abi',
|
|
193
|
-
|
|
207
|
+
// Same demotion as the provider check — non-deployed
|
|
208
|
+
// modules surface as todos rather than blockers.
|
|
209
|
+
severity: DEPLOYED_STATES.has(m.state) ? 'blocked' : 'todo',
|
|
194
210
|
code: 'capability_abi_consumer_mismatch',
|
|
195
211
|
message: `${m.id} requires ${need.name}@${need.version} but ${provider.moduleId} provides ${provider.version}`,
|
|
196
212
|
details,
|
|
@@ -24,7 +24,10 @@ function makeModule(
|
|
|
24
24
|
celilo_contract: '1.0',
|
|
25
25
|
variables: { owns: variables, imports: [] },
|
|
26
26
|
} as unknown as ModuleManifest;
|
|
27
|
-
|
|
27
|
+
// Default state INSTALLED so existing tests continue to expect
|
|
28
|
+
// `blocked` severity. Tests that exercise the IMPORTED path
|
|
29
|
+
// override the state explicitly.
|
|
30
|
+
return { id, state: 'INSTALLED', manifest, configs };
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
describe('auditModuleConfigs', () => {
|
|
@@ -128,4 +131,47 @@ describe('auditModuleConfigs', () => {
|
|
|
128
131
|
expect(result).toHaveLength(1);
|
|
129
132
|
expect(result[0].subject).toBe('caddy');
|
|
130
133
|
});
|
|
134
|
+
|
|
135
|
+
// The exact regression the operator hit on celilo-mgmt: required
|
|
136
|
+
// configs missing on IMPORTED-but-not-yet-deployed modules used to
|
|
137
|
+
// surface as `blocked`, gating system update entirely. The deploy
|
|
138
|
+
// interview collects them at deploy time, so they're todos here.
|
|
139
|
+
test('IMPORTED module with missing required config → todo, not blocked', async () => {
|
|
140
|
+
const moduleWithImportedState = {
|
|
141
|
+
...makeModule('namecheap', [makeVariable({ name: 'domains', required: true })], {}),
|
|
142
|
+
state: 'IMPORTED',
|
|
143
|
+
};
|
|
144
|
+
const result = await auditModuleConfigs({
|
|
145
|
+
modules: [moduleWithImportedState],
|
|
146
|
+
});
|
|
147
|
+
expect(result).toHaveLength(1);
|
|
148
|
+
expect(result[0].severity).toBe('todo');
|
|
149
|
+
// Remediation also flips: don't tell the operator to manually
|
|
150
|
+
// `module config set` (the deploy interview is the right path).
|
|
151
|
+
expect(result[0].remediation).toBe('celilo module deploy namecheap');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('VALIDATED / GENERATING / other pre-deploy states also demote to todo', async () => {
|
|
155
|
+
const validated = {
|
|
156
|
+
...makeModule('foo', [makeVariable({ name: 'x', required: true })], {}),
|
|
157
|
+
state: 'VALIDATED',
|
|
158
|
+
};
|
|
159
|
+
const generating = {
|
|
160
|
+
...makeModule('bar', [makeVariable({ name: 'x', required: true })], {}),
|
|
161
|
+
state: 'GENERATING',
|
|
162
|
+
};
|
|
163
|
+
const result = await auditModuleConfigs({ modules: [validated, generating] });
|
|
164
|
+
expect(result).toHaveLength(2);
|
|
165
|
+
expect(result.every((f) => f.severity === 'todo')).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('VERIFIED state stays blocked (deployed module)', async () => {
|
|
169
|
+
const verified = {
|
|
170
|
+
...makeModule('caddy', [makeVariable({ name: 'acme_email', required: true })], {}),
|
|
171
|
+
state: 'VERIFIED',
|
|
172
|
+
};
|
|
173
|
+
const result = await auditModuleConfigs({ modules: [verified] });
|
|
174
|
+
expect(result).toHaveLength(1);
|
|
175
|
+
expect(result[0].severity).toBe('blocked');
|
|
176
|
+
});
|
|
131
177
|
});
|
|
@@ -8,20 +8,30 @@
|
|
|
8
8
|
* the user.
|
|
9
9
|
*
|
|
10
10
|
* Severity rules:
|
|
11
|
-
* - `required: true`
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
11
|
+
* - DEPLOYED module (state=INSTALLED|VERIFIED) with `required: true`
|
|
12
|
+
* AND no current value AND no `default:` → BLOCKED. The module is
|
|
13
|
+
* live and a config gap is a real divergence: the next deploy
|
|
14
|
+
* would fail, capability consumers may be reading the unset value,
|
|
15
|
+
* etc.
|
|
16
|
+
* - NON-DEPLOYED module (IMPORTED, etc.) → TODO. The deploy
|
|
17
|
+
* interview will collect required values when the operator
|
|
18
|
+
* eventually runs `module deploy`. Telling them to manually
|
|
19
|
+
* `module config set` is bad UX — the interview is the canonical
|
|
20
|
+
* path. Surfacing as TODO keeps the visibility without escalating
|
|
21
|
+
* the verdict.
|
|
22
|
+
* - Variable has a `default:` (any state) → no finding. The default
|
|
23
|
+
* resolves the value.
|
|
18
24
|
*/
|
|
19
25
|
|
|
20
26
|
import type { ModuleManifest, VariableDeclare } from '../../manifest/schema';
|
|
21
27
|
import type { DriftFinding } from './types';
|
|
22
28
|
|
|
29
|
+
const DEPLOYED_STATES = new Set(['INSTALLED', 'VERIFIED']);
|
|
30
|
+
|
|
23
31
|
export interface InstalledModuleConfig {
|
|
24
32
|
id: string;
|
|
33
|
+
/** Lifecycle state from the modules table — drives severity. */
|
|
34
|
+
state: string;
|
|
25
35
|
manifest: ModuleManifest;
|
|
26
36
|
/** Map of config key → current value (string for primitives, parsed object for complex). */
|
|
27
37
|
configs: Record<string, unknown>;
|
|
@@ -37,6 +47,7 @@ function isUnset(value: unknown): boolean {
|
|
|
37
47
|
|
|
38
48
|
function ownVariableFindings(
|
|
39
49
|
moduleId: string,
|
|
50
|
+
state: string,
|
|
40
51
|
variable: VariableDeclare,
|
|
41
52
|
currentValue: unknown,
|
|
42
53
|
): DriftFinding[] {
|
|
@@ -47,23 +58,30 @@ function ownVariableFindings(
|
|
|
47
58
|
if (!isUnset(currentValue)) return [];
|
|
48
59
|
|
|
49
60
|
const hasDefault = variable.default !== undefined && variable.default !== null;
|
|
61
|
+
if (!variable.required || hasDefault) return [];
|
|
50
62
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
actionable: true,
|
|
61
|
-
subject: moduleId,
|
|
62
|
-
},
|
|
63
|
-
];
|
|
64
|
-
}
|
|
63
|
+
// BLOCKED only when the module is currently deployed. For
|
|
64
|
+
// IMPORTED-and-similar pre-deploy states, the deploy interview
|
|
65
|
+
// collects this value automatically — surfacing as a blocker
|
|
66
|
+
// wrongly directs the operator at `module config set` (a manual
|
|
67
|
+
// workaround) when the right command is `module deploy <id>`.
|
|
68
|
+
const severity = DEPLOYED_STATES.has(state) ? 'blocked' : 'todo';
|
|
69
|
+
const remediation = DEPLOYED_STATES.has(state)
|
|
70
|
+
? `celilo module config set ${moduleId} ${variable.name} <value>`
|
|
71
|
+
: `celilo module deploy ${moduleId}`;
|
|
65
72
|
|
|
66
|
-
return [
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
category: 'module_configs',
|
|
76
|
+
severity,
|
|
77
|
+
code: 'module_config_required_unset',
|
|
78
|
+
message: `${moduleId}: required config "${variable.name}" is not set`,
|
|
79
|
+
details: variable.description,
|
|
80
|
+
remediation,
|
|
81
|
+
actionable: true,
|
|
82
|
+
subject: moduleId,
|
|
83
|
+
},
|
|
84
|
+
];
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
export async function auditModuleConfigs(deps: ModuleConfigsAuditDeps): Promise<DriftFinding[]> {
|
|
@@ -72,7 +90,7 @@ export async function auditModuleConfigs(deps: ModuleConfigsAuditDeps): Promise<
|
|
|
72
90
|
for (const m of deps.modules) {
|
|
73
91
|
const owned = m.manifest.variables?.owns ?? [];
|
|
74
92
|
for (const variable of owned) {
|
|
75
|
-
findings.push(...ownVariableFindings(m.id, variable, m.configs[variable.name]));
|
|
93
|
+
findings.push(...ownVariableFindings(m.id, m.state, variable, m.configs[variable.name]));
|
|
76
94
|
}
|
|
77
95
|
}
|
|
78
96
|
|