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