@celilo/cli 0.3.25 → 0.3.26
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/system-update.ts +68 -19
- 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
|
@@ -159,9 +159,13 @@ async function buildSnapshots(
|
|
|
159
159
|
* - `health` calls `runModuleHealthCheck`.
|
|
160
160
|
* - `snapshotCeliloDb` calls `createSystemStateBackup`.
|
|
161
161
|
*/
|
|
162
|
-
function buildOps(registry: RegistryClient): OrchestratorOps {
|
|
162
|
+
function buildOps(registry: RegistryClient, wasDeployed: Set<string>): OrchestratorOps {
|
|
163
163
|
return {
|
|
164
164
|
backup: async (moduleId, _updateId) => {
|
|
165
|
+
// IMPORTED modules don't have running state to back up; skip
|
|
166
|
+
// silently. The orchestrator already gates on `!noBackup`, so
|
|
167
|
+
// this is the second layer (per-module rather than system-wide).
|
|
168
|
+
if (!wasDeployed.has(moduleId)) return { ok: true };
|
|
165
169
|
const db = getDb();
|
|
166
170
|
const mod = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
167
171
|
if (!mod) return { ok: false, error: `module ${moduleId} not found` };
|
|
@@ -202,6 +206,12 @@ function buildOps(registry: RegistryClient): OrchestratorOps {
|
|
|
202
206
|
}
|
|
203
207
|
},
|
|
204
208
|
deploy: async (id) => {
|
|
209
|
+
// IMPORTED modules weren't deployed before this run; we just
|
|
210
|
+
// refreshed their source files via `upgrade`. Don't try to
|
|
211
|
+
// deploy them here — that would surprise the operator who
|
|
212
|
+
// hasn't asked for a deploy. They'll get a `module deploy <id>`
|
|
213
|
+
// todo finding from the audit on the next run.
|
|
214
|
+
if (!wasDeployed.has(id)) return { ok: true };
|
|
205
215
|
const db = getDb();
|
|
206
216
|
// The deploy interview runs through the bus; if config is
|
|
207
217
|
// missing the deploy hangs waiting for a responder. system-update
|
|
@@ -211,6 +221,19 @@ function buildOps(registry: RegistryClient): OrchestratorOps {
|
|
|
211
221
|
return result.success ? { ok: true } : { ok: false, error: result.error };
|
|
212
222
|
},
|
|
213
223
|
health: async (id) => {
|
|
224
|
+
// Same gating as deploy: there's no live deployment to health-
|
|
225
|
+
// check for an IMPORTED module that we just refreshed. Return
|
|
226
|
+
// pass with a "not deployed" detail so the orchestrator's
|
|
227
|
+
// module-step record doesn't say "health: skipped" (which would
|
|
228
|
+
// imply a check ran and skipped its assertions).
|
|
229
|
+
if (!wasDeployed.has(id)) {
|
|
230
|
+
// The orchestrator's HealthStatus type uses 'healthy' /
|
|
231
|
+
// 'degraded' / 'unhealthy' / 'error' / 'no-checks'. There's
|
|
232
|
+
// no "skipped" — closest meaningful value for "we didn't
|
|
233
|
+
// run the check on purpose" is 'no-checks' (semantically:
|
|
234
|
+
// "the module didn't have any health checks to run").
|
|
235
|
+
return { status: 'no-checks', detail: 'not deployed; health check skipped' };
|
|
236
|
+
}
|
|
214
237
|
// We can call the existing health-runner directly today — that's
|
|
215
238
|
// already a clean service.
|
|
216
239
|
const db = getDb();
|
|
@@ -249,17 +272,20 @@ function formatResult(result: SystemUpdateResult): string {
|
|
|
249
272
|
);
|
|
250
273
|
lines.push(` backups: ${result.backupsCreated ? 'created' : 'skipped'}`);
|
|
251
274
|
|
|
252
|
-
// Surface audit findings inline.
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
275
|
+
// Surface audit findings inline. Three severity tiers:
|
|
276
|
+
// blocked → ✗ gates the run (orchestrator short-circuits)
|
|
277
|
+
// drift → ▸ informational; the system moved away from a
|
|
278
|
+
// desired state, but the run still proceeded
|
|
279
|
+
// todo → ➤ next-step reminders (e.g. "you imported X but
|
|
280
|
+
// haven't deployed it yet"); never escalates the
|
|
281
|
+
// verdict.
|
|
282
|
+
// Each tier renders separately so operators can scan for what
|
|
283
|
+
// needs attention vs. what's just a friendly nudge.
|
|
259
284
|
if (result.audit.findings.length > 0) {
|
|
260
285
|
lines.push('');
|
|
261
|
-
const drift = result.audit.findings.filter((f) => f.severity === 'drift');
|
|
262
286
|
const blocked = result.audit.findings.filter((f) => f.severity === 'blocked');
|
|
287
|
+
const drift = result.audit.findings.filter((f) => f.severity === 'drift');
|
|
288
|
+
const todo = result.audit.findings.filter((f) => f.severity === 'todo');
|
|
263
289
|
if (blocked.length > 0) {
|
|
264
290
|
lines.push(` BLOCKED (${blocked.length}):`);
|
|
265
291
|
for (const f of blocked) {
|
|
@@ -274,6 +300,13 @@ function formatResult(result: SystemUpdateResult): string {
|
|
|
274
300
|
if (f.remediation) lines.push(` → ${f.remediation}`);
|
|
275
301
|
}
|
|
276
302
|
}
|
|
303
|
+
if (todo.length > 0) {
|
|
304
|
+
lines.push(` Todos (${todo.length}, next-step reminders):`);
|
|
305
|
+
for (const f of todo) {
|
|
306
|
+
lines.push(` ➤ [${f.category}] ${f.subject}: ${f.message}`);
|
|
307
|
+
if (f.remediation) lines.push(` → ${f.remediation}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
277
310
|
}
|
|
278
311
|
|
|
279
312
|
if (result.modules.length > 0) {
|
|
@@ -303,12 +336,28 @@ export async function handleSystemUpdate(
|
|
|
303
336
|
|
|
304
337
|
const db = getDb();
|
|
305
338
|
const installed = db.select().from(modules).all();
|
|
306
|
-
|
|
339
|
+
// Include IMPORTED modules in the upgrade scope so a fresh
|
|
340
|
+
// `module import <name>` followed (later, possibly weeks later) by
|
|
341
|
+
// `system update` actually picks up registry-side updates for
|
|
342
|
+
// not-yet-deployed modules. The deploy and health steps are gated
|
|
343
|
+
// on prior state in buildOps so we don't deploy something the
|
|
344
|
+
// operator hasn't asked us to deploy.
|
|
345
|
+
// INSTALLED + VERIFIED + IMPORTED is the upgrade-eligible set;
|
|
346
|
+
// ERROR / DEPLOYING / GENERATING / UNINSTALLING are skipped (the
|
|
347
|
+
// operator needs to handle those manually first).
|
|
348
|
+
const upgradeEligibleStates = new Set(['INSTALLED', 'VERIFIED', 'IMPORTED']);
|
|
349
|
+
const upgradableModules = installed.filter((m) => upgradeEligibleStates.has(m.state));
|
|
350
|
+
// Track which modules were already deployed BEFORE this run.
|
|
351
|
+
// Anything else gets the upgrade only — no deploy / health / backup
|
|
352
|
+
// attempts (those would be no-ops at best, surprising at worst).
|
|
353
|
+
const wasDeployed = new Set(
|
|
354
|
+
installed.filter((m) => ['INSTALLED', 'VERIFIED'].includes(m.state)).map((m) => m.id),
|
|
355
|
+
);
|
|
307
356
|
const registry = new RegistryClient();
|
|
308
357
|
|
|
309
358
|
// Build the dep graph from installed manifests.
|
|
310
|
-
const graph = buildModuleGraph(
|
|
311
|
-
const snapshots = await buildSnapshots(db,
|
|
359
|
+
const graph = buildModuleGraph(upgradableModules.map((m) => m.manifestData as ModuleManifest));
|
|
360
|
+
const snapshots = await buildSnapshots(db, upgradableModules, registry);
|
|
312
361
|
|
|
313
362
|
// Build audit deps. (Mostly mirrors system-audit.ts; could be refactored
|
|
314
363
|
// into a shared helper in Phase 5.)
|
|
@@ -351,13 +400,13 @@ export async function handleSystemUpdate(
|
|
|
351
400
|
db,
|
|
352
401
|
},
|
|
353
402
|
capabilityAbi: {
|
|
354
|
-
modules:
|
|
403
|
+
modules: upgradableModules.map((m) => ({
|
|
355
404
|
id: m.id,
|
|
356
405
|
manifest: m.manifestData as ModuleManifest,
|
|
357
406
|
})),
|
|
358
407
|
},
|
|
359
408
|
terraformPlan: {
|
|
360
|
-
modules:
|
|
409
|
+
modules: upgradableModules.map((m) => ({
|
|
361
410
|
id: m.id,
|
|
362
411
|
terraformDir: existsSync(join(m.sourcePath, 'generated', 'terraform'))
|
|
363
412
|
? join(m.sourcePath, 'generated', 'terraform')
|
|
@@ -366,7 +415,7 @@ export async function handleSystemUpdate(
|
|
|
366
415
|
run: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
|
|
367
416
|
},
|
|
368
417
|
moduleVersions: {
|
|
369
|
-
installed:
|
|
418
|
+
installed: upgradableModules.map((m) => ({ id: m.id, version: m.version })),
|
|
370
419
|
fetcher: async (id: string) => {
|
|
371
420
|
const entries = await registry.getIndex(id);
|
|
372
421
|
const top = registry.latestVersion(entries);
|
|
@@ -374,7 +423,7 @@ export async function handleSystemUpdate(
|
|
|
374
423
|
},
|
|
375
424
|
},
|
|
376
425
|
moduleConfigs: {
|
|
377
|
-
modules:
|
|
426
|
+
modules: upgradableModules.map((m) => ({
|
|
378
427
|
id: m.id,
|
|
379
428
|
manifest: m.manifestData as ModuleManifest,
|
|
380
429
|
configs: configsByModule.get(m.id) ?? {},
|
|
@@ -382,7 +431,7 @@ export async function handleSystemUpdate(
|
|
|
382
431
|
},
|
|
383
432
|
health: { results: healthResults },
|
|
384
433
|
backups: {
|
|
385
|
-
modules:
|
|
434
|
+
modules: upgradableModules.map((m) => ({
|
|
386
435
|
id: m.id,
|
|
387
436
|
manifest: m.manifestData as ModuleManifest,
|
|
388
437
|
lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
|
|
@@ -415,7 +464,7 @@ export async function handleSystemUpdate(
|
|
|
415
464
|
updateId: crypto.randomUUID(),
|
|
416
465
|
audit,
|
|
417
466
|
willSelfUpdate: false, // computed at run time; the dry-run preview is best-effort
|
|
418
|
-
modules:
|
|
467
|
+
modules: upgradableModules
|
|
419
468
|
.filter((m) => {
|
|
420
469
|
const snap = snapshots.get(m.id);
|
|
421
470
|
return snap?.latestVersion && snap.latestVersion !== snap.installedVersion;
|
|
@@ -482,7 +531,7 @@ Then re-run system update.`,
|
|
|
482
531
|
}
|
|
483
532
|
}
|
|
484
533
|
|
|
485
|
-
const ops = buildOps(registry);
|
|
534
|
+
const ops = buildOps(registry, wasDeployed);
|
|
486
535
|
|
|
487
536
|
const result = await runSystemUpdate({
|
|
488
537
|
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}`,
|