@celilo/cli 1.5.0 → 1.6.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_SUBSYSTEMS.md +16 -2
- package/MODULE_PRIMITIVES.md +19 -6
- package/drizzle/0026_module_integrity_version.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/src/cli/commands/module-audit.ts +5 -2
- package/src/cli/commands/module-update.test.ts +90 -2
- package/src/cli/commands/module-update.ts +112 -6
- package/src/cli/commands/module-verify.ts +77 -13
- package/src/cli/commands/system-audit.ts +17 -0
- package/src/cli/commands/system-doctor.ts +78 -2
- package/src/cli/commands/system-update.ts +33 -3
- package/src/cli/index.ts +2 -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/schema.ts +5 -0
- package/src/manifest/json-schema-roundtrip.test.ts +12 -4
- package/src/manifest/schema.ts +23 -0
- package/src/module/import.ts +36 -35
- 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/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/module-deploy.ts +21 -0
- package/src/services/restore-from-file.ts +4 -0
- package/src/services/update/orchestrator.test.ts +2 -0
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -96,7 +96,8 @@ Module hooks reach a remote box ONLY through these typed primitives
|
|
|
96
96
|
(exported from `packages/capabilities/src/index.ts`).
|
|
97
97
|
|
|
98
98
|
- **`remoteExec`** — the ONE ssh seam (`ssh <user>@<target> <cmd>`); everything else builds on it. `user`/`port`/`identityFile` on the target default to `root`/22/the agent key, so a fleet call is unchanged; an OFF-FLEET account (`external_web`) sets them.
|
|
99
|
-
- **`probe`** — read-only health checks: `systemd` / `http`
|
|
99
|
+
- **`probe`** — read-only health checks over SSH: `systemd` / `command`. It carried a third `http` kind that SSHed in and ran `curl`; the production `ubuntu-22.04-standard` LXC ships none, and a missing binary was indistinguishable from a dead service. Deleted — `probeHttp` replaces it (`openspec/changes/probe-http-from-management/`).
|
|
100
|
+
- **`probeHttp`** — HTTP health check run FROM the management server over `fetch`; nothing on the target. Takes `port` + `path`, never a URL, and builds the address from the target's `ipv4_address` — so a check cannot name `localhost` and silently probe celilo-mgr's own port. Redirects observed, not followed (caddy's 308 is a healthy answer). Typed failure: `'unreachable'` vs `'status'`.
|
|
100
101
|
- **`serviceCtl`** — systemctl start/stop/restart/reload/enable/disable.
|
|
101
102
|
- **`runAppCommand` / `runAppCommandWithSecret`** — escape-hatch on-box command; the secret variant feeds the secret on **stdin** (`$SECRET`), never argv.
|
|
102
103
|
- **`streamBackup` / `streamRestore` / `fetchFile` / `pushFile`** — binary-safe streaming via local shell redirect/pipe.
|
|
@@ -226,13 +227,26 @@ to look, which is how a forgotten pause actually gets found.
|
|
|
226
227
|
|
|
227
228
|
## Packaging, registry & publish
|
|
228
229
|
|
|
229
|
-
- **Module packaging** — `apps/celilo/src/module/packaging/` — `build.ts` (`buildModule`), `extract.ts`, `checksum.ts`, `signature.ts` (`signChecksums`/`verifySignature`), `release-metadata.ts`, `audit.ts
|
|
230
|
+
- **Module packaging** — `apps/celilo/src/module/packaging/` — `build.ts` (`buildModule`), `extract.ts`, `checksum.ts`, `signature.ts` (`signChecksums`/`verifySignature`), `release-metadata.ts`, `audit.ts`, `package-rules.ts`, `generated-plane.ts`, `host-plane.ts` (see **Module integrity** below).
|
|
230
231
|
- **Publish driver** — `scripts/publish.ts` shims to `apps/celilo/src/cli/commands/publish/` (workspace npm packages via `bun publish` + module registry; preflight stale-version/stale-manifest gates).
|
|
231
232
|
- **Registry publish-token admin (append-safe)** — `apps/celilo/src/cli/commands/registry-token.ts` — `celilo registry token add/rm <token>` read-modify-write the celilo-registry `publish_tokens` secret (newline-separated bootstrap/admin list). Avoids the `module secret set` full-overwrite that clobbered other holders. Runs on-mgr where the master key lives; runtime-minted scoped tokens are handled separately by the registry-server.
|
|
232
233
|
- **Contributor identity tokens (idp-issued, per-user)** — `apps/celilo/src/cli/commands/token.ts` — `celilo token obtain|list|revoke` mints/lists/revokes per-user API tokens via the `idp` capability (`create_token`/`list_tokens`/`revoke_token` on authentik). A module contributor authenticates publishes AS THEMSELVES (SECURE_MODULE_PUBLISH.md §6) — no admin/shared token on their machine; the token feeds `celilo author init`. Shown once at mint; the idp stores it hashed, celilo persists nothing. Runs on-mgr where the idp provider lives. Distinct from `registry token add/rm` (raw bootstrap list, different trust model).
|
|
233
234
|
- **Registry token verification (opaque + idp introspection)** — `packages/registry-server/src/auth.ts` (`TokenAuth` — opaque SHA-256 publish tokens, admin/per-package scope) + `packages/registry-server/src/introspection.ts` (`IntrospectionVerifier` — RFC 7662 verify-bridge, SECURE_MODULE_PUBLISH.md §5[D-A]). `authorizePackage()` in `server.ts` tries the opaque set first (unchanged), then, for a token unknown to it, `identify()`s it via the idp introspection endpoint using the registry's confidential OIDC client creds (`OIDC_INTROSPECTION_ENDPOINT`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, provisioned on install — ce-7aa), reading `{active, sub, groups, exp}`. Fails CLOSED on any introspection error; never logs tokens/secrets. Instant revocation: revoke at the idp → next publish sees `active:false` → 401.
|
|
234
235
|
- **Registry module-owner table (hybrid group + owner authz — ce-1ch, D-C)** — `packages/registry-server/src/module-owner-store.ts` (`ModuleOwnerStore` — JSON-persisted `{moduleName, ownerSub, claimedAt, sourceGroup}`, `REGISTRY_OWNERS_FILE`/`dataDir/module-owners.json`). The verified `groups` claim gates *whether* an identity may publish (`REGISTRY_ADMIN_GROUP`→publish/reassign anything; `REGISTRY_PUBLISHER_GROUP`, default `celilo-authors`, configurable→claim+publish owned); the owner table gates *which names*. First-publish-claims: the first verified publisher of an unclaimed name owns it; a *different* publisher is then DENIED (confused-deputy defense — Author-A cannot publish Author-B's module). Admin HTTP endpoints `GET /api/v1/modules/owners`, `GET|POST /api/v1/modules/owners/{name}` (reassign). Operator front door: `celilo registry owner list|show|set` (`apps/celilo/src/cli/commands/registry-owner.ts`), admin token resolved from the local `publish_tokens` bootstrap list.
|
|
235
236
|
|
|
237
|
+
## Module integrity (the four places a module exists)
|
|
238
|
+
|
|
239
|
+
A module exists in four places at once, and celilo verifies the correspondence
|
|
240
|
+
between them. Design: `openspec/changes/module-integrity-rigor/design.md`.
|
|
241
|
+
|
|
242
|
+
- **The one classifier** — `apps/celilo/src/module/packaging/package-rules.ts` — `classifyModulePath(relPath)` returns `package` / `derived` / `unknown` and is the SINGLE answer to what belongs to a module. `build.ts#shouldExclude`, `extract.ts#scanDirectory`, `import.ts#copyModuleFiles`, `module-update.ts` and `audit.ts` all route through it. Composes with, and does not restate, `includeNodeModulesPath` (ISS-0046).
|
|
243
|
+
- **The versioned baseline** — `moduleIntegrity` in `apps/celilo/src/db/schema.ts` — checksums PLUS the `version` they describe. Upserted by `module import` and written by `module-update.ts#updateOne`, so it tracks the installed version instead of freezing at first import. `version` is nullable: NULL means "written before celilo stamped versions", which verify reports rather than papers over.
|
|
244
|
+
- **Plane 1, installed tree vs baseline** — `apps/celilo/src/module/packaging/audit.ts` — `auditModule(moduleId, db, { deep })`. The entry point for `module verify`.
|
|
245
|
+
- **Plane 2, generated project vs installed tree** — `apps/celilo/src/module/packaging/generated-plane.ts` — `compareVerbatimRoleAssets` (pure), `readVerbatimRoleAssets`, `refuseIfGeneratedIsStale`. Only `ansible/roles/<role>/files/**` has a meaningful expected digest; Ansible templates the rest. Wired into `module verify` and into `module-deploy.ts` as a pre-flight that refuses BEFORE contacting any system (celilo#925).
|
|
246
|
+
- **Plane 3, host vs generated project** — `apps/celilo/src/module/packaging/host-plane.ts` — `verifyModuleOnHosts` runs the generated playbook through `executeAnsible(..., { check: true })` and classifies each `PLAY RECAP` line as `converged` / `drift` / `unmeasured`. One SSH per system, so `--deep` only. A module opts out with `verify: { deep: false, reason: … }`; the reason is required and is printed.
|
|
247
|
+
- **Surfaces** — `celilo module verify <id> [--deep] [--json]` (`apps/celilo/src/cli/commands/module-verify.ts`), the `module_integrity` audit category (`apps/celilo/src/services/audit/module-integrity.ts`), and `system doctor`'s fleet section. Same implementation behind all three.
|
|
248
|
+
- **Dead convergence machinery** — `apps/celilo/src/services/audit/detect-without-converge.ts` — the `detect_without_converge` category reports a module declaring a `reconcile_*` / `refresh_registrations` / `reassert_dhcp_dns` hook that no subscription ever fires (celilo#934).
|
|
249
|
+
|
|
236
250
|
## Alerting & notifications
|
|
237
251
|
|
|
238
252
|
Observation and delivery: monitors run checks on a schedule, alerts hold what
|
package/MODULE_PRIMITIVES.md
CHANGED
|
@@ -64,12 +64,25 @@ that, not a primitive. The primitives are for the module's *own* box.
|
|
|
64
64
|
### `probe(target, check, runner?, opts?) → { healthy, detail }`
|
|
65
65
|
Read-only health check. Never mutates. `check` is one of:
|
|
66
66
|
- `{ kind: 'systemd', unit }` — unit is `active`.
|
|
67
|
-
- `{ kind: 'http', url, expectStatus?, headers? }` — curl runs **on the box**, so
|
|
68
|
-
it doesn't depend on Caddy/public DNS. `expectStatus` defaults to 200; pass a
|
|
69
|
-
list (e.g. `[200, 308]`) or `headers: { Host: 'app.example.com' }` for a vhost.
|
|
70
67
|
- `{ kind: 'command', command, expectStdoutIncludes? }`.
|
|
68
|
+
|
|
69
|
+
For HTTP use `probeHttp`, not `probe` — see below.
|
|
70
|
+
```ts
|
|
71
|
+
probe(sys, { kind: 'systemd', unit: 'caddy' }, run);
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### `probeHttp(target, { port, path?, expectStatus?, headers?, timeoutMs? }) → Promise<{ healthy, detail, failure? }>`
|
|
75
|
+
HTTP health check, run **from the management server** over `fetch`. Nothing runs
|
|
76
|
+
on the target — no SSH, no curl (the production LXC image ships none, and a
|
|
77
|
+
missing binary used to be indistinguishable from a dead service). There is
|
|
78
|
+
deliberately **no `url`**: you give a port and a path, and the primitive builds
|
|
79
|
+
the address from the target's `ipv4_address`, so a check cannot name `localhost`
|
|
80
|
+
and end up probing celilo-mgr itself. `expectStatus` defaults to 200; pass a
|
|
81
|
+
list (e.g. `[200, 308]`) or `headers: { Host: 'app.example.com' }` for a vhost.
|
|
82
|
+
Redirects are **observed, not followed**. On failure, `failure` is
|
|
83
|
+
`'unreachable'` (could not connect) or `'status'` (answered with the wrong code).
|
|
71
84
|
```ts
|
|
72
|
-
|
|
85
|
+
await probeHttp(sys, { port: 80, headers: { Host: fqdn }, expectStatus: [200, 308] });
|
|
73
86
|
```
|
|
74
87
|
|
|
75
88
|
### `serviceCtl(target, unit, action, runner?, opts?) → RunResult`
|
|
@@ -97,11 +110,11 @@ applyRenderedConfig({
|
|
|
97
110
|
|
|
98
111
|
### `waitFor(predicate, { attempts?, intervalMs?, onAttempt? }) → Promise<boolean>`
|
|
99
112
|
Poll an async predicate until true or exhausted (default 30 × 5s). A combinator —
|
|
100
|
-
it never touches the remote; wrap a `probe`/`runAppCommand` thunk. Use
|
|
113
|
+
it never touches the remote; wrap a `probe`/`probeHttp`/`runAppCommand` thunk. Use
|
|
101
114
|
`onAttempt` to emit a heartbeat so a long wait doesn't trip the hook idle-timeout.
|
|
102
115
|
Never `sleep`.
|
|
103
116
|
```ts
|
|
104
|
-
await waitFor(() =>
|
|
117
|
+
await waitFor(async () => (await probeHttp(sys, { port: 3000 })).healthy,
|
|
105
118
|
{ onAttempt: (n) => logger.info(`waiting… ${n * 5}s`) });
|
|
106
119
|
```
|
|
107
120
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- The integrity baseline records WHICH VERSION its checksums describe
|
|
2
|
+
-- (openspec/changes/module-integrity-rigor, D1).
|
|
3
|
+
--
|
|
4
|
+
-- Without it `module verify` cannot distinguish "the files are old" from "the
|
|
5
|
+
-- checksums are old", and it reported the same violations either way. The row
|
|
6
|
+
-- was written once, by a plain INSERT on a UNIQUE column inside a try/catch
|
|
7
|
+
-- that only warned, and `module update` never touched the table at all — so a
|
|
8
|
+
-- module imported once and updated ten times still carried its first import's
|
|
9
|
+
-- checksums, and every file that had legitimately changed since read as
|
|
10
|
+
-- [MODIFIED]. That was the whole of the manifest.yml / README.md / scripts/*
|
|
11
|
+
-- block in the fleet's 47- and 25-violation outputs.
|
|
12
|
+
--
|
|
13
|
+
-- NULLABLE on purpose, and this is the Rule 3.4 exception rather than a missing
|
|
14
|
+
-- default: NULL means "written before celilo stamped versions", which is a
|
|
15
|
+
-- fact verify must be able to state. A default of '' or of the module's current
|
|
16
|
+
-- version would fabricate a correspondence nobody measured — exactly the
|
|
17
|
+
-- failure this change exists to remove. Existing rows keep NULL until the next
|
|
18
|
+
-- `module import` or `module update` rewrites them.
|
|
19
|
+
|
|
20
|
+
ALTER TABLE `module_integrity` ADD `version` text;
|
|
@@ -183,6 +183,13 @@
|
|
|
183
183
|
"when": 1784100000000,
|
|
184
184
|
"tag": "0025_port_forward_owner",
|
|
185
185
|
"breakpoints": true
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"idx": 26,
|
|
189
|
+
"version": "6",
|
|
190
|
+
"when": 1784200000000,
|
|
191
|
+
"tag": "0026_module_integrity_version",
|
|
192
|
+
"breakpoints": true
|
|
186
193
|
}
|
|
187
194
|
]
|
|
188
|
-
}
|
|
195
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@aws-sdk/client-s3": "^3.1109.0",
|
|
60
60
|
"@aws-sdk/lib-storage": "^3.1101.0",
|
|
61
|
-
"@celilo/capabilities": "^2.
|
|
61
|
+
"@celilo/capabilities": "^2.3.0",
|
|
62
62
|
"@celilo/cli-display": "^0.2.0",
|
|
63
63
|
"@celilo/core": "^0.9.0",
|
|
64
64
|
"@celilo/event-bus": "^0.6.0",
|
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
import type { CommandResult } from '../types';
|
|
14
14
|
import { moduleVerify } from './module-verify';
|
|
15
15
|
|
|
16
|
-
export async function moduleAudit(
|
|
16
|
+
export async function moduleAudit(
|
|
17
|
+
args: string[],
|
|
18
|
+
flags: Record<string, string | boolean> = {},
|
|
19
|
+
): Promise<CommandResult> {
|
|
17
20
|
process.stderr.write(
|
|
18
21
|
'warning: `celilo module audit` is deprecated; use `celilo module verify` instead.\n',
|
|
19
22
|
);
|
|
20
|
-
return moduleVerify(args);
|
|
23
|
+
return moduleVerify(args, flags);
|
|
21
24
|
}
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
8
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
|
-
import { join } from 'node:path';
|
|
10
|
+
import { join, relative } from 'node:path';
|
|
11
11
|
import { eq } from 'drizzle-orm';
|
|
12
12
|
import { type DbClient, getDb } from '../../db/client';
|
|
13
13
|
import { modules } from '../../db/schema';
|
|
@@ -321,3 +321,91 @@ describe('registry sweep — an unanswered breaking update is not a decline', ()
|
|
|
321
321
|
expect(getDb().select().from(modules).all()[0].version).toBe('1.0.2+9');
|
|
322
322
|
});
|
|
323
323
|
});
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Every file under `root`, keyed by relative path, valued by its exact bytes.
|
|
327
|
+
* Compared whole so a TRUNCATION shows up — an existence check would not.
|
|
328
|
+
*/
|
|
329
|
+
function snapshot(root: string, dir = root): Record<string, string> {
|
|
330
|
+
const files: Record<string, string> = {};
|
|
331
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
332
|
+
const full = join(dir, entry.name);
|
|
333
|
+
if (entry.isDirectory()) Object.assign(files, snapshot(root, full));
|
|
334
|
+
else if (entry.isFile()) files[relative(root, full)] = readFileSync(full, 'utf-8');
|
|
335
|
+
}
|
|
336
|
+
return files;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
describe("updateOne — pointed at the module's own installed path", () => {
|
|
340
|
+
let tempDir: string;
|
|
341
|
+
let installedDir: string;
|
|
342
|
+
let db: DbClient;
|
|
343
|
+
|
|
344
|
+
beforeEach(() => {
|
|
345
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-selfpath-'));
|
|
346
|
+
process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
|
|
347
|
+
process.env.CELILO_ORIGINAL_CWD = tempDir;
|
|
348
|
+
|
|
349
|
+
installedDir = join(tempDir, 'installed', 'selfmod');
|
|
350
|
+
mkdirSync(installedDir, { recursive: true });
|
|
351
|
+
writeFileSync(
|
|
352
|
+
join(installedDir, 'manifest.yml'),
|
|
353
|
+
`celilo_contract: "1.0"
|
|
354
|
+
id: selfmod
|
|
355
|
+
name: Self Module
|
|
356
|
+
version: 1.0.0
|
|
357
|
+
description: fixture
|
|
358
|
+
`,
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
db = getDb();
|
|
362
|
+
db.insert(modules)
|
|
363
|
+
.values({
|
|
364
|
+
id: 'selfmod',
|
|
365
|
+
name: 'Self Module',
|
|
366
|
+
sourcePath: installedDir,
|
|
367
|
+
version: '1.0.0',
|
|
368
|
+
manifestData: {
|
|
369
|
+
celilo_contract: '1.0',
|
|
370
|
+
id: 'selfmod',
|
|
371
|
+
name: 'Self Module',
|
|
372
|
+
version: '1.0.0',
|
|
373
|
+
},
|
|
374
|
+
})
|
|
375
|
+
.run();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
afterEach(() => {
|
|
379
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
380
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
381
|
+
process.env.CELILO_ORIGINAL_CWD = undefined;
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('refuses by name, and writes NOTHING', async () => {
|
|
385
|
+
// A data-loss guard, not a UX nicety. On celilo-mgr on 2026-08-19,
|
|
386
|
+
// `celilo module update /var/celilo/modules/wireguard-manager` threw
|
|
387
|
+
// `EINVAL: copy_file_range` and TRUNCATED
|
|
388
|
+
// `ansible/roles/wireguard-manager/handlers/main.yml` in the module source
|
|
389
|
+
// on the way down. The crash was mid-copy, so the tree was left damaged
|
|
390
|
+
// rather than untouched. It stayed invisible for nine hours because
|
|
391
|
+
// `generated/` still held a good copy; the next generate propagated the
|
|
392
|
+
// truncation, and the deploy after that died with "The requested handler
|
|
393
|
+
// 'Restart wireguard-manager' was not found". App health, ingress and
|
|
394
|
+
// tunnel peers were all green throughout — none of them can see a
|
|
395
|
+
// truncated Ansible handler.
|
|
396
|
+
//
|
|
397
|
+
// So this compares the whole tree byte for byte rather than checking that
|
|
398
|
+
// a file still exists. "It threw" was already true of the behaviour that
|
|
399
|
+
// caused the damage, and an existence check cannot see a truncation.
|
|
400
|
+
const before = snapshot(installedDir);
|
|
401
|
+
|
|
402
|
+
const result = await updateOne(installedDir, db, {}, { quiet: true });
|
|
403
|
+
|
|
404
|
+
expect(result.status).toBe('failed');
|
|
405
|
+
if (result.status !== 'failed') return;
|
|
406
|
+
expect(result.error).toContain('IS the installed copy of selfmod');
|
|
407
|
+
expect(result.error).not.toContain('EINVAL');
|
|
408
|
+
|
|
409
|
+
expect(snapshot(installedDir)).toEqual(before);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
@@ -9,18 +9,20 @@
|
|
|
9
9
|
* The module ID is read from the manifest at the given path.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { cpSync, existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
12
|
+
import { cpSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
13
13
|
import { unlink } from 'node:fs/promises';
|
|
14
14
|
import { tmpdir } from 'node:os';
|
|
15
|
-
import { join, resolve } from 'node:path';
|
|
15
|
+
import { join, relative, resolve } from 'node:path';
|
|
16
16
|
import { eq } from 'drizzle-orm';
|
|
17
17
|
import { parse as parseYaml } from 'yaml';
|
|
18
18
|
import { registerModuleCapabilities } from '../../capabilities/registration';
|
|
19
19
|
import { getDb } from '../../db/client';
|
|
20
|
-
import { capabilities, modules } from '../../db/schema';
|
|
20
|
+
import { capabilities, moduleIntegrity, modules } from '../../db/schema';
|
|
21
21
|
import { ModuleManifestSchema } from '../../manifest/schema';
|
|
22
22
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
23
|
+
import { computeChecksums } from '../../module/packaging/build';
|
|
23
24
|
import { cleanupTempDir, extractPackage } from '../../module/packaging/extract';
|
|
25
|
+
import { classifyModulePath } from '../../module/packaging/package-rules';
|
|
24
26
|
import { RegistryClient } from '../../registry/client';
|
|
25
27
|
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
26
28
|
import { InterviewAbandonedError, InterviewUnansweredError } from '../../services/interview-errors';
|
|
@@ -152,6 +154,30 @@ export async function fetchAndUpdate(
|
|
|
152
154
|
/**
|
|
153
155
|
* Upgrade a single module from a source path
|
|
154
156
|
*/
|
|
157
|
+
/**
|
|
158
|
+
* Every file under an installed module that an update is entitled to remove:
|
|
159
|
+
* `package` (the new version decides whether it survives) and `unknown` (no
|
|
160
|
+
* version ever shipped it, so it self-heals a tree an older update polluted).
|
|
161
|
+
*
|
|
162
|
+
* `derived` is neither walked nor removed. It is celilo's or the operator's —
|
|
163
|
+
* `generated/`, the hook runtime closure, `screenshots/`, `cookies.json` — and
|
|
164
|
+
* `generated/` alone carries terraform state and provider binaries.
|
|
165
|
+
*/
|
|
166
|
+
function listPrunableFiles(root: string, dir = root): string[] {
|
|
167
|
+
const found: string[] = [];
|
|
168
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
169
|
+
const full = join(dir, entry.name);
|
|
170
|
+
const rel = relative(root, full);
|
|
171
|
+
if (classifyModulePath(rel) === 'derived') continue;
|
|
172
|
+
if (entry.isDirectory()) {
|
|
173
|
+
found.push(...listPrunableFiles(root, full));
|
|
174
|
+
} else if (entry.isFile()) {
|
|
175
|
+
found.push(rel);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return found;
|
|
179
|
+
}
|
|
180
|
+
|
|
155
181
|
export async function updateOne(
|
|
156
182
|
sourcePath: string,
|
|
157
183
|
db: ReturnType<typeof getDb>,
|
|
@@ -241,6 +267,20 @@ export async function updateOne(
|
|
|
241
267
|
};
|
|
242
268
|
}
|
|
243
269
|
|
|
270
|
+
// Pointed at the module's OWN install, `updateOne` used to copy every file
|
|
271
|
+
// onto itself and die inside `cpSync` with `EINVAL: copy_file_range` — an
|
|
272
|
+
// error that names a syscall and not the mistake. Worse since D1: the copy
|
|
273
|
+
// dies partway, after the prune has already run. Refuse by name instead
|
|
274
|
+
// (D11).
|
|
275
|
+
if (resolve(actualPath) === resolve(module.sourcePath)) {
|
|
276
|
+
if (tempDir) await cleanupTempDir(tempDir);
|
|
277
|
+
return {
|
|
278
|
+
status: 'failed',
|
|
279
|
+
moduleId,
|
|
280
|
+
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.`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
244
284
|
// Old version comes from the DB so we capture whatever was last
|
|
245
285
|
// recorded (which IS the registry-versioned form, e.g. "1.0.0+5",
|
|
246
286
|
// for registry-driven installs/upgrades).
|
|
@@ -257,18 +297,84 @@ export async function updateOne(
|
|
|
257
297
|
const installedPath = module.sourcePath;
|
|
258
298
|
const preserveDirs = new Set(['generated', 'screenshots', 'cookies.json']);
|
|
259
299
|
|
|
260
|
-
|
|
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.
|
|
261
306
|
const entries = readdirSync(actualPath);
|
|
262
307
|
for (const entry of entries) {
|
|
263
|
-
if (preserveDirs.has(entry)
|
|
308
|
+
if (preserveDirs.has(entry)) continue;
|
|
264
309
|
const src = join(actualPath, entry);
|
|
265
310
|
const dest = join(installedPath, entry);
|
|
266
|
-
cpSync(src, dest, {
|
|
311
|
+
cpSync(src, dest, {
|
|
312
|
+
recursive: true,
|
|
313
|
+
force: true,
|
|
314
|
+
filter: (from) => classifyModulePath(relative(actualPath, from)) !== 'unknown',
|
|
315
|
+
});
|
|
267
316
|
}
|
|
268
317
|
|
|
318
|
+
// The integrity baseline for the version just installed. Prefer the package's
|
|
319
|
+
// own signed `checksums.json`; a directory update has none, so compute over
|
|
320
|
+
// the source we just copied. Read before the temp dir goes away.
|
|
321
|
+
const packagedChecksumsPath = join(actualPath, 'checksums.json');
|
|
322
|
+
const packagedSignaturePath = join(actualPath, 'signature.sig');
|
|
323
|
+
let baselineChecksums: Record<string, string>;
|
|
324
|
+
if (existsSync(packagedChecksumsPath)) {
|
|
325
|
+
const parsed = JSON.parse(readFileSync(packagedChecksumsPath, 'utf-8')) as {
|
|
326
|
+
files?: Record<string, string>;
|
|
327
|
+
};
|
|
328
|
+
baselineChecksums = parsed.files ?? {};
|
|
329
|
+
} else {
|
|
330
|
+
baselineChecksums = (await computeChecksums(actualPath)).files;
|
|
331
|
+
}
|
|
332
|
+
const packagedSignature = existsSync(packagedSignaturePath)
|
|
333
|
+
? readFileSync(packagedSignaturePath, 'utf-8').trim()
|
|
334
|
+
: null;
|
|
335
|
+
|
|
269
336
|
// Clean up temp dir if we extracted a .netapp
|
|
270
337
|
if (tempDir) await cleanupTempDir(tempDir);
|
|
271
338
|
|
|
339
|
+
// Remove what the new version dropped. `updateOne` only ever overlaid files,
|
|
340
|
+
// so a hook script deleted in 1.1.0 stayed on the box and stayed runnable —
|
|
341
|
+
// code celilo no longer believes it has installed, which is the same class of
|
|
342
|
+
// lie as celilo#925 pointing the other way. Only `package`-class paths are
|
|
343
|
+
// pruned: `generated/`, the hook runtime closure, `screenshots/` and
|
|
344
|
+
// `cookies.json` are celilo's or the operator's, and survive an update by
|
|
345
|
+
// design.
|
|
346
|
+
const survivingPaths = new Set(
|
|
347
|
+
Object.keys(baselineChecksums).filter((p) => classifyModulePath(p) === 'package'),
|
|
348
|
+
);
|
|
349
|
+
for (const relPath of listPrunableFiles(installedPath)) {
|
|
350
|
+
if (!survivingPaths.has(relPath)) {
|
|
351
|
+
rmSync(join(installedPath, relPath));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Record it. `updateOne` never touched this table, so the baseline stayed
|
|
356
|
+
// frozen at the module's FIRST import no matter how many times it was
|
|
357
|
+
// updated. That is why `module verify` reported the same violations whether
|
|
358
|
+
// the files were old or the checksums were old, and could not answer the one
|
|
359
|
+
// question that matters (celilo#925).
|
|
360
|
+
db.insert(moduleIntegrity)
|
|
361
|
+
.values({
|
|
362
|
+
moduleId,
|
|
363
|
+
checksums: baselineChecksums,
|
|
364
|
+
version: newVersion,
|
|
365
|
+
signature: packagedSignature,
|
|
366
|
+
})
|
|
367
|
+
.onConflictDoUpdate({
|
|
368
|
+
target: moduleIntegrity.moduleId,
|
|
369
|
+
set: {
|
|
370
|
+
checksums: baselineChecksums,
|
|
371
|
+
version: newVersion,
|
|
372
|
+
signature: packagedSignature,
|
|
373
|
+
updatedAt: new Date(),
|
|
374
|
+
},
|
|
375
|
+
})
|
|
376
|
+
.run();
|
|
377
|
+
|
|
272
378
|
// Update manifest in database. We persist the display version (with
|
|
273
379
|
// +N when known) so subsequent `module list` / `module update` calls
|
|
274
380
|
// 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
|
}
|
|
@@ -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
|
};
|