@celilo/cli 0.3.27 → 0.3.29
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/storage-add-local.test.ts +66 -0
- package/src/cli/commands/storage-add-local.ts +66 -10
- package/src/cli/commands/system-audit.ts +3 -0
- package/src/cli/commands/system-update.test.ts +86 -1
- package/src/cli/commands/system-update.ts +47 -9
- 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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `storage add local`'s pre-save write probe.
|
|
3
|
+
*
|
|
4
|
+
* Background: an operator on celilo-mgmt typed `/var/backups/celilo`
|
|
5
|
+
* for the path. The CLI accepted it, saved a storage row, then the
|
|
6
|
+
* heavyweight verify step failed with EACCES — leaving an unverified
|
|
7
|
+
* row that confused subsequent `system update` runs. The probe
|
|
8
|
+
* catches unwriteable paths before any persistent state is created.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
12
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { probePathWriteable } from './storage-add-local';
|
|
16
|
+
|
|
17
|
+
describe('probePathWriteable', () => {
|
|
18
|
+
let tempRoot: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
tempRoot = mkdtempSync(join(tmpdir(), 'celilo-probe-test-'));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('returns null for a writeable existing directory', () => {
|
|
29
|
+
expect(probePathWriteable(tempRoot)).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("creates parent dirs that don't exist yet (mkdir -p semantics)", () => {
|
|
33
|
+
// The probe's mkdirSync uses `recursive: true`, so a path several
|
|
34
|
+
// levels below the temp root works on the first try. This is the
|
|
35
|
+
// common case for the user's typed path under their data dir.
|
|
36
|
+
const deep = join(tempRoot, 'a', 'b', 'c', 'backups');
|
|
37
|
+
expect(probePathWriteable(deep)).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('returns an error message for a path under a read-only ancestor', () => {
|
|
41
|
+
const readOnly = join(tempRoot, 'readonly');
|
|
42
|
+
mkdirSync(readOnly);
|
|
43
|
+
// 0o500 = read+execute for owner, no write. mkdirSync into it
|
|
44
|
+
// should EACCES.
|
|
45
|
+
require('node:fs').chmodSync(readOnly, 0o500);
|
|
46
|
+
try {
|
|
47
|
+
const target = join(readOnly, 'celilo-backups');
|
|
48
|
+
const err = probePathWriteable(target);
|
|
49
|
+
expect(err).not.toBeNull();
|
|
50
|
+
expect(err).toContain('EACCES');
|
|
51
|
+
} finally {
|
|
52
|
+
// Restore permissions so afterEach can clean up.
|
|
53
|
+
require('node:fs').chmodSync(readOnly, 0o700);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('cleans up the probe directory on success (no leftover state)', () => {
|
|
58
|
+
const dir = join(tempRoot, 'check');
|
|
59
|
+
expect(probePathWriteable(dir)).toBeNull();
|
|
60
|
+
// The probe creates `<dir>/.celilo-write-probe` then removes it.
|
|
61
|
+
// The directory itself stays (mkdir -p), but the probe sentinel
|
|
62
|
+
// doesn't.
|
|
63
|
+
const { existsSync } = require('node:fs');
|
|
64
|
+
expect(existsSync(join(dir, '.celilo-write-probe'))).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Configure a local filesystem backup storage destination
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { mkdirSync, rmSync } from 'node:fs';
|
|
6
7
|
import { homedir } from 'node:os';
|
|
7
8
|
import { join, resolve } from 'node:path';
|
|
8
9
|
import { getDataDir } from '../../config/paths';
|
|
@@ -15,6 +16,27 @@ import { celiloIntro, celiloOutro, promptConfirm, promptText } from '../prompts'
|
|
|
15
16
|
import type { CommandResult } from '../types';
|
|
16
17
|
import { validateRequired } from '../validators';
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Best-effort write probe. Attempts to mkdir -p a `.celilo-write-probe`
|
|
21
|
+
* subdir, then deletes it. Returns null on success, or a one-line
|
|
22
|
+
* error message on failure (suitable for surfacing to the operator).
|
|
23
|
+
*
|
|
24
|
+
* Exists so we can catch unwriteable paths AT INTERVIEW TIME instead
|
|
25
|
+
* of saving a storage row, failing the heavyweight verify step, and
|
|
26
|
+
* leaving a dangling unverified row that confuses subsequent
|
|
27
|
+
* `system update` runs (the regression that prompted this work).
|
|
28
|
+
*/
|
|
29
|
+
export function probePathWriteable(path: string): string | null {
|
|
30
|
+
const probe = join(path, '.celilo-write-probe');
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(probe, { recursive: true });
|
|
33
|
+
rmSync(probe, { recursive: true, force: true });
|
|
34
|
+
return null;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return err instanceof Error ? err.message : String(err);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
18
40
|
/**
|
|
19
41
|
* Expand leading tilde to the user's home directory.
|
|
20
42
|
* Node.js fs functions don't expand ~ like the shell does.
|
|
@@ -55,16 +77,46 @@ export async function handleStorageAddLocal(
|
|
|
55
77
|
// looked authoritative but required root to write.
|
|
56
78
|
const defaultPath = join(getDataDir(), 'backups');
|
|
57
79
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
80
|
+
// Probe writability BEFORE saving the storage row. The previous
|
|
81
|
+
// flow (save → verify → fail with EACCES → leave dangling
|
|
82
|
+
// unverified row) was confusing and required the operator to know
|
|
83
|
+
// about `storage verify` to recover. Probing here means an
|
|
84
|
+
// unwriteable path never produces persistent state.
|
|
85
|
+
//
|
|
86
|
+
// Non-interactive (--name + --path): probe once, fail loud.
|
|
87
|
+
// Interactive: re-prompt until a writeable path is supplied.
|
|
88
|
+
let resolvedPath: string;
|
|
89
|
+
if (flagPath !== undefined) {
|
|
90
|
+
resolvedPath = resolve(expandTilde(flagPath));
|
|
91
|
+
const writeError = probePathWriteable(resolvedPath);
|
|
92
|
+
if (writeError !== null) {
|
|
93
|
+
return {
|
|
94
|
+
success: false,
|
|
95
|
+
error: `Path '${resolvedPath}' is not writeable: ${writeError}\n\nTry a path under your home directory (e.g. '${defaultPath}') or run with elevated permissions.`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
let candidate: string | undefined;
|
|
100
|
+
while (candidate === undefined) {
|
|
101
|
+
const typed = await promptText({
|
|
102
|
+
message: 'Storage directory path:',
|
|
103
|
+
defaultValue: defaultPath,
|
|
104
|
+
placeholder: defaultPath,
|
|
105
|
+
validate: validateRequired('Storage path'),
|
|
106
|
+
});
|
|
107
|
+
const resolved = resolve(expandTilde(typed));
|
|
108
|
+
const writeError = probePathWriteable(resolved);
|
|
109
|
+
if (writeError === null) {
|
|
110
|
+
candidate = resolved;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
console.log(
|
|
114
|
+
`\n✗ Path '${resolved}' is not writeable: ${writeError}\nTry a path under your home directory (default '${defaultPath}' works without sudo).\n`,
|
|
115
|
+
);
|
|
116
|
+
// Loop continues — re-prompt with the same default suggestion.
|
|
117
|
+
}
|
|
118
|
+
resolvedPath = candidate;
|
|
119
|
+
}
|
|
68
120
|
|
|
69
121
|
const storage = await addBackupStorage({
|
|
70
122
|
name,
|
|
@@ -78,6 +130,10 @@ export async function handleStorageAddLocal(
|
|
|
78
130
|
const { result } = await verifyBackupStorage(storage.id);
|
|
79
131
|
|
|
80
132
|
if (!result.success) {
|
|
133
|
+
// Should be unreachable in interactive mode (the pre-save probe
|
|
134
|
+
// covers the EACCES path); could still fire for less-common
|
|
135
|
+
// verify failures (full disk, etc.). Keep the dangling-row
|
|
136
|
+
// recovery hint as a safety net.
|
|
81
137
|
console.log(`\n✗ Verification failed: ${result.message}`);
|
|
82
138
|
celiloOutro(
|
|
83
139
|
`Storage '${storage.storageId}' added but not verified.\n\nFix the path and re-verify: celilo storage verify ${storage.storageId}`,
|
|
@@ -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
|
},
|
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { describe, expect, test } from 'bun:test';
|
|
15
|
-
import
|
|
15
|
+
import type { ModuleSnapshot } from '../../services/update/orchestrator';
|
|
16
|
+
import {
|
|
17
|
+
type BackupStorageLike,
|
|
18
|
+
checkBackupStoragePreflight,
|
|
19
|
+
shouldTakeBackup,
|
|
20
|
+
} from './system-update';
|
|
16
21
|
|
|
17
22
|
const verified = (id: string): BackupStorageLike => ({ storageId: id, verified: true });
|
|
18
23
|
const unverified = (id: string): BackupStorageLike => ({ storageId: id, verified: false });
|
|
@@ -101,3 +106,83 @@ describe('checkBackupStoragePreflight', () => {
|
|
|
101
106
|
expect(result.message).toContain('Verified: home-backups, s3-cold');
|
|
102
107
|
});
|
|
103
108
|
});
|
|
109
|
+
|
|
110
|
+
const snap = (
|
|
111
|
+
id: string,
|
|
112
|
+
installedVersion: string,
|
|
113
|
+
latestVersion: string | null,
|
|
114
|
+
): ModuleSnapshot => ({
|
|
115
|
+
id,
|
|
116
|
+
installedVersion,
|
|
117
|
+
latestVersion,
|
|
118
|
+
installedProvides: {},
|
|
119
|
+
pendingRequires: {},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const snapshotsOf = (...entries: ModuleSnapshot[]): Map<string, ModuleSnapshot> =>
|
|
123
|
+
new Map(entries.map((s) => [s.id, s]));
|
|
124
|
+
|
|
125
|
+
describe('shouldTakeBackup', () => {
|
|
126
|
+
test('false when no module updates at all', () => {
|
|
127
|
+
expect(
|
|
128
|
+
shouldTakeBackup({
|
|
129
|
+
snapshots: snapshotsOf(snap('caddy', '2.0.0+5', '2.0.0+5')),
|
|
130
|
+
wasDeployed: new Set(['caddy']),
|
|
131
|
+
}),
|
|
132
|
+
).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('true when a deployed module has a registry-newer version', () => {
|
|
136
|
+
expect(
|
|
137
|
+
shouldTakeBackup({
|
|
138
|
+
snapshots: snapshotsOf(snap('caddy', '2.0.0+5', '2.0.0+6')),
|
|
139
|
+
wasDeployed: new Set(['caddy']),
|
|
140
|
+
}),
|
|
141
|
+
).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// The exact case the operator hit on celilo-mgmt: namecheap was
|
|
145
|
+
// imported but never deployed; system update wanted to back up the
|
|
146
|
+
// celilo DB even though no live state would be touched.
|
|
147
|
+
test('false when only IMPORTED modules have updates (live state untouched)', () => {
|
|
148
|
+
expect(
|
|
149
|
+
shouldTakeBackup({
|
|
150
|
+
snapshots: snapshotsOf(snap('namecheap', '3.1.0+10', '3.1.1+4')),
|
|
151
|
+
wasDeployed: new Set(), // nothing deployed
|
|
152
|
+
}),
|
|
153
|
+
).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('true when any deployed module needs updating, regardless of imported ones', () => {
|
|
157
|
+
expect(
|
|
158
|
+
shouldTakeBackup({
|
|
159
|
+
snapshots: snapshotsOf(
|
|
160
|
+
snap('namecheap', '3.1.0+10', '3.1.1+4'), // imported, has update
|
|
161
|
+
snap('caddy', '2.0.0+5', '2.0.0+6'), // deployed, has update
|
|
162
|
+
),
|
|
163
|
+
wasDeployed: new Set(['caddy']),
|
|
164
|
+
}),
|
|
165
|
+
).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('false when registry has no info (latestVersion null)', () => {
|
|
169
|
+
// Network failure — we don't know if updates exist. Default to
|
|
170
|
+
// "no backup needed" rather than blocking the run; the per-module
|
|
171
|
+
// upgrade step will report individual failures if any.
|
|
172
|
+
expect(
|
|
173
|
+
shouldTakeBackup({
|
|
174
|
+
snapshots: snapshotsOf(snap('caddy', '2.0.0+5', null)),
|
|
175
|
+
wasDeployed: new Set(['caddy']),
|
|
176
|
+
}),
|
|
177
|
+
).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('false on empty snapshots', () => {
|
|
181
|
+
expect(
|
|
182
|
+
shouldTakeBackup({
|
|
183
|
+
snapshots: new Map(),
|
|
184
|
+
wasDeployed: new Set(),
|
|
185
|
+
}),
|
|
186
|
+
).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -148,6 +148,29 @@ Or skip the safety net entirely (the CLI self-update still runs):
|
|
|
148
148
|
};
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Should `system update` take a celilo-DB snapshot for this run?
|
|
153
|
+
* True iff at least one module that's currently DEPLOYED has a
|
|
154
|
+
* registry-newer version waiting. Pure data in / data out.
|
|
155
|
+
*
|
|
156
|
+
* Excludes IMPORTED-but-not-yet-deployed modules — refreshing their
|
|
157
|
+
* on-disk source has no impact on live state, so the safety net
|
|
158
|
+
* isn't needed (and an operator who hasn't configured storage yet
|
|
159
|
+
* shouldn't be blocked by a snapshot they don't need).
|
|
160
|
+
*/
|
|
161
|
+
export function shouldTakeBackup(input: {
|
|
162
|
+
snapshots: Map<string, ModuleSnapshot>;
|
|
163
|
+
wasDeployed: Set<string>;
|
|
164
|
+
}): boolean {
|
|
165
|
+
for (const [id, s] of input.snapshots) {
|
|
166
|
+
if (!input.wasDeployed.has(id)) continue;
|
|
167
|
+
if (!s.latestVersion) continue;
|
|
168
|
+
if (s.latestVersion === s.installedVersion) continue;
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
151
174
|
function readInstalledCliVersion(): string {
|
|
152
175
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
153
176
|
const candidates = [
|
|
@@ -363,7 +386,12 @@ function buildOps(registry: RegistryClient, wasDeployed: Set<string>): Orchestra
|
|
|
363
386
|
function formatResult(result: SystemUpdateResult): string {
|
|
364
387
|
const lines: string[] = [];
|
|
365
388
|
lines.push('');
|
|
366
|
-
|
|
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'}`);
|
|
367
395
|
lines.push(` audit verdict: ${result.audit.verdict}`);
|
|
368
396
|
lines.push(
|
|
369
397
|
` self-update: ${result.selfUpdate.performed ? `${result.selfUpdate.from} → ${result.selfUpdate.to}` : `(${result.selfUpdate.reason})`}`,
|
|
@@ -500,6 +528,7 @@ export async function handleSystemUpdate(
|
|
|
500
528
|
capabilityAbi: {
|
|
501
529
|
modules: upgradableModules.map((m) => ({
|
|
502
530
|
id: m.id,
|
|
531
|
+
state: m.state,
|
|
503
532
|
manifest: m.manifestData as ModuleManifest,
|
|
504
533
|
})),
|
|
505
534
|
},
|
|
@@ -523,6 +552,7 @@ export async function handleSystemUpdate(
|
|
|
523
552
|
moduleConfigs: {
|
|
524
553
|
modules: upgradableModules.map((m) => ({
|
|
525
554
|
id: m.id,
|
|
555
|
+
state: m.state,
|
|
526
556
|
manifest: m.manifestData as ModuleManifest,
|
|
527
557
|
configs: configsByModule.get(m.id) ?? {},
|
|
528
558
|
})),
|
|
@@ -531,6 +561,7 @@ export async function handleSystemUpdate(
|
|
|
531
561
|
backups: {
|
|
532
562
|
modules: upgradableModules.map((m) => ({
|
|
533
563
|
id: m.id,
|
|
564
|
+
state: m.state,
|
|
534
565
|
manifest: m.manifestData as ModuleManifest,
|
|
535
566
|
lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
|
|
536
567
|
})),
|
|
@@ -587,14 +618,21 @@ export async function handleSystemUpdate(
|
|
|
587
618
|
}
|
|
588
619
|
|
|
589
620
|
// Decide whether the celilo-db snapshot is even needed for this run.
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
)
|
|
597
|
-
|
|
621
|
+
//
|
|
622
|
+
// The snapshot is a safety net — its purpose is to let `system update`
|
|
623
|
+
// roll back if a deploy / health step bricks a running module. So
|
|
624
|
+
// we ONLY need it when the run will actually upgrade-and-redeploy
|
|
625
|
+
// a currently-deployed module. Two cases that don't qualify:
|
|
626
|
+
//
|
|
627
|
+
// 1. Nothing has new code waiting at all (everyone's at latest).
|
|
628
|
+
// 2. The only modules with new code are IMPORTED-but-not-deployed.
|
|
629
|
+
// Their upgrade is a pure source-files-on-disk refresh; no
|
|
630
|
+
// live state to roll back, no risk to mitigate. Forcing a
|
|
631
|
+
// backup here means an operator on a fresh celilo-mgmt with
|
|
632
|
+
// nothing deployed yet has to configure backup storage just
|
|
633
|
+
// to refresh the on-disk modules they imported — which is
|
|
634
|
+
// exactly the friction that prompted this code path.
|
|
635
|
+
const effectiveNoBackup = noBackup || !shouldTakeBackup({ snapshots, wasDeployed });
|
|
598
636
|
|
|
599
637
|
// Pre-flight the storage check so a missing/unusable default doesn't
|
|
600
638
|
// reach the orchestrator's snapshot hook (where the throw would
|
|
@@ -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
|
|