@celilo/cli 1.6.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 (46) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +2 -0
  3. package/MODULE_PRIMITIVES.md +6 -1
  4. package/package.json +3 -3
  5. package/src/capabilities/lookup.ts +39 -29
  6. package/src/capabilities/secret-ref.test.ts +24 -0
  7. package/src/capabilities/secret-validation.ts +50 -0
  8. package/src/capabilities/validation.test.ts +187 -2
  9. package/src/capabilities/validation.ts +53 -1
  10. package/src/cli/commands/alerts-sweep.ts +18 -0
  11. package/src/cli/commands/module-remove.ts +34 -2
  12. package/src/cli/commands/module-update.test.ts +149 -2
  13. package/src/cli/commands/module-update.ts +113 -25
  14. package/src/cli/commands/service-set-credentials.test.ts +108 -0
  15. package/src/cli/commands/service-set-credentials.ts +115 -0
  16. package/src/cli/commands/system-migrate.ts +6 -4
  17. package/src/cli/completion.ts +16 -1
  18. package/src/cli/index.ts +9 -0
  19. package/src/db/client.ts +10 -8
  20. package/src/db/migrate.test.ts +147 -0
  21. package/src/db/migrate.ts +69 -1
  22. package/src/hooks/capability-loader.test.ts +55 -0
  23. package/src/hooks/capability-loader.ts +16 -1
  24. package/src/module/import.ts +20 -5
  25. package/src/policy/module-business-baseline.ts +0 -11
  26. package/src/services/alerting/monitors.ts +54 -2
  27. package/src/services/alerting/sweep-runner.ts +38 -1
  28. package/src/services/consumer-cleanup.ts +5 -3
  29. package/src/services/container-service.test.ts +34 -0
  30. package/src/services/container-service.ts +44 -0
  31. package/src/services/deployed-systems.test.ts +101 -0
  32. package/src/services/deployed-systems.ts +43 -11
  33. package/src/services/dns-provider-backfill.ts +30 -0
  34. package/src/services/fleet-checks.test.ts +26 -0
  35. package/src/services/fleet-checks.ts +11 -1
  36. package/src/services/module-deploy.ts +88 -41
  37. package/src/services/provider-arrival.test.ts +241 -0
  38. package/src/services/provider-arrival.ts +213 -0
  39. package/src/templates/generator.test.ts +35 -0
  40. package/src/templates/generator.ts +29 -1
  41. package/src/variables/context.test.ts +63 -0
  42. package/src/variables/context.ts +10 -2
  43. package/src/variables/declarative-derivation.test.ts +47 -8
  44. package/src/variables/declarative-derivation.ts +6 -4
  45. package/src/services/public-web-republish.test.ts +0 -189
  46. package/src/services/public-web-republish.ts +0 -84
@@ -15,6 +15,7 @@ import { createGaugeLogger } from '../../hooks/logger';
15
15
  import { runNamedHook } from '../../hooks/run-named-hook';
16
16
  import { deallocateForModule } from '../../ipam/auto-allocator';
17
17
  import { type ModuleManifest, ModuleManifestSchema } from '../../manifest/schema';
18
+ import { deleteMonitorForModule } from '../../services/alerting/monitors';
18
19
  import { executeBuildWithProgress } from '../../services/build-stream';
19
20
  import { askConfirm, withInterviewSession } from '../../services/bus-interview';
20
21
  import {
@@ -22,7 +23,11 @@ import {
22
23
  emitUninstallFailed,
23
24
  emitUninstallStarted,
24
25
  } from '../../services/celilo-events';
25
- import { loadConsumerCleanupPlan, runConsumerCleanup } from '../../services/consumer-cleanup';
26
+ import {
27
+ PRE_DEPLOY_STATES,
28
+ loadConsumerCleanupPlan,
29
+ runConsumerCleanup,
30
+ } from '../../services/consumer-cleanup';
26
31
  import { getContainerService, getServiceCredentials } from '../../services/container-service';
27
32
  import { completeOperation, failOperation, startOperation } from '../../services/module-operations';
28
33
  import {
@@ -97,7 +102,7 @@ export async function handleModuleRemove(
97
102
  id: m.id,
98
103
  manifest: parsed.data,
99
104
  paused: m.state === 'PAUSED',
100
- deployed: !(['IMPORTED', 'VALIDATED', 'CONFIGURED'] as string[]).includes(m.state),
105
+ deployed: !PRE_DEPLOY_STATES.has(m.state),
101
106
  });
102
107
  }
103
108
 
@@ -403,6 +408,33 @@ async function performModuleRemove(
403
408
  log.warn(`Failed to unregister event-bus subscriptions: ${msg}`);
404
409
  }
405
410
 
411
+ // Drop the module's health monitor. No cascade can reach it — `monitors.target`
412
+ // holds a module id or an audit check name depending on `kind`, so the column
413
+ // carries no foreign key (celilo#1029). Left behind, the monitor fires
414
+ // `Module not found` and then becomes permanently unschedulable, because its
415
+ // cadence resolves from a module row that no longer exists — so nothing ever
416
+ // runs it again to resolve the alert it just raised.
417
+ //
418
+ // What it was holding is named, not just counted. The alerts are deleted with
419
+ // it (cascade), so a firing check goes silent and the coverage of it goes at
420
+ // the same moment — an operator who is told only `removed monitor` never
421
+ // learns which real failure just stopped being reported.
422
+ try {
423
+ const droppedAlerts = deleteMonitorForModule(db, moduleId);
424
+ if (droppedAlerts) {
425
+ log.info(`Removed health monitor for ${moduleId}`);
426
+ for (const alert of droppedAlerts) {
427
+ log.warn(` dropped live alert ${alert.key}: ${alert.message}`);
428
+ }
429
+ if (droppedAlerts.length > 0) {
430
+ log.warn(` nothing checks ${moduleId} any more, so this will not be reported again.`);
431
+ }
432
+ }
433
+ } catch (error) {
434
+ const msg = error instanceof Error ? error.message : String(error);
435
+ log.warn(`Failed to remove health monitor for ${moduleId}: ${msg}`);
436
+ }
437
+
406
438
  // Delete module (cascade will remove configs, secrets, capabilities, infrastructure records)
407
439
  db.delete(modules).where(eq(modules.id, moduleId)).run();
408
440
 
@@ -5,12 +5,21 @@
5
5
  */
6
6
 
7
7
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
8
- import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { execFileSync } from 'node:child_process';
9
+ import {
10
+ existsSync,
11
+ mkdirSync,
12
+ mkdtempSync,
13
+ readFileSync,
14
+ readdirSync,
15
+ rmSync,
16
+ writeFileSync,
17
+ } from 'node:fs';
9
18
  import { tmpdir } from 'node:os';
10
19
  import { join, relative } from 'node:path';
11
20
  import { eq } from 'drizzle-orm';
12
21
  import { type DbClient, getDb } from '../../db/client';
13
- import { modules } from '../../db/schema';
22
+ import { moduleIntegrity, modules } from '../../db/schema';
14
23
  import { classifyVersionChange, handleModuleUpdate, updateOne } from './module-update';
15
24
 
16
25
  describe('classifyVersionChange', () => {
@@ -409,3 +418,141 @@ description: fixture
409
418
  expect(snapshot(installedDir)).toEqual(before);
410
419
  });
411
420
  });
421
+
422
+ /**
423
+ * celilo#1008. `updateOne` copied the new tree onto the installed one in
424
+ * place, so a copy that died partway left a module half old and half new,
425
+ * with no record anywhere of which files were which. Jeremy Banka's
426
+ * `f9a57f1b` staged into a sibling and swapped with two renames; the rest of
427
+ * that commit is superseded by celilo#925, but the atomicity is not, and this
428
+ * is where it lands.
429
+ *
430
+ * The failure is provoked rather than injected, so nothing test-only reaches
431
+ * production code. The source carries a FIFO, which `cpSync` refuses with
432
+ * ENOTSUP — a stand-in for any mid-copy failure a real update can hit (a full
433
+ * disk, a permission, an I/O error). It is chosen because it fails on the
434
+ * SOURCE, so it fires whether the copy targets the live install or a staging
435
+ * directory, which a destination-side collision would not. `z-` sorts last,
436
+ * so the entries ahead of it copy successfully first — precisely the
437
+ * half-applied state being ruled out.
438
+ */
439
+ describe('updateOne — an update that fails partway leaves the install untouched', () => {
440
+ let tempDir: string;
441
+ let srcDir: string;
442
+ let installedDir: string;
443
+ let db: DbClient;
444
+
445
+ /** Every file under `root`, relative path → bytes, for an exact comparison. */
446
+ function snapshotTree(root: string, dir = root): Record<string, string> {
447
+ const out: Record<string, string> = {};
448
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
449
+ const full = join(dir, entry.name);
450
+ if (entry.isDirectory()) Object.assign(out, snapshotTree(root, full));
451
+ else out[relative(root, full)] = readFileSync(full, 'utf-8');
452
+ }
453
+ return out;
454
+ }
455
+
456
+ beforeEach(() => {
457
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-atomic-'));
458
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
459
+ process.env.CELILO_ORIGINAL_CWD = tempDir;
460
+
461
+ installedDir = join(tempDir, 'installed', 'testmod');
462
+ mkdirSync(join(installedDir, 'scripts'), { recursive: true });
463
+ mkdirSync(join(installedDir, 'generated'), { recursive: true });
464
+ writeFileSync(
465
+ join(installedDir, 'manifest.yml'),
466
+ 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
467
+ );
468
+ writeFileSync(join(installedDir, 'scripts', 'on_install.ts'), 'export const OLD = 1;\n');
469
+ // `derived` — celilo's own output, must survive an update either way.
470
+ writeFileSync(join(installedDir, 'generated', 'terraform.tfstate'), '{"old":true}\n');
471
+
472
+ srcDir = join(tempDir, 'src');
473
+ mkdirSync(join(srcDir, 'scripts'), { recursive: true });
474
+ writeFileSync(
475
+ join(srcDir, 'manifest.yml'),
476
+ 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 2.0.0\ndescription: fixture\n',
477
+ );
478
+ writeFileSync(join(srcDir, 'scripts', 'on_install.ts'), 'export const NEW = 2;\n');
479
+ // cpSync refuses a FIFO with ENOTSUP. Sorts last, so the real files copy first.
480
+ execFileSync('mkfifo', [join(srcDir, 'z-boom')]);
481
+
482
+ db = getDb();
483
+ db.insert(modules)
484
+ .values({
485
+ id: 'testmod',
486
+ name: 'Test Module',
487
+ sourcePath: installedDir,
488
+ version: '1.0.0',
489
+ manifestData: {
490
+ celilo_contract: '1.0',
491
+ id: 'testmod',
492
+ name: 'Test Module',
493
+ version: '1.0.0',
494
+ },
495
+ })
496
+ .run();
497
+ });
498
+
499
+ afterEach(() => {
500
+ rmSync(tempDir, { recursive: true, force: true });
501
+ process.env.CELILO_DB_PATH = undefined;
502
+ process.env.CELILO_ORIGINAL_CWD = undefined;
503
+ });
504
+
505
+ test('a failed update leaves the installed tree byte-identical', async () => {
506
+ const before = snapshotTree(installedDir);
507
+ expect(before['scripts/on_install.ts']).toBe('export const OLD = 1;\n');
508
+
509
+ await expect(updateOne(srcDir, db, {}, { quiet: true })).rejects.toThrow();
510
+
511
+ expect(snapshotTree(installedDir)).toEqual(before);
512
+ });
513
+
514
+ test('a successful update keeps celilo output and drops what the version removed', async () => {
515
+ // Same fixture minus the FIFO, so the update runs to completion.
516
+ rmSync(join(srcDir, 'z-boom'));
517
+ // A file the previous version shipped and the new one does not.
518
+ writeFileSync(join(installedDir, 'scripts', 'gone_in_2.ts'), 'export const OLD = 1;\n');
519
+
520
+ const result = await updateOne(srcDir, db, {}, { quiet: true });
521
+ expect(result.status).toBe('success');
522
+
523
+ // `derived`: celilo's own output survives (task 11.6).
524
+ expect(readFileSync(join(installedDir, 'generated', 'terraform.tfstate'), 'utf-8')).toBe(
525
+ '{"old":true}\n',
526
+ );
527
+ // `package`: the new version's content landed...
528
+ expect(readFileSync(join(installedDir, 'scripts', 'on_install.ts'), 'utf-8')).toBe(
529
+ 'export const NEW = 2;\n',
530
+ );
531
+ // ...and what it dropped is gone, pruned in staging rather than in place.
532
+ expect(existsSync(join(installedDir, 'scripts', 'gone_in_2.ts'))).toBe(false);
533
+ // No staging debris on the success path either.
534
+ expect(readdirSync(join(tempDir, 'installed'))).toEqual(['testmod']);
535
+ });
536
+
537
+ test('a failed update leaves no staging directory behind', async () => {
538
+ await expect(updateOne(srcDir, db, {}, { quiet: true })).rejects.toThrow();
539
+
540
+ const siblings = readdirSync(join(tempDir, 'installed'));
541
+ expect(siblings).toEqual(['testmod']);
542
+ });
543
+
544
+ test('a failed update does not advance the integrity baseline', async () => {
545
+ await expect(updateOne(srcDir, db, {}, { quiet: true })).rejects.toThrow();
546
+
547
+ const row = db
548
+ .select()
549
+ .from(moduleIntegrity)
550
+ .where(eq(moduleIntegrity.moduleId, 'testmod'))
551
+ .get();
552
+ // Nothing recorded at all: the update never reached a state worth claiming.
553
+ expect(row).toBeUndefined();
554
+ // And the module row still names the version actually on disk.
555
+ const mod = db.select().from(modules).where(eq(modules.id, 'testmod')).get();
556
+ expect(mod?.version).toBe('1.0.0');
557
+ });
558
+ });
@@ -9,7 +9,15 @@
9
9
  * The module ID is read from the manifest at the given path.
10
10
  */
11
11
 
12
- import { cpSync, existsSync, readFileSync, readdirSync, rmSync } 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
23
  import { join, relative, resolve } from 'node:path';
@@ -163,6 +171,13 @@ export async function fetchAndUpdate(
163
171
  * `generated/`, the hook runtime closure, `screenshots/`, `cookies.json` — and
164
172
  * `generated/` alone carries terraform state and provider binaries.
165
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
+
166
181
  function listPrunableFiles(root: string, dir = root): string[] {
167
182
  const found: string[] = [];
168
183
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -178,6 +193,88 @@ function listPrunableFiles(root: string, dir = root): string[] {
178
193
  return found;
179
194
  }
180
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
+
181
278
  export async function updateOne(
182
279
  sourcePath: string,
183
280
  db: ReturnType<typeof getDb>,
@@ -293,27 +390,7 @@ export async function updateOne(
293
390
  log.info(`Upgrading ${moduleId}: ${previousVersion} → ${newVersion}`);
294
391
  }
295
392
 
296
- // Copy new module files, preserving generated output and state
297
393
  const installedPath = module.sourcePath;
298
- const preserveDirs = new Set(['generated', 'screenshots', 'cookies.json']);
299
-
300
- // Route the copy through the one classifier, the way `module import` does.
301
- // `updateOne` used to copy the source tree wholesale, so updating from a
302
- // directory planted `e2e/`, `*.test.ts` and `scripts/tsconfig.json` in the
303
- // install — files no package ships and no target runs. Import never did,
304
- // because a directory import goes through the packager; update is the path
305
- // that skipped it.
306
- const entries = readdirSync(actualPath);
307
- for (const entry of entries) {
308
- if (preserveDirs.has(entry)) continue;
309
- const src = join(actualPath, entry);
310
- const dest = join(installedPath, entry);
311
- cpSync(src, dest, {
312
- recursive: true,
313
- force: true,
314
- filter: (from) => classifyModulePath(relative(actualPath, from)) !== 'unknown',
315
- });
316
- }
317
394
 
318
395
  // The integrity baseline for the version just installed. Prefer the package's
319
396
  // own signed `checksums.json`; a directory update has none, so compute over
@@ -343,14 +420,25 @@ export async function updateOne(
343
420
  // pruned: `generated/`, the hook runtime closure, `screenshots/` and
344
421
  // `cookies.json` are celilo's or the operator's, and survive an update by
345
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).
346
427
  const survivingPaths = new Set(
347
428
  Object.keys(baselineChecksums).filter((p) => classifyModulePath(p) === 'package'),
348
429
  );
349
- for (const relPath of listPrunableFiles(installedPath)) {
350
- if (!survivingPaths.has(relPath)) {
351
- rmSync(join(installedPath, relPath));
430
+ const pruneDropped = (root: string) => {
431
+ for (const relPath of listPrunableFiles(root)) {
432
+ if (!survivingPaths.has(relPath)) {
433
+ rmSync(join(root, relPath));
434
+ }
352
435
  }
353
- }
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);
354
442
 
355
443
  // Record it. `updateOne` never touched this table, so the baseline stayed
356
444
  // frozen at the module's FIRST import no matter how many times it was
@@ -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
+ }
@@ -108,9 +108,11 @@ export async function handleSystemMigrate(
108
108
  }
109
109
  }
110
110
 
111
- // getDb() auto-migrates on open; do it inside try so an existing DB that
112
- // predates the drizzle-authoritative change fails with an actionable message
113
- // instead of a raw migrator error.
111
+ // getDb() auto-migrates on open, and repairs a frozen `__drizzle_migrations`
112
+ // watermark itself when the declared schema is already complete. What reaches
113
+ // this catch is the case it will not guess at: schema that is only PARTLY
114
+ // there, where stamping would record migrations that never ran. Caught so it
115
+ // says what to do instead of surfacing a raw migrator error.
114
116
  let db: ReturnType<typeof getDb>;
115
117
  try {
116
118
  db = getDb();
@@ -118,7 +120,7 @@ export async function handleSystemMigrate(
118
120
  const msg = error instanceof Error ? error.message : String(error);
119
121
  return {
120
122
  success: false,
121
- error: `Migration failed: ${msg}\n\nIf this DB predates the drizzle-authoritative migration change, it needs a one-time remediation (stamp \`__drizzle_migrations\` to the latest migration + create any missing table) before the migrator can run cleanlysee ISS-0100.`,
123
+ error: `Migration failed: ${msg}\n\nThis DB's \`__drizzle_migrations\` watermark disagrees with a schema that is only partly applied, which celilo will not resolve on its own. It needs a one-time remediation by hand (create the genuinely missing objects from their migration .sql, then stamp the watermark to the latest migration) runbook in celilo#169.`,
122
124
  };
123
125
  }
124
126