@celilo/cli 0.3.25 → 0.3.27
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.ts +13 -2
- package/src/cli/commands/system-update.test.ts +103 -0
- package/src/cli/commands/system-update.ts +179 -48
- package/src/cli/tui/icons.ts +7 -1
- package/src/services/audit/types.test.ts +26 -0
- package/src/services/audit/types.ts +15 -5
- package/src/services/audit/unconfigured-modules.test.ts +1 -1
- package/src/services/audit/unconfigured-modules.ts +5 -1
- package/src/services/audit/undeployed-modules.test.ts +1 -1
- package/src/services/audit/undeployed-modules.ts +7 -1
package/package.json
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
|
-
import { resolve } from 'node:path';
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
import { getDataDir } from '../../config/paths';
|
|
8
9
|
import {
|
|
9
10
|
addBackupStorage,
|
|
10
11
|
setDefaultBackupStorage,
|
|
@@ -45,11 +46,21 @@ export async function handleStorageAddLocal(
|
|
|
45
46
|
validate: validateRequired('Storage name'),
|
|
46
47
|
}));
|
|
47
48
|
|
|
49
|
+
// Default path lives under celilo's own data dir
|
|
50
|
+
// (~/.local/share/celilo/backups on Linux, ~/Library/Application
|
|
51
|
+
// Support/celilo/backups on macOS). The directory is owned by the
|
|
52
|
+
// running user, mkdir -p succeeds without sudo, and operators who
|
|
53
|
+
// want NAS / external paths can override at the prompt. This
|
|
54
|
+
// replaces the prior placeholder of "/var/backups/celilo" which
|
|
55
|
+
// looked authoritative but required root to write.
|
|
56
|
+
const defaultPath = join(getDataDir(), 'backups');
|
|
57
|
+
|
|
48
58
|
const path =
|
|
49
59
|
flagPath ??
|
|
50
60
|
(await promptText({
|
|
51
61
|
message: 'Storage directory path:',
|
|
52
|
-
|
|
62
|
+
defaultValue: defaultPath,
|
|
63
|
+
placeholder: defaultPath,
|
|
53
64
|
validate: validateRequired('Storage path'),
|
|
54
65
|
}));
|
|
55
66
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `system update` storage pre-flight.
|
|
3
|
+
*
|
|
4
|
+
* Background: an operator on celilo-mgmt hit a confusing chain when
|
|
5
|
+
* they ran `celilo storage add local /var/backups/celilo` (a system
|
|
6
|
+
* path that needs root). The verify step failed with EACCES, leaving
|
|
7
|
+
* an unverified storage row on disk. The next `celilo system update`
|
|
8
|
+
* said "No default backup storage configured" — technically true
|
|
9
|
+
* (unverified storage never auto-defaults), but misleading: there
|
|
10
|
+
* IS a storage, it just needs fixing. The pre-flight now distinguishes
|
|
11
|
+
* the cases and points at the right next command for each.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test } from 'bun:test';
|
|
15
|
+
import { type BackupStorageLike, checkBackupStoragePreflight } from './system-update';
|
|
16
|
+
|
|
17
|
+
const verified = (id: string): BackupStorageLike => ({ storageId: id, verified: true });
|
|
18
|
+
const unverified = (id: string): BackupStorageLike => ({ storageId: id, verified: false });
|
|
19
|
+
|
|
20
|
+
describe('checkBackupStoragePreflight', () => {
|
|
21
|
+
test('verified default → ok', () => {
|
|
22
|
+
const result = checkBackupStoragePreflight({
|
|
23
|
+
defaultStorage: verified('home-backups'),
|
|
24
|
+
allStorages: [verified('home-backups')],
|
|
25
|
+
});
|
|
26
|
+
expect(result.kind).toBe('ok');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('default exists but unverified → suggests `storage verify`', () => {
|
|
30
|
+
const result = checkBackupStoragePreflight({
|
|
31
|
+
defaultStorage: unverified('home-backups'),
|
|
32
|
+
allStorages: [unverified('home-backups')],
|
|
33
|
+
});
|
|
34
|
+
if (result.kind !== 'error') throw new Error('expected error');
|
|
35
|
+
expect(result.message).toContain("Default backup storage 'home-backups' is not verified");
|
|
36
|
+
expect(result.message).toContain('celilo storage verify home-backups');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('no storage at all → suggests `storage add local`', () => {
|
|
40
|
+
const result = checkBackupStoragePreflight({
|
|
41
|
+
defaultStorage: null,
|
|
42
|
+
allStorages: [],
|
|
43
|
+
});
|
|
44
|
+
if (result.kind !== 'error') throw new Error('expected error');
|
|
45
|
+
expect(result.message).toContain('No backup storage configured');
|
|
46
|
+
expect(result.message).toContain('celilo storage add local');
|
|
47
|
+
// Always offer the --no-backup escape so the operator knows the
|
|
48
|
+
// CLI self-update can still run on a system without storage.
|
|
49
|
+
expect(result.message).toContain('--no-backup');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// The exact case the operator hit on celilo-mgmt: ran storage add,
|
|
53
|
+
// path was unwriteable (/var/backups/celilo without sudo), verify
|
|
54
|
+
// failed, the unverified row stayed in the DB and isn't default.
|
|
55
|
+
test('only unverified storage → suggests `storage verify`, not `storage add`', () => {
|
|
56
|
+
const result = checkBackupStoragePreflight({
|
|
57
|
+
defaultStorage: null,
|
|
58
|
+
allStorages: [unverified('local-backups')],
|
|
59
|
+
});
|
|
60
|
+
if (result.kind !== 'error') throw new Error('expected error');
|
|
61
|
+
expect(result.message).toContain('none is verified yet');
|
|
62
|
+
expect(result.message).toContain('Storages: local-backups');
|
|
63
|
+
expect(result.message).toContain('celilo storage verify <storage-id>');
|
|
64
|
+
// Critically, this case does NOT direct the operator to run
|
|
65
|
+
// `storage add local` — they already did, the row exists.
|
|
66
|
+
expect(result.message).not.toContain('celilo storage add local');
|
|
67
|
+
// Surface the most common cause inline rather than burying it.
|
|
68
|
+
expect(result.message).toContain('elevated permissions');
|
|
69
|
+
expect(result.message).toContain('--no-backup');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('multiple unverified storages → lists all storage IDs', () => {
|
|
73
|
+
const result = checkBackupStoragePreflight({
|
|
74
|
+
defaultStorage: null,
|
|
75
|
+
allStorages: [unverified('local-a'), unverified('local-b'), unverified('local-c')],
|
|
76
|
+
});
|
|
77
|
+
if (result.kind !== 'error') throw new Error('expected error');
|
|
78
|
+
expect(result.message).toContain('Storages: local-a, local-b, local-c');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('verified storage exists but none is default → suggests `storage set-default`', () => {
|
|
82
|
+
const result = checkBackupStoragePreflight({
|
|
83
|
+
defaultStorage: null,
|
|
84
|
+
allStorages: [verified('home-backups'), unverified('s3-cold')],
|
|
85
|
+
});
|
|
86
|
+
if (result.kind !== 'error') throw new Error('expected error');
|
|
87
|
+
expect(result.message).toContain('verified but no default is set');
|
|
88
|
+
expect(result.message).toContain('Verified: home-backups');
|
|
89
|
+
// Doesn't mention the unverified one in the "Verified:" listing
|
|
90
|
+
// (that would mislead).
|
|
91
|
+
expect(result.message).not.toContain('Verified: home-backups, s3-cold');
|
|
92
|
+
expect(result.message).toContain('celilo storage set-default <storage-id>');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('multiple verified, no default → lists all verified IDs', () => {
|
|
96
|
+
const result = checkBackupStoragePreflight({
|
|
97
|
+
defaultStorage: null,
|
|
98
|
+
allStorages: [verified('home-backups'), verified('s3-cold'), unverified('nas')],
|
|
99
|
+
});
|
|
100
|
+
if (result.kind !== 'error') throw new Error('expected error');
|
|
101
|
+
expect(result.message).toContain('Verified: home-backups, s3-cold');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { spawnSync } from 'node:child_process';
|
|
19
19
|
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
20
21
|
import { dirname, join } from 'node:path';
|
|
21
22
|
import { fileURLToPath } from 'node:url';
|
|
22
23
|
import { eq } from 'drizzle-orm';
|
|
@@ -50,6 +51,103 @@ import { fetchAndUpgrade } from './module-upgrade';
|
|
|
50
51
|
*/
|
|
51
52
|
const MANAGED_PACKAGES = ['@celilo/cli', '@celilo/event-bus', '@celilo/e2e'] as const;
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Result of inspecting backup-storage configuration before letting
|
|
56
|
+
* `runSystemUpdate` take a celilo-DB snapshot. Three failure shapes
|
|
57
|
+
* the operator might hit, each with a distinct friendly message:
|
|
58
|
+
*
|
|
59
|
+
* 1. No storage rows at all → run `storage add local`
|
|
60
|
+
* 2. Storage rows exist, none verified → run `storage verify <id>`
|
|
61
|
+
* (the case the operator hits when an unverified storage was
|
|
62
|
+
* left behind by a failed verify)
|
|
63
|
+
* 3. Verified storage exists, none is default → run
|
|
64
|
+
* `storage set-default <id>`
|
|
65
|
+
* 4. Default exists but isn't verified → run `storage verify <id>`
|
|
66
|
+
*
|
|
67
|
+
* Pure data in / data out so it's testable without setting up an
|
|
68
|
+
* audit, registry, or DB.
|
|
69
|
+
*/
|
|
70
|
+
export interface BackupStorageLike {
|
|
71
|
+
storageId: string;
|
|
72
|
+
verified: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function checkBackupStoragePreflight(input: {
|
|
76
|
+
defaultStorage: BackupStorageLike | null;
|
|
77
|
+
allStorages: BackupStorageLike[];
|
|
78
|
+
}): { kind: 'ok' } | { kind: 'error'; message: string } {
|
|
79
|
+
const { defaultStorage, allStorages } = input;
|
|
80
|
+
|
|
81
|
+
if (defaultStorage) {
|
|
82
|
+
if (defaultStorage.verified) return { kind: 'ok' };
|
|
83
|
+
return {
|
|
84
|
+
kind: 'error',
|
|
85
|
+
message: `Default backup storage '${defaultStorage.storageId}' is not verified.
|
|
86
|
+
|
|
87
|
+
Run: celilo storage verify ${defaultStorage.storageId}
|
|
88
|
+
|
|
89
|
+
Then re-run system update.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (allStorages.length === 0) {
|
|
94
|
+
return {
|
|
95
|
+
kind: 'error',
|
|
96
|
+
message: `No backup storage configured.
|
|
97
|
+
|
|
98
|
+
celilo system update snapshots the celilo DB before applying module
|
|
99
|
+
updates as a safety net. Configure backup storage first:
|
|
100
|
+
|
|
101
|
+
celilo storage add local
|
|
102
|
+
|
|
103
|
+
Or skip the safety net entirely (the CLI self-update still runs):
|
|
104
|
+
|
|
105
|
+
celilo system update --no-backup`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const verified = allStorages.filter((s) => s.verified);
|
|
110
|
+
if (verified.length === 0) {
|
|
111
|
+
const ids = allStorages.map((s) => s.storageId).join(', ');
|
|
112
|
+
return {
|
|
113
|
+
kind: 'error',
|
|
114
|
+
message: `Backup storage exists but none is verified yet.
|
|
115
|
+
|
|
116
|
+
Storages: ${ids}
|
|
117
|
+
|
|
118
|
+
Verify one before re-running system update:
|
|
119
|
+
|
|
120
|
+
celilo storage verify <storage-id>
|
|
121
|
+
|
|
122
|
+
Common causes of failed verification:
|
|
123
|
+
- The path requires elevated permissions (try a path under your
|
|
124
|
+
home directory; the default '${join(homedir(), '.local/share/celilo/backups')}'
|
|
125
|
+
works without sudo).
|
|
126
|
+
- The disk is full or the mount point isn't writeable.
|
|
127
|
+
|
|
128
|
+
Or skip the safety net entirely (the CLI self-update still runs):
|
|
129
|
+
|
|
130
|
+
celilo system update --no-backup`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const ids = verified.map((s) => s.storageId).join(', ');
|
|
135
|
+
return {
|
|
136
|
+
kind: 'error',
|
|
137
|
+
message: `Backup storage is verified but no default is set.
|
|
138
|
+
|
|
139
|
+
Verified: ${ids}
|
|
140
|
+
|
|
141
|
+
Set a default before re-running system update:
|
|
142
|
+
|
|
143
|
+
celilo storage set-default <storage-id>
|
|
144
|
+
|
|
145
|
+
Or skip the safety net entirely (the CLI self-update still runs):
|
|
146
|
+
|
|
147
|
+
celilo system update --no-backup`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
53
151
|
function readInstalledCliVersion(): string {
|
|
54
152
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
55
153
|
const candidates = [
|
|
@@ -159,9 +257,13 @@ async function buildSnapshots(
|
|
|
159
257
|
* - `health` calls `runModuleHealthCheck`.
|
|
160
258
|
* - `snapshotCeliloDb` calls `createSystemStateBackup`.
|
|
161
259
|
*/
|
|
162
|
-
function buildOps(registry: RegistryClient): OrchestratorOps {
|
|
260
|
+
function buildOps(registry: RegistryClient, wasDeployed: Set<string>): OrchestratorOps {
|
|
163
261
|
return {
|
|
164
262
|
backup: async (moduleId, _updateId) => {
|
|
263
|
+
// IMPORTED modules don't have running state to back up; skip
|
|
264
|
+
// silently. The orchestrator already gates on `!noBackup`, so
|
|
265
|
+
// this is the second layer (per-module rather than system-wide).
|
|
266
|
+
if (!wasDeployed.has(moduleId)) return { ok: true };
|
|
165
267
|
const db = getDb();
|
|
166
268
|
const mod = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
167
269
|
if (!mod) return { ok: false, error: `module ${moduleId} not found` };
|
|
@@ -202,6 +304,12 @@ function buildOps(registry: RegistryClient): OrchestratorOps {
|
|
|
202
304
|
}
|
|
203
305
|
},
|
|
204
306
|
deploy: async (id) => {
|
|
307
|
+
// IMPORTED modules weren't deployed before this run; we just
|
|
308
|
+
// refreshed their source files via `upgrade`. Don't try to
|
|
309
|
+
// deploy them here — that would surprise the operator who
|
|
310
|
+
// hasn't asked for a deploy. They'll get a `module deploy <id>`
|
|
311
|
+
// todo finding from the audit on the next run.
|
|
312
|
+
if (!wasDeployed.has(id)) return { ok: true };
|
|
205
313
|
const db = getDb();
|
|
206
314
|
// The deploy interview runs through the bus; if config is
|
|
207
315
|
// missing the deploy hangs waiting for a responder. system-update
|
|
@@ -211,6 +319,19 @@ function buildOps(registry: RegistryClient): OrchestratorOps {
|
|
|
211
319
|
return result.success ? { ok: true } : { ok: false, error: result.error };
|
|
212
320
|
},
|
|
213
321
|
health: async (id) => {
|
|
322
|
+
// Same gating as deploy: there's no live deployment to health-
|
|
323
|
+
// check for an IMPORTED module that we just refreshed. Return
|
|
324
|
+
// pass with a "not deployed" detail so the orchestrator's
|
|
325
|
+
// module-step record doesn't say "health: skipped" (which would
|
|
326
|
+
// imply a check ran and skipped its assertions).
|
|
327
|
+
if (!wasDeployed.has(id)) {
|
|
328
|
+
// The orchestrator's HealthStatus type uses 'healthy' /
|
|
329
|
+
// 'degraded' / 'unhealthy' / 'error' / 'no-checks'. There's
|
|
330
|
+
// no "skipped" — closest meaningful value for "we didn't
|
|
331
|
+
// run the check on purpose" is 'no-checks' (semantically:
|
|
332
|
+
// "the module didn't have any health checks to run").
|
|
333
|
+
return { status: 'no-checks', detail: 'not deployed; health check skipped' };
|
|
334
|
+
}
|
|
214
335
|
// We can call the existing health-runner directly today — that's
|
|
215
336
|
// already a clean service.
|
|
216
337
|
const db = getDb();
|
|
@@ -249,17 +370,20 @@ function formatResult(result: SystemUpdateResult): string {
|
|
|
249
370
|
);
|
|
250
371
|
lines.push(` backups: ${result.backupsCreated ? 'created' : 'skipped'}`);
|
|
251
372
|
|
|
252
|
-
// Surface audit findings inline.
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
373
|
+
// Surface audit findings inline. Three severity tiers:
|
|
374
|
+
// blocked → ✗ gates the run (orchestrator short-circuits)
|
|
375
|
+
// drift → ▸ informational; the system moved away from a
|
|
376
|
+
// desired state, but the run still proceeded
|
|
377
|
+
// todo → ➤ next-step reminders (e.g. "you imported X but
|
|
378
|
+
// haven't deployed it yet"); never escalates the
|
|
379
|
+
// verdict.
|
|
380
|
+
// Each tier renders separately so operators can scan for what
|
|
381
|
+
// needs attention vs. what's just a friendly nudge.
|
|
259
382
|
if (result.audit.findings.length > 0) {
|
|
260
383
|
lines.push('');
|
|
261
|
-
const drift = result.audit.findings.filter((f) => f.severity === 'drift');
|
|
262
384
|
const blocked = result.audit.findings.filter((f) => f.severity === 'blocked');
|
|
385
|
+
const drift = result.audit.findings.filter((f) => f.severity === 'drift');
|
|
386
|
+
const todo = result.audit.findings.filter((f) => f.severity === 'todo');
|
|
263
387
|
if (blocked.length > 0) {
|
|
264
388
|
lines.push(` BLOCKED (${blocked.length}):`);
|
|
265
389
|
for (const f of blocked) {
|
|
@@ -274,6 +398,13 @@ function formatResult(result: SystemUpdateResult): string {
|
|
|
274
398
|
if (f.remediation) lines.push(` → ${f.remediation}`);
|
|
275
399
|
}
|
|
276
400
|
}
|
|
401
|
+
if (todo.length > 0) {
|
|
402
|
+
lines.push(` Todos (${todo.length}, next-step reminders):`);
|
|
403
|
+
for (const f of todo) {
|
|
404
|
+
lines.push(` ➤ [${f.category}] ${f.subject}: ${f.message}`);
|
|
405
|
+
if (f.remediation) lines.push(` → ${f.remediation}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
277
408
|
}
|
|
278
409
|
|
|
279
410
|
if (result.modules.length > 0) {
|
|
@@ -303,12 +434,28 @@ export async function handleSystemUpdate(
|
|
|
303
434
|
|
|
304
435
|
const db = getDb();
|
|
305
436
|
const installed = db.select().from(modules).all();
|
|
306
|
-
|
|
437
|
+
// Include IMPORTED modules in the upgrade scope so a fresh
|
|
438
|
+
// `module import <name>` followed (later, possibly weeks later) by
|
|
439
|
+
// `system update` actually picks up registry-side updates for
|
|
440
|
+
// not-yet-deployed modules. The deploy and health steps are gated
|
|
441
|
+
// on prior state in buildOps so we don't deploy something the
|
|
442
|
+
// operator hasn't asked us to deploy.
|
|
443
|
+
// INSTALLED + VERIFIED + IMPORTED is the upgrade-eligible set;
|
|
444
|
+
// ERROR / DEPLOYING / GENERATING / UNINSTALLING are skipped (the
|
|
445
|
+
// operator needs to handle those manually first).
|
|
446
|
+
const upgradeEligibleStates = new Set(['INSTALLED', 'VERIFIED', 'IMPORTED']);
|
|
447
|
+
const upgradableModules = installed.filter((m) => upgradeEligibleStates.has(m.state));
|
|
448
|
+
// Track which modules were already deployed BEFORE this run.
|
|
449
|
+
// Anything else gets the upgrade only — no deploy / health / backup
|
|
450
|
+
// attempts (those would be no-ops at best, surprising at worst).
|
|
451
|
+
const wasDeployed = new Set(
|
|
452
|
+
installed.filter((m) => ['INSTALLED', 'VERIFIED'].includes(m.state)).map((m) => m.id),
|
|
453
|
+
);
|
|
307
454
|
const registry = new RegistryClient();
|
|
308
455
|
|
|
309
456
|
// Build the dep graph from installed manifests.
|
|
310
|
-
const graph = buildModuleGraph(
|
|
311
|
-
const snapshots = await buildSnapshots(db,
|
|
457
|
+
const graph = buildModuleGraph(upgradableModules.map((m) => m.manifestData as ModuleManifest));
|
|
458
|
+
const snapshots = await buildSnapshots(db, upgradableModules, registry);
|
|
312
459
|
|
|
313
460
|
// Build audit deps. (Mostly mirrors system-audit.ts; could be refactored
|
|
314
461
|
// into a shared helper in Phase 5.)
|
|
@@ -351,13 +498,13 @@ export async function handleSystemUpdate(
|
|
|
351
498
|
db,
|
|
352
499
|
},
|
|
353
500
|
capabilityAbi: {
|
|
354
|
-
modules:
|
|
501
|
+
modules: upgradableModules.map((m) => ({
|
|
355
502
|
id: m.id,
|
|
356
503
|
manifest: m.manifestData as ModuleManifest,
|
|
357
504
|
})),
|
|
358
505
|
},
|
|
359
506
|
terraformPlan: {
|
|
360
|
-
modules:
|
|
507
|
+
modules: upgradableModules.map((m) => ({
|
|
361
508
|
id: m.id,
|
|
362
509
|
terraformDir: existsSync(join(m.sourcePath, 'generated', 'terraform'))
|
|
363
510
|
? join(m.sourcePath, 'generated', 'terraform')
|
|
@@ -366,7 +513,7 @@ export async function handleSystemUpdate(
|
|
|
366
513
|
run: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
|
|
367
514
|
},
|
|
368
515
|
moduleVersions: {
|
|
369
|
-
installed:
|
|
516
|
+
installed: upgradableModules.map((m) => ({ id: m.id, version: m.version })),
|
|
370
517
|
fetcher: async (id: string) => {
|
|
371
518
|
const entries = await registry.getIndex(id);
|
|
372
519
|
const top = registry.latestVersion(entries);
|
|
@@ -374,7 +521,7 @@ export async function handleSystemUpdate(
|
|
|
374
521
|
},
|
|
375
522
|
},
|
|
376
523
|
moduleConfigs: {
|
|
377
|
-
modules:
|
|
524
|
+
modules: upgradableModules.map((m) => ({
|
|
378
525
|
id: m.id,
|
|
379
526
|
manifest: m.manifestData as ModuleManifest,
|
|
380
527
|
configs: configsByModule.get(m.id) ?? {},
|
|
@@ -382,7 +529,7 @@ export async function handleSystemUpdate(
|
|
|
382
529
|
},
|
|
383
530
|
health: { results: healthResults },
|
|
384
531
|
backups: {
|
|
385
|
-
modules:
|
|
532
|
+
modules: upgradableModules.map((m) => ({
|
|
386
533
|
id: m.id,
|
|
387
534
|
manifest: m.manifestData as ModuleManifest,
|
|
388
535
|
lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
|
|
@@ -415,7 +562,7 @@ export async function handleSystemUpdate(
|
|
|
415
562
|
updateId: crypto.randomUUID(),
|
|
416
563
|
audit,
|
|
417
564
|
willSelfUpdate: false, // computed at run time; the dry-run preview is best-effort
|
|
418
|
-
modules:
|
|
565
|
+
modules: upgradableModules
|
|
419
566
|
.filter((m) => {
|
|
420
567
|
const snap = snapshots.get(m.id);
|
|
421
568
|
return snap?.latestVersion && snap.latestVersion !== snap.installedVersion;
|
|
@@ -449,40 +596,24 @@ export async function handleSystemUpdate(
|
|
|
449
596
|
);
|
|
450
597
|
const effectiveNoBackup = noBackup || !hasModuleUpdates;
|
|
451
598
|
|
|
452
|
-
// Pre-flight the storage check so a missing default doesn't
|
|
453
|
-
// orchestrator's snapshot hook (where the throw would
|
|
454
|
-
// hostile stack trace). Skip when we're already not
|
|
599
|
+
// Pre-flight the storage check so a missing/unusable default doesn't
|
|
600
|
+
// reach the orchestrator's snapshot hook (where the throw would
|
|
601
|
+
// surface as a hostile stack trace). Skip when we're already not
|
|
602
|
+
// going to backup.
|
|
455
603
|
if (!effectiveNoBackup) {
|
|
456
|
-
const { getDefaultBackupStorage } = await import(
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
celilo storage add local
|
|
467
|
-
|
|
468
|
-
Or skip the safety net entirely (the CLI self-update still runs):
|
|
469
|
-
|
|
470
|
-
celilo system update --no-backup`,
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
if (!storage.verified) {
|
|
474
|
-
return {
|
|
475
|
-
success: false,
|
|
476
|
-
error: `Default backup storage '${storage.storageId}' is not verified.
|
|
477
|
-
|
|
478
|
-
Run: celilo storage verify ${storage.storageId}
|
|
479
|
-
|
|
480
|
-
Then re-run system update.`,
|
|
481
|
-
};
|
|
604
|
+
const { getDefaultBackupStorage, listBackupStorages } = await import(
|
|
605
|
+
'../../services/backup-storage'
|
|
606
|
+
);
|
|
607
|
+
const preflight = checkBackupStoragePreflight({
|
|
608
|
+
defaultStorage: getDefaultBackupStorage(),
|
|
609
|
+
allStorages: listBackupStorages(),
|
|
610
|
+
});
|
|
611
|
+
if (preflight.kind === 'error') {
|
|
612
|
+
return { success: false, error: preflight.message };
|
|
482
613
|
}
|
|
483
614
|
}
|
|
484
615
|
|
|
485
|
-
const ops = buildOps(registry);
|
|
616
|
+
const ops = buildOps(registry, wasDeployed);
|
|
486
617
|
|
|
487
618
|
const result = await runSystemUpdate({
|
|
488
619
|
audit: auditDeps,
|
package/src/cli/tui/icons.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface SeverityVisual {
|
|
|
9
9
|
export const SEVERITY_VISUALS: Record<DriftSeverity, SeverityVisual> = {
|
|
10
10
|
blocked: { icon: '×', color: 'red', label: 'BLOCKED' },
|
|
11
11
|
drift: { icon: '▲', color: 'yellow', label: 'DRIFT' },
|
|
12
|
+
todo: { icon: '➤', color: 'gray', label: 'TODO' },
|
|
12
13
|
};
|
|
13
14
|
|
|
14
15
|
export const VERDICT_VISUALS: Record<AuditVerdict, SeverityVisual> = {
|
|
@@ -17,6 +18,11 @@ export const VERDICT_VISUALS: Record<AuditVerdict, SeverityVisual> = {
|
|
|
17
18
|
READY: { icon: '✓', color: 'green', label: 'READY' },
|
|
18
19
|
};
|
|
19
20
|
|
|
21
|
+
// Lower rank = higher priority (sorts first when listing). todo is
|
|
22
|
+
// the lowest priority — operators can scan past them when triaging
|
|
23
|
+
// what actually needs attention.
|
|
20
24
|
export function severityRank(s: DriftSeverity): number {
|
|
21
|
-
|
|
25
|
+
if (s === 'blocked') return 0;
|
|
26
|
+
if (s === 'drift') return 1;
|
|
27
|
+
return 2; // todo
|
|
22
28
|
}
|
|
@@ -17,6 +17,14 @@ const blocked: DriftFinding = {
|
|
|
17
17
|
subject: 'lunacycle',
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
const todo: DriftFinding = {
|
|
21
|
+
category: 'undeployed_modules',
|
|
22
|
+
severity: 'todo',
|
|
23
|
+
code: 'module_undeployed',
|
|
24
|
+
message: 'namecheap: imported but not deployed (state: IMPORTED)',
|
|
25
|
+
subject: 'namecheap',
|
|
26
|
+
};
|
|
27
|
+
|
|
20
28
|
describe('computeVerdict', () => {
|
|
21
29
|
test('READY when no findings', () => {
|
|
22
30
|
expect(computeVerdict([])).toBe('READY');
|
|
@@ -33,4 +41,22 @@ describe('computeVerdict', () => {
|
|
|
33
41
|
test('BLOCKED takes precedence regardless of order', () => {
|
|
34
42
|
expect(computeVerdict([blocked, drift])).toBe('BLOCKED');
|
|
35
43
|
});
|
|
44
|
+
|
|
45
|
+
// Pinning down the new severity tier: todo findings are
|
|
46
|
+
// informational reminders and never escalate the verdict beyond
|
|
47
|
+
// READY. The whole point of this severity level is to let
|
|
48
|
+
// categories like undeployed_modules and unconfigured_modules
|
|
49
|
+
// surface a TODO list without making `system update` look like
|
|
50
|
+
// it has unfinished work.
|
|
51
|
+
test('READY when only todo-severity findings', () => {
|
|
52
|
+
expect(computeVerdict([todo, { ...todo, subject: 'caddy' }])).toBe('READY');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('DRIFT when both drift and todo findings exist (drift wins)', () => {
|
|
56
|
+
expect(computeVerdict([todo, drift])).toBe('DRIFT');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('BLOCKED still wins over todos', () => {
|
|
60
|
+
expect(computeVerdict([todo, drift, blocked])).toBe('BLOCKED');
|
|
61
|
+
});
|
|
36
62
|
});
|
|
@@ -3,14 +3,22 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `runAudit` returns a `SystemAuditReport` aggregating findings from
|
|
5
5
|
* each drift category. Each finding has a category (one of
|
|
6
|
-
* `DriftCategory`), a severity (`drift
|
|
6
|
+
* `DriftCategory`), a severity (`todo`, `drift`, or `blocked`), a stable
|
|
7
7
|
* machine-readable code, and a human-readable message + suggested
|
|
8
8
|
* remediation.
|
|
9
9
|
*
|
|
10
10
|
* The overall verdict is computed from the findings:
|
|
11
11
|
* - any `blocked` finding → BLOCKED
|
|
12
12
|
* - any `drift` finding (no blocked) → DRIFT
|
|
13
|
-
* -
|
|
13
|
+
* - only `todo` findings (or none) → READY
|
|
14
|
+
*
|
|
15
|
+
* `todo` exists because some categories surface "next-step reminders"
|
|
16
|
+
* rather than actual divergence — `unconfigured_modules` and
|
|
17
|
+
* `undeployed_modules` are the canonical examples. The operator
|
|
18
|
+
* imported a module and hasn't gotten around to configuring/deploying;
|
|
19
|
+
* that's a TODO list, not a drifted system. Calling it DRIFT
|
|
20
|
+
* overloaded the term and made `system update` look like it had
|
|
21
|
+
* unfinished work even when it didn't.
|
|
14
22
|
*/
|
|
15
23
|
|
|
16
24
|
export type DriftCategory =
|
|
@@ -29,7 +37,7 @@ export type DriftCategory =
|
|
|
29
37
|
| 'services_reachable'
|
|
30
38
|
| 'machines_reachable';
|
|
31
39
|
|
|
32
|
-
export type DriftSeverity = 'drift' | 'blocked';
|
|
40
|
+
export type DriftSeverity = 'todo' | 'drift' | 'blocked';
|
|
33
41
|
|
|
34
42
|
export type AuditVerdict = 'READY' | 'DRIFT' | 'BLOCKED';
|
|
35
43
|
|
|
@@ -81,10 +89,12 @@ export interface SystemAuditReport {
|
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
/**
|
|
84
|
-
* Compute the overall verdict from a set of findings.
|
|
92
|
+
* Compute the overall verdict from a set of findings. `todo` findings
|
|
93
|
+
* never escalate beyond READY — they're informational reminders, not
|
|
94
|
+
* a signal that the system has moved away from a desired state.
|
|
85
95
|
*/
|
|
86
96
|
export function computeVerdict(findings: DriftFinding[]): AuditVerdict {
|
|
87
97
|
if (findings.some((f) => f.severity === 'blocked')) return 'BLOCKED';
|
|
88
|
-
if (findings.
|
|
98
|
+
if (findings.some((f) => f.severity === 'drift')) return 'DRIFT';
|
|
89
99
|
return 'READY';
|
|
90
100
|
}
|
|
@@ -9,7 +9,7 @@ describe('auditUnconfiguredModules', () => {
|
|
|
9
9
|
expect(result).toHaveLength(1);
|
|
10
10
|
expect(result[0]).toMatchObject({
|
|
11
11
|
category: 'unconfigured_modules',
|
|
12
|
-
severity: '
|
|
12
|
+
severity: 'todo',
|
|
13
13
|
code: 'module_unconfigured',
|
|
14
14
|
actionable: false,
|
|
15
15
|
subject: 'authentik',
|
|
@@ -41,9 +41,13 @@ export async function auditUnconfiguredModules(
|
|
|
41
41
|
if (ALREADY_DEPLOYED.has(m.state)) continue;
|
|
42
42
|
if (m.configCount > 0) continue;
|
|
43
43
|
|
|
44
|
+
// todo: same reasoning as undeployed_modules — never running the
|
|
45
|
+
// configuration interview is a next-step reminder, not divergence
|
|
46
|
+
// from a desired state. Won't escalate the audit verdict beyond
|
|
47
|
+
// READY.
|
|
44
48
|
findings.push({
|
|
45
49
|
category: 'unconfigured_modules',
|
|
46
|
-
severity: '
|
|
50
|
+
severity: 'todo',
|
|
47
51
|
code: 'module_unconfigured',
|
|
48
52
|
message: `${m.id}: never configured (no config rows in DB)`,
|
|
49
53
|
details: [
|
|
@@ -19,7 +19,7 @@ describe('auditUndeployedModules', () => {
|
|
|
19
19
|
expect(result).toHaveLength(1);
|
|
20
20
|
expect(result[0]).toMatchObject({
|
|
21
21
|
category: 'undeployed_modules',
|
|
22
|
-
severity: '
|
|
22
|
+
severity: 'todo',
|
|
23
23
|
code: 'module_undeployed',
|
|
24
24
|
remediation: 'celilo module deploy authentik',
|
|
25
25
|
actionable: true,
|
|
@@ -57,9 +57,15 @@ export async function auditUndeployedModules(
|
|
|
57
57
|
continue;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// todo: this is a next-step reminder, not a divergence from a
|
|
61
|
+
// desired state. The operator imported the module and hasn't
|
|
62
|
+
// deployed yet — that's a TODO list, not "the system has drifted".
|
|
63
|
+
// ERROR-state modules (handled above) DO stay severity=blocked
|
|
64
|
+
// because the operator's prior intent was to deploy, the deploy
|
|
65
|
+
// failed, and the system is now stuck pending intervention.
|
|
60
66
|
findings.push({
|
|
61
67
|
category: 'undeployed_modules',
|
|
62
|
-
severity: '
|
|
68
|
+
severity: 'todo',
|
|
63
69
|
code: 'module_undeployed',
|
|
64
70
|
message: `${m.id}: imported but not deployed (state: ${m.state})`,
|
|
65
71
|
remediation: `celilo module deploy ${m.id}`,
|