@celilo/cli 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +18 -2
  3. package/MODULE_PRIMITIVES.md +25 -7
  4. package/drizzle/0026_module_integrity_version.sql +20 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/lookup.ts +39 -29
  8. package/src/capabilities/secret-ref.test.ts +24 -0
  9. package/src/capabilities/secret-validation.ts +50 -0
  10. package/src/capabilities/validation.test.ts +187 -2
  11. package/src/capabilities/validation.ts +53 -1
  12. package/src/cli/commands/alerts-sweep.ts +18 -0
  13. package/src/cli/commands/module-audit.ts +5 -2
  14. package/src/cli/commands/module-remove.ts +34 -2
  15. package/src/cli/commands/module-update.test.ts +238 -3
  16. package/src/cli/commands/module-update.ts +206 -12
  17. package/src/cli/commands/module-verify.ts +77 -13
  18. package/src/cli/commands/service-set-credentials.test.ts +108 -0
  19. package/src/cli/commands/service-set-credentials.ts +115 -0
  20. package/src/cli/commands/system-audit.ts +17 -0
  21. package/src/cli/commands/system-doctor.ts +78 -2
  22. package/src/cli/commands/system-migrate.ts +6 -4
  23. package/src/cli/commands/system-update.ts +33 -3
  24. package/src/cli/completion.ts +16 -1
  25. package/src/cli/index.ts +11 -2
  26. package/src/cli/tui/audit-state.ts +11 -3
  27. package/src/cli/tui/audit-tui.tsx +10 -4
  28. package/src/cli/tui/icons.ts +9 -2
  29. package/src/cli/tui/modals/analyzing.tsx +3 -0
  30. package/src/db/client.ts +10 -8
  31. package/src/db/migrate.test.ts +147 -0
  32. package/src/db/migrate.ts +69 -1
  33. package/src/db/schema.ts +5 -0
  34. package/src/hooks/capability-loader.test.ts +55 -0
  35. package/src/hooks/capability-loader.ts +16 -1
  36. package/src/manifest/json-schema-roundtrip.test.ts +12 -4
  37. package/src/manifest/schema.ts +23 -0
  38. package/src/module/import.ts +56 -40
  39. package/src/module/packaging/audit.ts +103 -28
  40. package/src/module/packaging/build.ts +12 -53
  41. package/src/module/packaging/classify-module-path.test.ts +104 -0
  42. package/src/module/packaging/extract.ts +31 -3
  43. package/src/module/packaging/generated-plane.test.ts +79 -0
  44. package/src/module/packaging/generated-plane.ts +134 -0
  45. package/src/module/packaging/host-plane.test.ts +132 -0
  46. package/src/module/packaging/host-plane.ts +135 -0
  47. package/src/module/packaging/package-rules.ts +62 -0
  48. package/src/policy/module-business-baseline.ts +0 -11
  49. package/src/services/alerting/monitors.ts +54 -2
  50. package/src/services/alerting/sweep-runner.ts +38 -1
  51. package/src/services/audit/cli-version.test.ts +6 -2
  52. package/src/services/audit/cli-version.ts +20 -6
  53. package/src/services/audit/detect-without-converge.test.ts +91 -0
  54. package/src/services/audit/detect-without-converge.ts +81 -0
  55. package/src/services/audit/disk-space.test.ts +5 -2
  56. package/src/services/audit/disk-space.ts +5 -3
  57. package/src/services/audit/health.test.ts +39 -0
  58. package/src/services/audit/index.test.ts +7 -1
  59. package/src/services/audit/index.ts +12 -0
  60. package/src/services/audit/module-integrity.test.ts +146 -0
  61. package/src/services/audit/module-integrity.ts +113 -0
  62. package/src/services/audit/module-versions.ts +4 -1
  63. package/src/services/audit/schema.test.ts +7 -2
  64. package/src/services/audit/schema.ts +19 -1
  65. package/src/services/audit/terraform-plan.ts +17 -2
  66. package/src/services/audit/types.test.ts +29 -0
  67. package/src/services/audit/types.ts +30 -4
  68. package/src/services/consumer-cleanup.ts +5 -3
  69. package/src/services/container-service.test.ts +34 -0
  70. package/src/services/container-service.ts +44 -0
  71. package/src/services/deployed-systems.test.ts +101 -0
  72. package/src/services/deployed-systems.ts +43 -11
  73. package/src/services/dns-provider-backfill.ts +30 -0
  74. package/src/services/fleet-checks.test.ts +26 -0
  75. package/src/services/fleet-checks.ts +11 -1
  76. package/src/services/module-deploy.ts +109 -41
  77. package/src/services/provider-arrival.test.ts +241 -0
  78. package/src/services/provider-arrival.ts +213 -0
  79. package/src/services/restore-from-file.ts +4 -0
  80. package/src/services/update/orchestrator.test.ts +2 -0
  81. package/src/templates/generator.test.ts +35 -0
  82. package/src/templates/generator.ts +29 -1
  83. package/src/variables/context.test.ts +63 -0
  84. package/src/variables/context.ts +10 -2
  85. package/src/variables/declarative-derivation.test.ts +47 -8
  86. package/src/variables/declarative-derivation.ts +6 -4
  87. package/src/services/public-web-republish.test.ts +0 -189
  88. package/src/services/public-web-republish.ts +0 -84
@@ -9,18 +9,28 @@
9
9
  * The module ID is read from the manifest at the given path.
10
10
  */
11
11
 
12
- import { cpSync, existsSync, readFileSync, readdirSync } from 'node:fs';
12
+ import {
13
+ cpSync,
14
+ existsSync,
15
+ mkdirSync,
16
+ readFileSync,
17
+ readdirSync,
18
+ renameSync,
19
+ rmSync,
20
+ } from 'node:fs';
13
21
  import { unlink } from 'node:fs/promises';
14
22
  import { tmpdir } from 'node:os';
15
- import { join, resolve } from 'node:path';
23
+ import { join, relative, resolve } from 'node:path';
16
24
  import { eq } from 'drizzle-orm';
17
25
  import { parse as parseYaml } from 'yaml';
18
26
  import { registerModuleCapabilities } from '../../capabilities/registration';
19
27
  import { getDb } from '../../db/client';
20
- import { capabilities, modules } from '../../db/schema';
28
+ import { capabilities, moduleIntegrity, modules } from '../../db/schema';
21
29
  import { ModuleManifestSchema } from '../../manifest/schema';
22
30
  import type { ModuleManifest } from '../../manifest/schema';
31
+ import { computeChecksums } from '../../module/packaging/build';
23
32
  import { cleanupTempDir, extractPackage } from '../../module/packaging/extract';
33
+ import { classifyModulePath } from '../../module/packaging/package-rules';
24
34
  import { RegistryClient } from '../../registry/client';
25
35
  import { askConfirm, withInterviewSession } from '../../services/bus-interview';
26
36
  import { InterviewAbandonedError, InterviewUnansweredError } from '../../services/interview-errors';
@@ -152,6 +162,119 @@ export async function fetchAndUpdate(
152
162
  /**
153
163
  * Upgrade a single module from a source path
154
164
  */
165
+ /**
166
+ * Every file under an installed module that an update is entitled to remove:
167
+ * `package` (the new version decides whether it survives) and `unknown` (no
168
+ * version ever shipped it, so it self-heals a tree an older update polluted).
169
+ *
170
+ * `derived` is neither walked nor removed. It is celilo's or the operator's —
171
+ * `generated/`, the hook runtime closure, `screenshots/`, `cookies.json` — and
172
+ * `generated/` alone carries terraform state and provider binaries.
173
+ */
174
+ /**
175
+ * What an update leaves alone: celilo's own output and the operator's state,
176
+ * never the package's. The set the in-place update skipped, kept verbatim so
177
+ * the staging swap changes only WHEN files move, not WHICH ones.
178
+ */
179
+ const PRESERVED_ENTRIES = new Set(['generated', 'screenshots', 'cookies.json']);
180
+
181
+ function listPrunableFiles(root: string, dir = root): string[] {
182
+ const found: string[] = [];
183
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
184
+ const full = join(dir, entry.name);
185
+ const rel = relative(root, full);
186
+ if (classifyModulePath(rel) === 'derived') continue;
187
+ if (entry.isDirectory()) {
188
+ found.push(...listPrunableFiles(root, full));
189
+ } else if (entry.isFile()) {
190
+ found.push(rel);
191
+ }
192
+ }
193
+ return found;
194
+ }
195
+
196
+ /**
197
+ * Replace an installed module tree atomically (celilo#1008).
198
+ *
199
+ * The install used to be built in place: copy the new files over the old,
200
+ * then prune what the new version dropped. A failure anywhere in that
201
+ * sequence left a module half old and half new, with nothing on disk or in
202
+ * the DB recording which files were which. The comment on the in-place copy
203
+ * already named the hazard — "the copy dies partway, after the prune has
204
+ * already run".
205
+ *
206
+ * So the new tree is assembled in a sibling directory and swapped in with two
207
+ * renames. Everything that can fail — the copy, the prune, a bad source —
208
+ * fails while the live install is still untouched, because nothing has moved
209
+ * yet. If the second rename throws, the first is undone and the original is
210
+ * back. A sibling is deliberate: it is on the same filesystem, so the renames
211
+ * are atomic rather than a copy in disguise.
212
+ *
213
+ * The staging tree is removed on both paths, so a failure leaves no debris
214
+ * beside the module for the next reader to wonder about.
215
+ *
216
+ * `prune` receives the STAGED root, so the surviving-path rule is the one
217
+ * `updateOne` has always applied — only where it runs has changed.
218
+ *
219
+ * Adapted from `replaceInstalledModule` in Jeremy Banka's `f9a57f1b`. The rest
220
+ * of that commit is superseded by celilo#925; this half is not. It uses
221
+ * celilo's `classifyModulePath` rather than that commit's hand-rolled
222
+ * preserve/skip sets, which is what makes the `celilo/types.d.ts` special case
223
+ * it carried unnecessary — the classifier already calls that file `derived`.
224
+ */
225
+ function replaceInstalledModule(
226
+ actualPath: string,
227
+ installedPath: string,
228
+ prune: (stagedRoot: string) => void,
229
+ ): void {
230
+ const suffix = `${process.pid}-${Date.now()}`;
231
+ const stagedPath = `${installedPath}.update-${suffix}`;
232
+ const previousPath = `${installedPath}.previous-${suffix}`;
233
+
234
+ rmSync(stagedPath, { recursive: true, force: true });
235
+ mkdirSync(stagedPath, { recursive: true });
236
+
237
+ try {
238
+ // The incoming version, filtered by the same classifier the in-place copy
239
+ // used, so an update from a directory still cannot plant `e2e/`,
240
+ // `*.test.ts` or `scripts/tsconfig.json` in the install.
241
+ for (const entry of readdirSync(actualPath)) {
242
+ if (PRESERVED_ENTRIES.has(entry)) continue;
243
+ cpSync(join(actualPath, entry), join(stagedPath, entry), {
244
+ recursive: true,
245
+ force: true,
246
+ filter: (from) => classifyModulePath(relative(actualPath, from)) !== 'unknown',
247
+ });
248
+ }
249
+
250
+ // Carry the live tree's own copies across. Same three entries the in-place
251
+ // update left alone, for the same reason: they are celilo's or the
252
+ // operator's, not the package's. Every other `derived` path — the hook
253
+ // runtime closure under `scripts/node_modules`, `celilo/types.d.ts` —
254
+ // still comes from the incoming version exactly as it did before, so a new
255
+ // module version can still deliver new hook dependencies.
256
+ for (const entry of PRESERVED_ENTRIES) {
257
+ const from = join(installedPath, entry);
258
+ if (!existsSync(from)) continue;
259
+ cpSync(from, join(stagedPath, entry), { recursive: true, force: true });
260
+ }
261
+
262
+ prune(stagedPath);
263
+
264
+ renameSync(installedPath, previousPath);
265
+ try {
266
+ renameSync(stagedPath, installedPath);
267
+ } catch (error) {
268
+ renameSync(previousPath, installedPath);
269
+ throw error;
270
+ }
271
+ rmSync(previousPath, { recursive: true, force: true });
272
+ } catch (error) {
273
+ rmSync(stagedPath, { recursive: true, force: true });
274
+ throw error;
275
+ }
276
+ }
277
+
155
278
  export async function updateOne(
156
279
  sourcePath: string,
157
280
  db: ReturnType<typeof getDb>,
@@ -241,6 +364,20 @@ export async function updateOne(
241
364
  };
242
365
  }
243
366
 
367
+ // Pointed at the module's OWN install, `updateOne` used to copy every file
368
+ // onto itself and die inside `cpSync` with `EINVAL: copy_file_range` — an
369
+ // error that names a syscall and not the mistake. Worse since D1: the copy
370
+ // dies partway, after the prune has already run. Refuse by name instead
371
+ // (D11).
372
+ if (resolve(actualPath) === resolve(module.sourcePath)) {
373
+ if (tempDir) await cleanupTempDir(tempDir);
374
+ return {
375
+ status: 'failed',
376
+ moduleId,
377
+ error: `'${sourcePath}' IS the installed copy of ${moduleId}. There is nothing to update it from. Point 'module update' at the module's source tree or a .netapp, or run 'celilo module upgrade ${moduleId}' to take the registry's version.`,
378
+ };
379
+ }
380
+
244
381
  // Old version comes from the DB so we capture whatever was last
245
382
  // recorded (which IS the registry-versioned form, e.g. "1.0.0+5",
246
383
  // for registry-driven installs/upgrades).
@@ -253,22 +390,79 @@ export async function updateOne(
253
390
  log.info(`Upgrading ${moduleId}: ${previousVersion} → ${newVersion}`);
254
391
  }
255
392
 
256
- // Copy new module files, preserving generated output and state
257
393
  const installedPath = module.sourcePath;
258
- const preserveDirs = new Set(['generated', 'screenshots', 'cookies.json']);
259
394
 
260
- const skipDirs = new Set(['.git', 'node_modules', '.next', '.cache']);
261
- const entries = readdirSync(actualPath);
262
- for (const entry of entries) {
263
- if (preserveDirs.has(entry) || skipDirs.has(entry)) continue;
264
- const src = join(actualPath, entry);
265
- const dest = join(installedPath, entry);
266
- cpSync(src, dest, { recursive: true, force: true });
395
+ // The integrity baseline for the version just installed. Prefer the package's
396
+ // own signed `checksums.json`; a directory update has none, so compute over
397
+ // the source we just copied. Read before the temp dir goes away.
398
+ const packagedChecksumsPath = join(actualPath, 'checksums.json');
399
+ const packagedSignaturePath = join(actualPath, 'signature.sig');
400
+ let baselineChecksums: Record<string, string>;
401
+ if (existsSync(packagedChecksumsPath)) {
402
+ const parsed = JSON.parse(readFileSync(packagedChecksumsPath, 'utf-8')) as {
403
+ files?: Record<string, string>;
404
+ };
405
+ baselineChecksums = parsed.files ?? {};
406
+ } else {
407
+ baselineChecksums = (await computeChecksums(actualPath)).files;
267
408
  }
409
+ const packagedSignature = existsSync(packagedSignaturePath)
410
+ ? readFileSync(packagedSignaturePath, 'utf-8').trim()
411
+ : null;
268
412
 
269
413
  // Clean up temp dir if we extracted a .netapp
270
414
  if (tempDir) await cleanupTempDir(tempDir);
271
415
 
416
+ // Remove what the new version dropped. `updateOne` only ever overlaid files,
417
+ // so a hook script deleted in 1.1.0 stayed on the box and stayed runnable —
418
+ // code celilo no longer believes it has installed, which is the same class of
419
+ // lie as celilo#925 pointing the other way. Only `package`-class paths are
420
+ // pruned: `generated/`, the hook runtime closure, `screenshots/` and
421
+ // `cookies.json` are celilo's or the operator's, and survive an update by
422
+ // design.
423
+ //
424
+ // Unchanged except for WHERE it runs. It now prunes the staged tree, before
425
+ // anything is swapped in, so a prune that throws cannot leave the live
426
+ // install short of files (celilo#1008).
427
+ const survivingPaths = new Set(
428
+ Object.keys(baselineChecksums).filter((p) => classifyModulePath(p) === 'package'),
429
+ );
430
+ const pruneDropped = (root: string) => {
431
+ for (const relPath of listPrunableFiles(root)) {
432
+ if (!survivingPaths.has(relPath)) {
433
+ rmSync(join(root, relPath));
434
+ }
435
+ }
436
+ };
437
+
438
+ // Build the new tree beside the install and swap it in with two renames.
439
+ // Everything above this line is reversible by doing nothing; everything
440
+ // below it runs only once the files are really in place.
441
+ replaceInstalledModule(actualPath, installedPath, pruneDropped);
442
+
443
+ // Record it. `updateOne` never touched this table, so the baseline stayed
444
+ // frozen at the module's FIRST import no matter how many times it was
445
+ // updated. That is why `module verify` reported the same violations whether
446
+ // the files were old or the checksums were old, and could not answer the one
447
+ // question that matters (celilo#925).
448
+ db.insert(moduleIntegrity)
449
+ .values({
450
+ moduleId,
451
+ checksums: baselineChecksums,
452
+ version: newVersion,
453
+ signature: packagedSignature,
454
+ })
455
+ .onConflictDoUpdate({
456
+ target: moduleIntegrity.moduleId,
457
+ set: {
458
+ checksums: baselineChecksums,
459
+ version: newVersion,
460
+ signature: packagedSignature,
461
+ updatedAt: new Date(),
462
+ },
463
+ })
464
+ .run();
465
+
272
466
  // Update manifest in database. We persist the display version (with
273
467
  // +N when known) so subsequent `module list` / `module update` calls
274
468
  // see the same version string the registry reported.
@@ -1,53 +1,117 @@
1
1
  import { auditModule } from '../../module/packaging/audit';
2
+ import type { IntegrityViolation } from '../../module/packaging/extract';
3
+ import { hasFlag } from '../parser';
2
4
  import type { CommandResult } from '../types';
3
5
 
4
6
  /**
5
- * Verify module integrity (signature + checksums).
7
+ * Verify module integrity across the three planes a module lives in.
6
8
  *
7
- * Usage: celilo module verify <module-id>
9
+ * Usage: celilo module verify <module-id> [--deep] [--json]
10
+ *
11
+ * installed tree vs baseline did anything change the files since install?
12
+ * generated project vs installed is what we would deploy built from it?
13
+ * host vs generated project is what is running what we generated? (--deep)
14
+ *
15
+ * The first two are local and take milliseconds. `--deep` is one SSH per
16
+ * system, so it is opt-in (openspec/changes/module-integrity-rigor, D3).
8
17
  *
9
18
  * Renamed from `module audit` per CELILO_UPDATE D11 — `audit` is now
10
19
  * reserved for system-level drift detection (`celilo system audit`).
11
20
  * The legacy `module audit` continues to work via a deprecation alias
12
21
  * (see `module-audit.ts`).
13
22
  *
14
- * Returns a CommandResult so the dispatcher controls process exit
15
- * behavior.
23
+ * Returns a CommandResult so the dispatcher controls process exit behavior.
16
24
  */
17
- export async function moduleVerify(args: string[]): Promise<CommandResult> {
25
+ export async function moduleVerify(
26
+ args: string[],
27
+ flags: Record<string, string | boolean> = {},
28
+ ): Promise<CommandResult> {
18
29
  if (args.length === 0) {
19
30
  return {
20
31
  success: false,
21
- error: 'Module ID is required\n\nUsage: celilo module verify <module-id>',
32
+ error: 'Module ID is required\n\nUsage: celilo module verify <module-id> [--deep] [--json]',
22
33
  };
23
34
  }
24
35
 
25
36
  const moduleId = args[0];
37
+ const deep = hasFlag(flags, 'deep');
38
+ const json = hasFlag(flags, 'json');
39
+
40
+ const result = await auditModule(moduleId, undefined, { deep });
26
41
 
27
- const result = await auditModule(moduleId);
42
+ if (json) {
43
+ // The whole point of D9: one call answers "is the installed tree the
44
+ // version celilo thinks it is", with the digests on both sides, so nobody
45
+ // needs a shell on celilo-mgr to settle a celilo#925-shaped question.
46
+ const payload = {
47
+ moduleId,
48
+ deep,
49
+ ok: result.success && !result.error,
50
+ error: result.error ?? null,
51
+ moduleVersion: result.moduleVersion ?? null,
52
+ baselineVersion: result.baselineVersion ?? null,
53
+ violations: result.violations.map((v) => ({
54
+ type: v.type,
55
+ path: v.path,
56
+ message: v.message,
57
+ expectedDigest: v.expectedDigest ?? null,
58
+ actualDigest: v.actualDigest ?? null,
59
+ })),
60
+ hosts: result.hostPlane?.findings ?? [],
61
+ deepOptOut: result.hostPlane?.optedOut ?? null,
62
+ };
63
+ const text = JSON.stringify(payload, null, 2);
64
+ return payload.ok ? { success: true, message: text } : { success: false, error: text };
65
+ }
28
66
 
29
67
  if (result.error) {
30
68
  return { success: false, error: result.error };
31
69
  }
32
70
 
71
+ const lines: string[] = [];
72
+
73
+ // An opt-out nobody sees is a check that quietly disappeared, so it prints
74
+ // whether the module is clean or not (task 6.3).
75
+ if (result.hostPlane?.optedOut) {
76
+ lines.push(
77
+ ` ⚠ [DEEP SKIPPED] This module opts out of the host check: ${result.hostPlane.optedOut.reason}`,
78
+ );
79
+ }
80
+ for (const finding of result.hostPlane?.findings ?? []) {
81
+ if (finding.state === 'converged') {
82
+ lines.push(` ✓ [HOST] ${finding.hostname}: running what celilo generated`);
83
+ } else {
84
+ const tag = finding.state === 'drift' ? 'HOST-DRIFT' : 'HOST-UNMEASURED';
85
+ lines.push(
86
+ ` ${finding.state === 'drift' ? '✗' : '⚠'} [${tag}] ${finding.hostname}: ${finding.detail}`,
87
+ );
88
+ }
89
+ }
90
+
33
91
  if (result.success) {
34
92
  return {
35
93
  success: true,
36
- message: `Module '${moduleId}' passed integrity check\n No violations found.`,
94
+ message: [`Module '${moduleId}' passed integrity check`, ...lines, ' No violations found.']
95
+ .join('\n')
96
+ .trimEnd(),
37
97
  };
38
98
  }
39
99
 
40
- const violationLines = result.violations.map((v) => {
41
- const icon = v.type === 'missing' ? '⚠' : v.type === 'modified' ? '✗' : '!';
42
- return ` ${icon} [${v.type.toUpperCase()}] ${v.message}`;
43
- });
100
+ const ICONS: Record<IntegrityViolation['type'], string> = {
101
+ missing: '⚠',
102
+ modified: '✗',
103
+ extra: '!',
104
+ 'stale-baseline': '⚠',
105
+ 'stale-generated': '✗',
106
+ };
44
107
 
45
108
  return {
46
109
  success: false,
47
110
  error: [
48
111
  `Module '${moduleId}' failed integrity check`,
49
112
  ` Found ${result.violations.length} violation(s):`,
50
- ...violationLines,
113
+ ...result.violations.map((v) => ` ${ICONS[v.type]} [${v.type.toUpperCase()}] ${v.message}`),
114
+ ...lines,
51
115
  ].join('\n'),
52
116
  };
53
117
  }
@@ -0,0 +1,108 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { closeDb } from '../../db/client';
6
+ import { runMigrations } from '../../db/migrate';
7
+ import {
8
+ type ProxmoxCredentials,
9
+ addContainerService,
10
+ getContainerService,
11
+ getServiceCredentials,
12
+ updateVerificationStatus,
13
+ } from '../../services/container-service';
14
+ import { handleServiceSetCredentials } from './service-set-credentials';
15
+
16
+ describe('service set-credentials', () => {
17
+ let testDir: string;
18
+
19
+ beforeEach(async () => {
20
+ testDir = mkdtempSync(join(tmpdir(), 'celilo-service-credentials-test-'));
21
+ process.env.CELILO_DB_PATH = join(testDir, 'test.db');
22
+ process.env.CELILO_MASTER_KEY_PATH = join(testDir, 'master.key');
23
+ writeFileSync(process.env.CELILO_MASTER_KEY_PATH, 'a'.repeat(64), 'utf8');
24
+ await runMigrations(process.env.CELILO_DB_PATH);
25
+ });
26
+
27
+ afterEach(() => {
28
+ closeDb();
29
+ delete process.env.CELILO_DB_PATH;
30
+ delete process.env.CELILO_MASTER_KEY_PATH;
31
+ delete process.env.PROXMOX_API_URL;
32
+ delete process.env.PROXMOX_API_TOKEN_ID;
33
+ delete process.env.PROXMOX_API_TOKEN_SECRET;
34
+ delete process.env.DIGITALOCEAN_API_TOKEN;
35
+ rmSync(testDir, { recursive: true, force: true });
36
+ });
37
+
38
+ it('updates only the Proxmox endpoint and retains the token', async () => {
39
+ const service = await addContainerService({
40
+ name: 'Chubs',
41
+ providerName: 'proxmox',
42
+ zones: ['internal'],
43
+ providerConfig: {},
44
+ apiCredentials: {
45
+ api_url: 'https://192.168.0.50:8006',
46
+ api_token_id: 'root@pam!celilo',
47
+ api_token_secret: 'existing-secret',
48
+ },
49
+ });
50
+ await updateVerificationStatus(service.id, { success: true, message: 'Connected' });
51
+
52
+ const result = await handleServiceSetCredentials(['chubs'], {
53
+ 'api-url': 'https://10.77.20.50:8006',
54
+ });
55
+
56
+ expect(result.success).toBe(true);
57
+ expect(await getServiceCredentials(service.id)).toEqual({
58
+ api_url: 'https://10.77.20.50:8006',
59
+ api_token_id: 'root@pam!celilo',
60
+ api_token_secret: 'existing-secret',
61
+ });
62
+ expect((await getContainerService(service.id))?.verified).toBe(false);
63
+ if (!result.success) throw new Error(result.error);
64
+ expect(result.message).not.toContain('existing-secret');
65
+ });
66
+
67
+ it('accepts the endpoint through the documented environment variable', async () => {
68
+ const service = await addContainerService({
69
+ name: 'Nubs',
70
+ providerName: 'proxmox',
71
+ zones: ['internal'],
72
+ providerConfig: {},
73
+ apiCredentials: {
74
+ api_url: 'https://192.168.0.51:8006',
75
+ api_token_id: 'root@pam!celilo',
76
+ api_token_secret: 'existing-secret',
77
+ },
78
+ });
79
+ process.env.PROXMOX_API_URL = 'https://10.77.20.51:8006';
80
+
81
+ const result = await handleServiceSetCredentials(['nubs']);
82
+
83
+ expect(result.success).toBe(true);
84
+ expect(((await getServiceCredentials(service.id)) as ProxmoxCredentials).api_url).toBe(
85
+ 'https://10.77.20.51:8006',
86
+ );
87
+ });
88
+
89
+ it('refuses a no-op that would only churn encrypted state', async () => {
90
+ await addContainerService({
91
+ name: 'Chubs',
92
+ providerName: 'proxmox',
93
+ zones: ['internal'],
94
+ providerConfig: {},
95
+ apiCredentials: {
96
+ api_url: 'https://192.168.0.50:8006',
97
+ api_token_id: 'root@pam!celilo',
98
+ api_token_secret: 'existing-secret',
99
+ },
100
+ });
101
+
102
+ const result = await handleServiceSetCredentials(['chubs']);
103
+
104
+ expect(result.success).toBe(false);
105
+ if (result.success) throw new Error('Expected set-credentials to reject a no-op');
106
+ expect(result.error).toContain('No credential changes supplied');
107
+ });
108
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Service set-credentials command.
3
+ *
4
+ * Credential values travel only by flag or environment variable (D7). Fields
5
+ * omitted from the invocation retain their existing encrypted values, which
6
+ * lets an operator move a provider endpoint without rotating its API token.
7
+ */
8
+
9
+ import {
10
+ type DigitalOceanCredentials,
11
+ type ProxmoxCredentials,
12
+ getContainerServiceByServiceId,
13
+ getServiceCredentials,
14
+ updateServiceCredentials,
15
+ } from '../../services/container-service';
16
+ import type { CommandResult } from '../types';
17
+
18
+ type Flags = Record<string, boolean | string>;
19
+
20
+ function credentialUpdateValue(flags: Flags, flag: string, envVar: string): string | undefined {
21
+ const flagValue = flags[flag];
22
+ if (flagValue === true) {
23
+ throw new Error(`--${flag} requires a value`);
24
+ }
25
+ if (typeof flagValue === 'string') {
26
+ const value = flagValue.trim();
27
+ if (!value) throw new Error(`--${flag} requires a non-empty value`);
28
+ return value;
29
+ }
30
+
31
+ const envValue = process.env[envVar]?.trim();
32
+ return envValue || undefined;
33
+ }
34
+
35
+ export async function handleServiceSetCredentials(
36
+ args: string[],
37
+ flags: Flags = {},
38
+ ): Promise<CommandResult> {
39
+ const serviceId = args[0];
40
+ if (!serviceId) {
41
+ return {
42
+ success: false,
43
+ error:
44
+ 'Service ID is required\n\nUsage: celilo service set-credentials <service-id> [options]',
45
+ };
46
+ }
47
+
48
+ try {
49
+ const service = await getContainerServiceByServiceId(serviceId);
50
+ if (!service) {
51
+ return {
52
+ success: false,
53
+ error: `Service not found: ${serviceId}\n\nRun 'celilo service list' to see available services.`,
54
+ };
55
+ }
56
+
57
+ const current = await getServiceCredentials(service.id);
58
+ let changed = false;
59
+
60
+ if (service.providerName === 'proxmox') {
61
+ const existing = current as ProxmoxCredentials;
62
+ const apiUrl = credentialUpdateValue(flags, 'api-url', 'PROXMOX_API_URL');
63
+ const apiTokenId = credentialUpdateValue(flags, 'api-token-id', 'PROXMOX_API_TOKEN_ID');
64
+ const apiTokenSecret = credentialUpdateValue(
65
+ flags,
66
+ 'api-token-secret',
67
+ 'PROXMOX_API_TOKEN_SECRET',
68
+ );
69
+ changed = Boolean(apiUrl || apiTokenId || apiTokenSecret);
70
+
71
+ if (changed) {
72
+ await updateServiceCredentials(service.id, {
73
+ api_url: apiUrl ?? existing.api_url,
74
+ api_token_id: apiTokenId ?? existing.api_token_id,
75
+ api_token_secret: apiTokenSecret ?? existing.api_token_secret,
76
+ });
77
+ }
78
+ } else if (service.providerName === 'digitalocean') {
79
+ const existing = current as DigitalOceanCredentials;
80
+ const apiToken = credentialUpdateValue(flags, 'api-token', 'DIGITALOCEAN_API_TOKEN');
81
+ changed = Boolean(apiToken);
82
+
83
+ if (changed) {
84
+ await updateServiceCredentials(service.id, {
85
+ api_token: apiToken ?? existing.api_token,
86
+ });
87
+ }
88
+ } else {
89
+ return {
90
+ success: false,
91
+ error: `Credential updates are not supported for provider: ${service.providerName}`,
92
+ };
93
+ }
94
+
95
+ if (!changed) {
96
+ return {
97
+ success: false,
98
+ error:
99
+ service.providerName === 'proxmox'
100
+ ? 'No credential changes supplied. Pass --api-url, --api-token-id, or --api-token-secret (or set the corresponding PROXMOX_API_* environment variable).'
101
+ : 'No credential changes supplied. Pass --api-token or set $DIGITALOCEAN_API_TOKEN.',
102
+ };
103
+ }
104
+
105
+ return {
106
+ success: true,
107
+ message: `Updated credentials for service '${serviceId}'. Verification status cleared; run: celilo service verify ${serviceId}`,
108
+ };
109
+ } catch (error) {
110
+ return {
111
+ success: false,
112
+ error: `Failed to update service credentials: ${error instanceof Error ? error.message : String(error)}`,
113
+ };
114
+ }
115
+ }
@@ -211,6 +211,13 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
211
211
 
212
212
  const healthResults = await runAllHealthChecks(db, { onProgress });
213
213
 
214
+ // Module integrity. Shallow by default: the installed tree against its
215
+ // baseline, and the generated project against the installed tree. Both are
216
+ // local and take milliseconds. The host plane is one SSH per system and is
217
+ // reached through `module verify --deep`, not from here.
218
+ const { auditModule } = await import('../../module/packaging/audit');
219
+ const integrityResults = await Promise.all(installed.map((m) => auditModule(m.id, db)));
220
+
214
221
  // Services-credentials: try decrypting each container service's
215
222
  // credential envelope. Failures (missing master key, wrong
216
223
  // provider shape, corrupt envelope) become BLOCKED audit findings.
@@ -369,6 +376,14 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
369
376
  fetcher: makeRegistryFetcher(registryClient),
370
377
  },
371
378
  moduleConfigs: { modules: installedConfigs },
379
+ moduleIntegrity: { results: integrityResults },
380
+ detectWithoutConverge: {
381
+ modules: deployedModules.map((m) => ({
382
+ id: m.id,
383
+ state: m.state,
384
+ manifest: m.manifestData as ModuleManifest,
385
+ })),
386
+ },
372
387
  health: { results: healthResults },
373
388
  backups: { modules: installedBackupInfo },
374
389
  abandonedOperations: { records: loadAbandonedOperations(db) },
@@ -419,11 +434,13 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
419
434
 
420
435
  const VERDICT_ICON: Record<string, string> = {
421
436
  READY: '●',
437
+ UNKNOWN: '?',
422
438
  DRIFT: '⚠',
423
439
  BLOCKED: '✗',
424
440
  };
425
441
 
426
442
  const SEVERITY_ICON: Record<string, string> = {
443
+ unmeasured: '?',
427
444
  drift: '⚠',
428
445
  blocked: '✗',
429
446
  };