@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.
- package/CELILO_CORE_MODULES.md +2 -1
- package/CELILO_SUBSYSTEMS.md +2 -0
- package/MODULE_PRIMITIVES.md +6 -1
- package/package.json +3 -3
- package/src/capabilities/lookup.ts +39 -29
- package/src/capabilities/secret-ref.test.ts +24 -0
- package/src/capabilities/secret-validation.ts +50 -0
- package/src/capabilities/validation.test.ts +187 -2
- package/src/capabilities/validation.ts +53 -1
- package/src/cli/commands/alerts-sweep.ts +18 -0
- package/src/cli/commands/module-remove.ts +34 -2
- package/src/cli/commands/module-update.test.ts +149 -2
- package/src/cli/commands/module-update.ts +113 -25
- package/src/cli/commands/service-set-credentials.test.ts +108 -0
- package/src/cli/commands/service-set-credentials.ts +115 -0
- package/src/cli/commands/system-migrate.ts +6 -4
- package/src/cli/completion.ts +16 -1
- package/src/cli/index.ts +9 -0
- package/src/db/client.ts +10 -8
- package/src/db/migrate.test.ts +147 -0
- package/src/db/migrate.ts +69 -1
- package/src/hooks/capability-loader.test.ts +55 -0
- package/src/hooks/capability-loader.ts +16 -1
- package/src/module/import.ts +20 -5
- package/src/policy/module-business-baseline.ts +0 -11
- package/src/services/alerting/monitors.ts +54 -2
- package/src/services/alerting/sweep-runner.ts +38 -1
- package/src/services/consumer-cleanup.ts +5 -3
- package/src/services/container-service.test.ts +34 -0
- package/src/services/container-service.ts +44 -0
- package/src/services/deployed-systems.test.ts +101 -0
- package/src/services/deployed-systems.ts +43 -11
- package/src/services/dns-provider-backfill.ts +30 -0
- package/src/services/fleet-checks.test.ts +26 -0
- package/src/services/fleet-checks.ts +11 -1
- package/src/services/module-deploy.ts +88 -41
- package/src/services/provider-arrival.test.ts +241 -0
- package/src/services/provider-arrival.ts +213 -0
- package/src/templates/generator.test.ts +35 -0
- package/src/templates/generator.ts +29 -1
- package/src/variables/context.test.ts +63 -0
- package/src/variables/context.ts +10 -2
- package/src/variables/declarative-derivation.test.ts +47 -8
- package/src/variables/declarative-derivation.ts +6 -4
- package/src/services/public-web-republish.test.ts +0 -189
- 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 {
|
|
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: !
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
112
|
-
//
|
|
113
|
-
//
|
|
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\
|
|
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
|
|