@celilo/cli 1.6.0 → 1.8.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 +3 -1
- package/CELILO_SUBSYSTEMS.md +7 -1
- package/MODULE_PRIMITIVES.md +6 -1
- package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
- package/drizzle/meta/_journal.json +8 -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 +238 -2
- package/src/capabilities/validation.ts +67 -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/dns-internal-cascade-migration.test.ts +184 -0
- package/src/db/migrate.test.ts +147 -0
- package/src/db/migrate.ts +69 -1
- package/src/db/schema.ts +21 -4
- package/src/hooks/capability-loader.test.ts +55 -0
- package/src/hooks/capability-loader.ts +16 -1
- package/src/manifest/template-validator.test.ts +47 -0
- package/src/manifest/template-validator.ts +18 -1
- package/src/module/import.ts +39 -6
- package/src/policy/capability-shape-baseline.ts +88 -0
- package/src/policy/capability-shape-drift.test.ts +162 -0
- package/src/policy/capability-shape.ts +117 -0
- package/src/policy/dns-aspect-coverage.test.ts +100 -0
- package/src/policy/module-business-baseline.ts +32 -18
- package/src/services/alerting/monitors.ts +54 -2
- package/src/services/alerting/sweep-runner.ts +38 -1
- package/src/services/capability-table-rows.test.ts +191 -0
- package/src/services/capability-table-rows.ts +103 -0
- package/src/services/consumer-cleanup.ts +18 -10
- 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-internal-records.test.ts +72 -1
- 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/module-validator/capability-versions.test.ts +6 -1
- package/src/services/port-forwards.test.ts +6 -2
- package/src/services/port-forwards.ts +0 -11
- package/src/services/provider-arrival.test.ts +241 -0
- package/src/services/provider-arrival.ts +213 -0
- package/src/services/trusted-sources.ts +0 -5
- 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 +85 -12
- package/src/variables/declarative-derivation.test.ts +47 -8
- package/src/variables/declarative-derivation.ts +6 -4
- package/src/variables/lxc-nameserver.test.ts +144 -0
- package/src/services/public-web-republish.test.ts +0 -189
- package/src/services/public-web-republish.ts +0 -84
|
@@ -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
|
|
package/src/cli/completion.ts
CHANGED
|
@@ -325,7 +325,15 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
325
325
|
}
|
|
326
326
|
|
|
327
327
|
if (command === 'service' && currentIndex === 1) {
|
|
328
|
-
const subcommands = [
|
|
328
|
+
const subcommands = [
|
|
329
|
+
'add',
|
|
330
|
+
'list',
|
|
331
|
+
'verify',
|
|
332
|
+
'reconfigure',
|
|
333
|
+
'remove',
|
|
334
|
+
'config',
|
|
335
|
+
'set-credentials',
|
|
336
|
+
];
|
|
329
337
|
return filterSuggestions(subcommands, args[1] || '');
|
|
330
338
|
}
|
|
331
339
|
|
|
@@ -356,6 +364,13 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
356
364
|
return filterSuggestions(serviceIds, args[2] || '');
|
|
357
365
|
}
|
|
358
366
|
|
|
367
|
+
// Service set-credentials - complete with service IDs
|
|
368
|
+
if (command === 'service' && args[1] === 'set-credentials' && currentIndex === 2) {
|
|
369
|
+
const services = await listContainerServices();
|
|
370
|
+
const serviceIds = services.map((s) => s.serviceId);
|
|
371
|
+
return filterSuggestions(serviceIds, args[2] || '');
|
|
372
|
+
}
|
|
373
|
+
|
|
359
374
|
// Service config operations
|
|
360
375
|
if (command === 'service' && args[1] === 'config' && currentIndex === 2) {
|
|
361
376
|
const operations = ['get', 'set'];
|
package/src/cli/index.ts
CHANGED
|
@@ -109,6 +109,7 @@ import { handleServiceConfigSet } from './commands/service-config-set';
|
|
|
109
109
|
import { handleServiceList } from './commands/service-list';
|
|
110
110
|
import { handleServiceReconfigure } from './commands/service-reconfigure';
|
|
111
111
|
import { handleServiceRemove } from './commands/service-remove';
|
|
112
|
+
import { handleServiceSetCredentials } from './commands/service-set-credentials';
|
|
112
113
|
import { handleServiceVerify } from './commands/service-verify';
|
|
113
114
|
import { handleStatus } from './commands/status';
|
|
114
115
|
import { handleSubscribersAdd } from './commands/subscribers-add';
|
|
@@ -721,6 +722,7 @@ Subcommands:
|
|
|
721
722
|
Options:
|
|
722
723
|
--zone <zone> Filter by network zone
|
|
723
724
|
verify <service-id> Re-verify a container service connection
|
|
725
|
+
set-credentials <service-id> Update a provider endpoint or API credential
|
|
724
726
|
reconfigure <service-id> Re-run configuration interview (change template, storage, etc.)
|
|
725
727
|
remove <id> Remove a container service
|
|
726
728
|
Options:
|
|
@@ -751,6 +753,9 @@ Examples:
|
|
|
751
753
|
# Verify a service connection
|
|
752
754
|
celilo service verify proxmox-home-lab
|
|
753
755
|
|
|
756
|
+
# Move a Proxmox endpoint while retaining its existing token
|
|
757
|
+
celilo service set-credentials proxmox-home-lab --api-url https://10.77.20.50:8006
|
|
758
|
+
|
|
754
759
|
# Get service configuration
|
|
755
760
|
celilo service config get proxmox-home-lab
|
|
756
761
|
celilo service config get proxmox-home-lab name
|
|
@@ -1750,6 +1755,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1750
1755
|
return handleServiceVerify(parsed.args, parsed.flags);
|
|
1751
1756
|
}
|
|
1752
1757
|
|
|
1758
|
+
if (parsed.subcommand === 'set-credentials') {
|
|
1759
|
+
return handleServiceSetCredentials(parsed.args, parsed.flags);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1753
1762
|
if (parsed.subcommand === 'reconfigure') {
|
|
1754
1763
|
return handleServiceReconfigure(parsed.args, parsed.flags);
|
|
1755
1764
|
}
|
package/src/db/client.ts
CHANGED
|
@@ -4,8 +4,8 @@ import { dirname, join } from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { BUSY_TIMEOUT_MS, ensureWalMode } from '@celilo/event-bus/wal';
|
|
6
6
|
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
|
7
|
-
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
|
8
7
|
import { getDbPath } from '../config/paths';
|
|
8
|
+
import { runMigrationsOn } from './migrate';
|
|
9
9
|
import * as schema from './schema';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -83,15 +83,17 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
|
|
|
83
83
|
// idempotent: it applies every migration newer than the latest recorded in
|
|
84
84
|
// `__drizzle_migrations` and no-ops once current.
|
|
85
85
|
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
86
|
+
// A DB from the hand-list era has a frozen `__drizzle_migrations` watermark,
|
|
87
|
+
// so drizzle re-runs already-applied migrations and throws (celilo#169).
|
|
88
|
+
// runMigrationsOn repairs that itself where the schema is already complete.
|
|
89
|
+
// It has to happen HERE and not only in `celilo system migrate`, because
|
|
90
|
+
// that command reaches its own repair through getDb() — this line — and so
|
|
91
|
+
// would die before getting there. A PARTIALLY applied schema still throws
|
|
92
|
+
// and still needs a human. `celilo system doctor` (checkSchemaDrift) detects
|
|
93
|
+
// the drift.
|
|
91
94
|
if (!readonly) {
|
|
92
95
|
try {
|
|
93
|
-
|
|
94
|
-
migrate(db, { migrationsFolder });
|
|
96
|
+
runMigrationsOn(db);
|
|
95
97
|
} catch (error) {
|
|
96
98
|
console.error('Failed to run migrations:', error);
|
|
97
99
|
throw error;
|