@celilo/cli 4.0.0 → 5.0.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/AGENTS.md +2 -2
- package/CELILO_SUBSYSTEMS.md +1 -0
- package/drizzle/0034_private_routes.sql +41 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +5 -4
- package/src/api/sessions.test.ts +1 -1
- package/src/capabilities/registration.test.ts +11 -11
- package/src/capabilities/secrets.test.ts +18 -18
- package/src/capabilities/validation.test.ts +24 -24
- package/src/cli/cli.test.ts +3 -3
- package/src/cli/command-tree-parser.test.ts +18 -0
- package/src/cli/command-tree-parser.ts +12 -0
- package/src/cli/commands/api.ts +67 -14
- package/src/cli/commands/events.ts +5 -2
- package/src/cli/commands/machine-reclassify.ts +79 -0
- package/src/cli/commands/module-changeset.test.ts +1 -1
- package/src/cli/commands/module-generate.test.ts +2 -2
- package/src/cli/commands/module-import-aspect.test.ts +2 -2
- package/src/cli/commands/module-update.test.ts +14 -14
- package/src/cli/commands/module-upgrade-gate.test.ts +26 -1
- package/src/cli/commands/module-upgrade.test.ts +3 -3
- package/src/cli/commands/module-upgrade.ts +12 -0
- package/src/cli/commands/module-version.test.ts +1 -1
- package/src/cli/commands/system-audit.ts +22 -0
- package/src/cli/commands/system-reboot.ts +126 -0
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +3 -2
- package/src/cli/index.ts +41 -2
- package/src/cli/restore-migration-failure.test.ts +1 -1
- package/src/cli/stdout-pipe-flush.test.ts +35 -0
- package/src/cli/tui/audit-state.ts +4 -0
- package/src/db/private-routes.test.ts +159 -0
- package/src/db/schema.ts +49 -0
- package/src/hooks/broker.ts +31 -0
- package/src/hooks/capability-loader.ts +185 -12
- package/src/hooks/executor.test.ts +13 -13
- package/src/hooks/executor.ts +20 -2
- package/src/hooks/hook-state-dir.test.ts +2 -2
- package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
- package/src/hooks/test-fixtures/store-backed.ts +5 -3
- package/src/hooks/timeout-names-provider.test.ts +87 -0
- package/src/manifest/contracts/index.ts +28 -7
- package/src/manifest/contracts/v2.ts +32 -0
- package/src/manifest/icon-schema.test.ts +1 -1
- package/src/manifest/schema.ts +13 -2
- package/src/manifest/template-validator.test.ts +1 -1
- package/src/manifest/validate-privileged.test.ts +1 -1
- package/src/manifest/validate.test.ts +68 -29
- package/src/manifest/validate.ts +14 -1
- package/src/module/import.test.ts +7 -7
- package/src/module/packaging/build.test.ts +1 -1
- package/src/packaging/nfpm-depends.test.ts +42 -0
- package/src/policy/capability-shape-baseline.ts +7 -1
- package/src/policy/fixture-capability-coverage.test.ts +5 -5
- package/src/policy/module-business-baseline.ts +33 -4
- package/src/services/api-access.test.ts +44 -0
- package/src/services/aspect-approvals.test.ts +2 -2
- package/src/services/aspect-reconcile.test.ts +2 -2
- package/src/services/aspect-runner.test.ts +4 -4
- package/src/services/audit/backups.test.ts +1 -1
- package/src/services/audit/capability-abi.test.ts +1 -1
- package/src/services/audit/index.test.ts +7 -0
- package/src/services/audit/index.ts +15 -0
- package/src/services/audit/machine-interface-zones.test.ts +30 -0
- package/src/services/audit/machine-interface-zones.ts +43 -0
- package/src/services/audit/module-configs.test.ts +1 -1
- package/src/services/audit/reboot-pending.test.ts +63 -0
- package/src/services/audit/reboot-pending.ts +85 -0
- package/src/services/audit/recurrence-gate.test.ts +21 -0
- package/src/services/audit/server-bun-pin.test.ts +44 -0
- package/src/services/audit/server-bun-pin.ts +95 -0
- package/src/services/audit/types.ts +2 -0
- package/src/services/build-bus/hook-dispatch-executor.test.ts +3 -3
- package/src/services/bus-interview-park.test.ts +1 -1
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/capability-compat.test.ts +22 -0
- package/src/services/capability-compat.ts +15 -2
- package/src/services/capability-table-rows.test.ts +20 -2
- package/src/services/celilo-events.test.ts +119 -6
- package/src/services/celilo-events.ts +156 -16
- package/src/services/celilo-mgmt-hooks.test.ts +6 -3
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-preflight.ts +12 -3
- package/src/services/deploy-validation.test.ts +86 -7
- package/src/services/deploy-validation.ts +19 -0
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-registrations.test.ts +1 -1
- package/src/services/fleet-checks.test.ts +2 -2
- package/src/services/fleet-checks.ts +40 -10
- package/src/services/health-runner.test.ts +1 -1
- package/src/services/infrastructure-variable-resolver.test.ts +11 -11
- package/src/services/instance-ops.test.ts +2 -2
- package/src/services/machine-interface-zones.test.ts +73 -0
- package/src/services/machine-interface-zones.ts +101 -0
- package/src/services/module-deploy.dns-repoint.test.ts +1 -1
- package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
- package/src/services/module-deploy.ts +31 -10
- package/src/services/module-pause-quiescence.test.ts +1 -1
- package/src/services/module-pause.test.ts +1 -1
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/module-types-generator.test.ts +1 -1
- package/src/services/module-validator/contract-version.test.ts +3 -3
- package/src/services/network-ensure.test.ts +1 -1
- package/src/services/provider-arrival.test.ts +62 -46
- package/src/services/provider-arrival.ts +41 -10
- package/src/services/proxmox-reconcile.test.ts +2 -2
- package/src/services/remove-guard.test.ts +1 -1
- package/src/services/system-reboot.test.ts +231 -0
- package/src/services/system-reboot.ts +357 -0
- package/src/services/update/dep-graph.test.ts +1 -1
- package/src/services/update/orchestrator.test.ts +165 -1
- package/src/services/update/orchestrator.ts +43 -12
- package/src/services/update/types.ts +10 -1
- package/src/templates/generator.test.ts +15 -4
- package/src/templates/generator.ts +12 -4
- package/src/variables/capability-self-ref.test.ts +2 -2
- package/src/variables/computed/computed-integration.test.ts +1 -1
- package/src/variables/context.ts +14 -1
- package/src/variables/declarative-derivation.test.ts +19 -19
|
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { closeDb } from '@/db/client';
|
|
6
|
+
import { COMMANDS, flattenLeaves, isReadOnlyPath, opOf, readOnlyGrants } from '@celilo/core';
|
|
6
7
|
import {
|
|
7
8
|
getPrincipalByName,
|
|
8
9
|
grantPrincipal,
|
|
@@ -136,3 +137,46 @@ describe('api-access (DB-backed)', () => {
|
|
|
136
137
|
expect(await listPrincipals()).toHaveLength(0);
|
|
137
138
|
});
|
|
138
139
|
});
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The recurrence gate for celilo#1404: every command classified read-only must
|
|
143
|
+
* actually be reachable by the read-only principal.
|
|
144
|
+
*
|
|
145
|
+
* The check is cross-instrument on purpose: `readOnlyGrants` decides what the RO
|
|
146
|
+
* principal is granted, and `grantsAllow` is the SERVER's own matcher deciding
|
|
147
|
+
* what that grant admits. What it catches is the two disagreeing about the shape
|
|
148
|
+
* of an op — which is how a derived list still leaves a read command denied.
|
|
149
|
+
* Verified by mutation (Rule 7.6): rendering the op as `module.journal` instead of
|
|
150
|
+
* `module:journal` turns this red.
|
|
151
|
+
*
|
|
152
|
+
* What it does NOT catch is the classifier itself calling a read a write — both
|
|
153
|
+
* sides read the same derivation, so they move together. That direction is
|
|
154
|
+
* covered in read-only-classifier.test.ts by a hand-written list of ops that must
|
|
155
|
+
* never be granted.
|
|
156
|
+
*/
|
|
157
|
+
describe('read-only principal reachability (celilo#1404)', () => {
|
|
158
|
+
const grants = readOnlyGrants(COMMANDS);
|
|
159
|
+
const readLeaves = flattenLeaves(COMMANDS).filter((l) => isReadOnlyPath(l.path));
|
|
160
|
+
|
|
161
|
+
test('the registry has read-only leaves at all', () => {
|
|
162
|
+
// Otherwise the sweep below is vacuously green.
|
|
163
|
+
expect(readLeaves.length).toBeGreaterThan(20);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('the server admits every read-only command for the RO principal', () => {
|
|
167
|
+
const denied = readLeaves
|
|
168
|
+
.map((l) => ({ path: l.path.join(' '), op: opOf(l.path) }))
|
|
169
|
+
.filter((l) => !grantsAllow(grants, l.op.split(':')[0], l.op.split(':')[1]));
|
|
170
|
+
expect(denied).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('module journal — the op the live server denied with a bare exit 126', () => {
|
|
174
|
+
expect(grantsAllow(grants, 'module', 'journal')).toBe(true);
|
|
175
|
+
expect(grantsAllow(grants, 'module', 'where')).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('and still denies a write op', () => {
|
|
179
|
+
expect(grantsAllow(grants, 'module', 'deploy')).toBe(false);
|
|
180
|
+
expect(grantsAllow(grants, 'api', 'grant')).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -28,7 +28,7 @@ function insertModule(id: string, version: string) {
|
|
|
28
28
|
id,
|
|
29
29
|
name: id,
|
|
30
30
|
version,
|
|
31
|
-
manifestData: { id, name: id, version, celilo_contract: '
|
|
31
|
+
manifestData: { id, name: id, version, celilo_contract: '2.0' },
|
|
32
32
|
sourcePath: `/tmp/${id}`,
|
|
33
33
|
})
|
|
34
34
|
.run();
|
|
@@ -160,7 +160,7 @@ describe('aspect-approvals', () => {
|
|
|
160
160
|
id: 'knot-unbound-internal-v2',
|
|
161
161
|
name: 'knot',
|
|
162
162
|
version: '2.0.0',
|
|
163
|
-
manifestData: { id: 'knot', name: 'knot', version: '2.0.0', celilo_contract: '
|
|
163
|
+
manifestData: { id: 'knot', name: 'knot', version: '2.0.0', celilo_contract: '2.0' },
|
|
164
164
|
sourcePath: '/tmp/knot',
|
|
165
165
|
})
|
|
166
166
|
.run();
|
|
@@ -62,7 +62,7 @@ function seedProvider(opts: {
|
|
|
62
62
|
id: opts.moduleId,
|
|
63
63
|
name: opts.moduleId,
|
|
64
64
|
version: '1.0.0',
|
|
65
|
-
celilo_contract: '
|
|
65
|
+
celilo_contract: '2.0',
|
|
66
66
|
base_module_aspect: aspect,
|
|
67
67
|
},
|
|
68
68
|
sourcePath: `/tmp/${opts.moduleId}`,
|
|
@@ -108,7 +108,7 @@ function seedTargetSystem(opts: {
|
|
|
108
108
|
id: opts.moduleId,
|
|
109
109
|
name: opts.moduleId,
|
|
110
110
|
version: '1.0.0',
|
|
111
|
-
celilo_contract: '
|
|
111
|
+
celilo_contract: '2.0',
|
|
112
112
|
},
|
|
113
113
|
sourcePath: `/tmp/${opts.moduleId}`,
|
|
114
114
|
})
|
|
@@ -85,7 +85,7 @@ function seedLxc(opts: {
|
|
|
85
85
|
id: opts.moduleId,
|
|
86
86
|
name: opts.moduleId,
|
|
87
87
|
version: '1.0.0',
|
|
88
|
-
celilo_contract: '
|
|
88
|
+
celilo_contract: '2.0',
|
|
89
89
|
},
|
|
90
90
|
sourcePath: `/tmp/${opts.moduleId}`,
|
|
91
91
|
})
|
|
@@ -320,7 +320,7 @@ describe('aspect-runner', () => {
|
|
|
320
320
|
id: 'knot-unbound-internal',
|
|
321
321
|
name: 'knot-unbound-internal',
|
|
322
322
|
version: '1.0.0',
|
|
323
|
-
celilo_contract: '
|
|
323
|
+
celilo_contract: '2.0',
|
|
324
324
|
},
|
|
325
325
|
sourcePath: '/tmp/knot',
|
|
326
326
|
})
|
|
@@ -386,7 +386,7 @@ describe('aspect-runner', () => {
|
|
|
386
386
|
|
|
387
387
|
describe('maybeRunAspectForTrigger', () => {
|
|
388
388
|
const manifestWithAspect: ModuleManifest = {
|
|
389
|
-
celilo_contract: '
|
|
389
|
+
celilo_contract: '2.0',
|
|
390
390
|
id: 'knot-unbound-internal',
|
|
391
391
|
name: 'Knot Unbound Internal',
|
|
392
392
|
version: '1.0.0',
|
|
@@ -413,7 +413,7 @@ describe('aspect-runner', () => {
|
|
|
413
413
|
id: opts.id,
|
|
414
414
|
name: opts.id,
|
|
415
415
|
version: opts.version,
|
|
416
|
-
celilo_contract: '
|
|
416
|
+
celilo_contract: '2.0',
|
|
417
417
|
},
|
|
418
418
|
sourcePath: `/tmp/${opts.id}`,
|
|
419
419
|
})
|
|
@@ -20,7 +20,7 @@ function makeModule(
|
|
|
20
20
|
id,
|
|
21
21
|
name: id,
|
|
22
22
|
version: '1.0.0',
|
|
23
|
-
celilo_contract: '
|
|
23
|
+
celilo_contract: '2.0',
|
|
24
24
|
hooks: opts.hasBackupHook ? { on_backup: { script: './backup.ts' } } : {},
|
|
25
25
|
backup: opts.schedule ? { schedule: opts.schedule } : undefined,
|
|
26
26
|
} as unknown as ModuleManifest;
|
|
@@ -20,6 +20,12 @@ const emptyDeps = {
|
|
|
20
20
|
db: fakeDb,
|
|
21
21
|
},
|
|
22
22
|
browserPin: { consumers: [], provisioned: null },
|
|
23
|
+
serverBunPin: {
|
|
24
|
+
pinnedVersion: '1.4.2',
|
|
25
|
+
runningVersion: '1.4.2',
|
|
26
|
+
pinPath: '/etc/celilo/mise.pin.toml',
|
|
27
|
+
},
|
|
28
|
+
rebootPending: { verdict: { pending: false, unmeasured: false, reasons: [] }, marker: null },
|
|
23
29
|
capabilityAbi: { modules: [] },
|
|
24
30
|
terraformPlan: {
|
|
25
31
|
modules: [],
|
|
@@ -42,6 +48,7 @@ const emptyDeps = {
|
|
|
42
48
|
secretsDecryptable: { results: [] },
|
|
43
49
|
servicesReachable: { results: [] },
|
|
44
50
|
machinesReachable: { results: [] },
|
|
51
|
+
machineInterfaceZones: { machines: [] },
|
|
45
52
|
diskSpace: { results: [] },
|
|
46
53
|
transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
|
|
47
54
|
trustedSources: { firewalls: [], unreachableFirewalls: [] },
|
|
@@ -25,13 +25,19 @@ import {
|
|
|
25
25
|
import { type DiskSpaceAuditDeps, auditDiskSpace } from './disk-space';
|
|
26
26
|
import { type HealthAuditDeps, auditHealth } from './health';
|
|
27
27
|
import { type JailExemptionsAuditDeps, auditJailExemptions } from './jail-exemptions';
|
|
28
|
+
import {
|
|
29
|
+
type MachineInterfaceZonesAuditDeps,
|
|
30
|
+
auditMachineInterfaceZones,
|
|
31
|
+
} from './machine-interface-zones';
|
|
28
32
|
import { type MachinesReachableAuditDeps, auditMachinesReachable } from './machines-reachable';
|
|
29
33
|
import { type ModuleConfigsAuditDeps, auditModuleConfigs } from './module-configs';
|
|
30
34
|
import { type ModuleIntegrityAuditDeps, auditModuleIntegrity } from './module-integrity';
|
|
31
35
|
import { type ModuleVersionsAuditDeps, auditModuleVersions } from './module-versions';
|
|
32
36
|
import { type PublicDnsAuditDeps, auditPublicDns } from './public-dns';
|
|
37
|
+
import { type RebootPendingAuditDeps, auditRebootPending } from './reboot-pending';
|
|
33
38
|
import { type SchemaAuditDeps, auditSchema } from './schema';
|
|
34
39
|
import { type SecretsDecryptableAuditDeps, auditSecretsDecryptable } from './secrets-decryptable';
|
|
40
|
+
import { type ServerBunPinAuditDeps, auditServerBunPin } from './server-bun-pin';
|
|
35
41
|
import {
|
|
36
42
|
type ServicesCredentialsAuditDeps,
|
|
37
43
|
auditServicesCredentials,
|
|
@@ -57,6 +63,8 @@ export interface AuditDeps {
|
|
|
57
63
|
schema: SchemaAuditDeps;
|
|
58
64
|
capabilityAbi: CapabilityAbiAuditDeps;
|
|
59
65
|
browserPin: BrowserPinAuditDeps;
|
|
66
|
+
serverBunPin: ServerBunPinAuditDeps;
|
|
67
|
+
rebootPending: RebootPendingAuditDeps;
|
|
60
68
|
terraformPlan: TerraformPlanAuditDeps;
|
|
61
69
|
moduleVersions: ModuleVersionsAuditDeps;
|
|
62
70
|
moduleConfigs: ModuleConfigsAuditDeps;
|
|
@@ -72,6 +80,7 @@ export interface AuditDeps {
|
|
|
72
80
|
secretsDecryptable: SecretsDecryptableAuditDeps;
|
|
73
81
|
servicesReachable: ServicesReachableAuditDeps;
|
|
74
82
|
machinesReachable: MachinesReachableAuditDeps;
|
|
83
|
+
machineInterfaceZones: MachineInterfaceZonesAuditDeps;
|
|
75
84
|
publicDns: PublicDnsAuditDeps;
|
|
76
85
|
diskSpace: DiskSpaceAuditDeps;
|
|
77
86
|
transportReads: TransportReadsAuditDeps;
|
|
@@ -115,6 +124,8 @@ export async function runAudit(
|
|
|
115
124
|
wrap('schema', auditSchema(deps.schema)),
|
|
116
125
|
wrap('capability_abi', auditCapabilityAbi(deps.capabilityAbi)),
|
|
117
126
|
wrap('browser_pin', auditBrowserPin(deps.browserPin)),
|
|
127
|
+
wrap('server_bun_pin', Promise.resolve(auditServerBunPin(deps.serverBunPin))),
|
|
128
|
+
wrap('reboot_pending', Promise.resolve(auditRebootPending(deps.rebootPending))),
|
|
118
129
|
wrap('terraform_plan', auditTerraformPlan(deps.terraformPlan)),
|
|
119
130
|
wrap('module_versions', auditModuleVersions(deps.moduleVersions)),
|
|
120
131
|
wrap('module_configs', auditModuleConfigs(deps.moduleConfigs)),
|
|
@@ -136,6 +147,10 @@ export async function runAudit(
|
|
|
136
147
|
wrap('secrets_decryptable', auditSecretsDecryptable(deps.secretsDecryptable)),
|
|
137
148
|
wrap('services_reachable', auditServicesReachable(deps.servicesReachable)),
|
|
138
149
|
wrap('machines_reachable', auditMachinesReachable(deps.machinesReachable)),
|
|
150
|
+
wrap(
|
|
151
|
+
'interface_classification',
|
|
152
|
+
Promise.resolve(auditMachineInterfaceZones(deps.machineInterfaceZones)),
|
|
153
|
+
),
|
|
139
154
|
wrap('public_dns', auditPublicDns(deps.publicDns)),
|
|
140
155
|
wrap('disk_space', Promise.resolve(auditDiskSpace(deps.diskSpace))),
|
|
141
156
|
wrap('transport_reads', auditTransportReads(deps.transportReads)),
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { auditMachineInterfaceZones } from './machine-interface-zones';
|
|
3
|
+
|
|
4
|
+
describe('auditMachineInterfaceZones', () => {
|
|
5
|
+
test('reports a machine whose stored labels predate the zone model', () => {
|
|
6
|
+
const findings = auditMachineInterfaceZones({
|
|
7
|
+
machines: [
|
|
8
|
+
{
|
|
9
|
+
hostname: 'celilo-mgr',
|
|
10
|
+
changes: [
|
|
11
|
+
{ name: 'eth0', ipAddress: '10.0.120.10', from: 'external', to: 'secure-mgmt' },
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
expect(findings).toHaveLength(1);
|
|
18
|
+
expect(findings[0].severity).toBe('drift');
|
|
19
|
+
expect(findings[0].code).toBe('stale_interface_zone_labels');
|
|
20
|
+
expect(findings[0].subject).toBe('celilo-mgr');
|
|
21
|
+
expect(findings[0].message).toContain('external → secure-mgmt');
|
|
22
|
+
expect(findings[0].remediation).toContain('celilo machine reclassify celilo-mgr --apply');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('reports nothing for a machine whose labels agree', () => {
|
|
26
|
+
expect(auditMachineInterfaceZones({ machines: [{ hostname: 'pi-01', changes: [] }] })).toEqual(
|
|
27
|
+
[],
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stored machine interface zone labels that no longer match the zone model.
|
|
3
|
+
*
|
|
4
|
+
* A label written at `machine add` is never revisited, so a zone-model change
|
|
5
|
+
* leaves every earlier machine describing a topology that no longer exists.
|
|
6
|
+
* Nothing said so until this check: celilo#1382 surfaced only as an upgrade
|
|
7
|
+
* failing with "no non-loopback interface in zone 'secure-mgmt'", on a machine
|
|
8
|
+
* that had one.
|
|
9
|
+
*
|
|
10
|
+
* `drift`, not `blocked`: a stale label breaks a deploy only when something
|
|
11
|
+
* resolves by zone, and the remediation is one command.
|
|
12
|
+
*
|
|
13
|
+
* Pure over a pre-computed diff — see services/machine-interface-zones.ts.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ZoneReclassification } from '../machine-interface-zones';
|
|
17
|
+
import type { DriftFinding } from './types';
|
|
18
|
+
|
|
19
|
+
export interface MachineInterfaceZonesAuditDeps {
|
|
20
|
+
/** One entry per machine, carrying only the labels that disagree. */
|
|
21
|
+
machines: Array<{ hostname: string; changes: ZoneReclassification[] }>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function auditMachineInterfaceZones(deps: MachineInterfaceZonesAuditDeps): DriftFinding[] {
|
|
25
|
+
return deps.machines
|
|
26
|
+
.filter((m) => m.changes.length > 0)
|
|
27
|
+
.map(({ hostname, changes }) => {
|
|
28
|
+
const named = changes
|
|
29
|
+
.map((c) => `${c.name} (${c.ipAddress}): ${c.from} → ${c.to}`)
|
|
30
|
+
.join(', ');
|
|
31
|
+
return {
|
|
32
|
+
category: 'interface_classification',
|
|
33
|
+
severity: 'drift',
|
|
34
|
+
code: 'stale_interface_zone_labels',
|
|
35
|
+
subject: hostname,
|
|
36
|
+
message: `${hostname}: ${changes.length} stored interface zone label(s) predate the current zone model — ${named}`,
|
|
37
|
+
details:
|
|
38
|
+
'Interface zones are decided once, at `machine add`, and never re-checked. When a zone is added or a subnet is declared for one that had none, every machine added earlier keeps the old label. Anything resolving a machine by zone then reports the interface as absent rather than as mislabelled.',
|
|
39
|
+
remediation: `celilo machine reclassify ${hostname} --apply`,
|
|
40
|
+
actionable: true,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -21,7 +21,7 @@ function makeModule(
|
|
|
21
21
|
id,
|
|
22
22
|
name: id,
|
|
23
23
|
version: '1.0.0',
|
|
24
|
-
celilo_contract: '
|
|
24
|
+
celilo_contract: '2.0',
|
|
25
25
|
variables: { owns: variables, imports: [] },
|
|
26
26
|
} as unknown as ModuleManifest;
|
|
27
27
|
// Default state INSTALLED so existing tests continue to expect
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { auditRebootPending } from './reboot-pending';
|
|
3
|
+
|
|
4
|
+
const CLEAN = { pending: false, unmeasured: false, reasons: [] };
|
|
5
|
+
const NOW = 1_700_000_000_000;
|
|
6
|
+
|
|
7
|
+
describe('auditRebootPending', () => {
|
|
8
|
+
it('reports a pending reboot as drift, carrying the reason', () => {
|
|
9
|
+
const [finding] = auditRebootPending({
|
|
10
|
+
verdict: {
|
|
11
|
+
pending: true,
|
|
12
|
+
unmeasured: false,
|
|
13
|
+
reasons: ['the running kernel is 6.8.0-1063-raspi'],
|
|
14
|
+
},
|
|
15
|
+
marker: null,
|
|
16
|
+
now: () => NOW,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
expect(finding.severity).toBe('drift');
|
|
20
|
+
expect(finding.code).toBe('reboot_pending');
|
|
21
|
+
expect(finding.message).toContain('6.8.0-1063-raspi');
|
|
22
|
+
expect(finding.remediation).toContain('celilo system reboot');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('reports an unverified reboot as BLOCKED — a control plane may not have come back', () => {
|
|
26
|
+
const [finding] = auditRebootPending({
|
|
27
|
+
verdict: CLEAN,
|
|
28
|
+
marker: { bootIdBefore: 'a', requestedAt: NOW - 3 * 60 * 60 * 1000, reason: 'kernel' },
|
|
29
|
+
now: () => NOW,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(finding.severity).toBe('blocked');
|
|
33
|
+
expect(finding.code).toBe('reboot_unverified');
|
|
34
|
+
expect(finding.message).toContain('3h ago');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('is silent on a host that is current and has no reboot outstanding', () => {
|
|
38
|
+
expect(auditRebootPending({ verdict: CLEAN, marker: null, now: () => NOW })).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('reports UNMEASURED rather than clean when the host could not be asked', () => {
|
|
42
|
+
const [finding] = auditRebootPending({
|
|
43
|
+
verdict: { pending: false, unmeasured: true, reasons: [] },
|
|
44
|
+
marker: null,
|
|
45
|
+
now: () => NOW,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(finding.severity).toBe('unmeasured');
|
|
49
|
+
expect(finding.actionable).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('still reports the unverified reboot when the host itself is unmeasurable', () => {
|
|
53
|
+
// The two facts are independent: not being able to read the kernel does
|
|
54
|
+
// not make an outstanding reboot request go away.
|
|
55
|
+
const findings = auditRebootPending({
|
|
56
|
+
verdict: { pending: false, unmeasured: true, reasons: [] },
|
|
57
|
+
marker: { bootIdBefore: 'a', requestedAt: NOW, reason: '' },
|
|
58
|
+
now: () => NOW,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(findings.map((f) => f.code)).toEqual(['reboot_unverified', 'reboot_pending_unmeasured']);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending reboot on the management server (celilo#1378).
|
|
3
|
+
*
|
|
4
|
+
* `apt-upgrade` printed needrestart's pending-kernel block and that was the
|
|
5
|
+
* only place the condition was ever visible — so it was visible exactly when
|
|
6
|
+
* an operator happened to run an upgrade, and invisible the rest of the time.
|
|
7
|
+
* celilo-mgr sat on a kernel it was not running for days that way.
|
|
8
|
+
*
|
|
9
|
+
* Two findings, and they are different facts:
|
|
10
|
+
*
|
|
11
|
+
* reboot_pending — the host needs a reboot and has not had one.
|
|
12
|
+
* reboot_unverified — celilo ASKED for a reboot and nothing has confirmed
|
|
13
|
+
* the host came back. That is the more serious of the
|
|
14
|
+
* two: it is the shape of a control plane that went
|
|
15
|
+
* down and did not return, or returned without its
|
|
16
|
+
* dispatcher.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { RebootMarker, RebootPendingVerdict } from '../system-reboot';
|
|
20
|
+
import type { DriftFinding } from './types';
|
|
21
|
+
|
|
22
|
+
export interface RebootPendingAuditDeps {
|
|
23
|
+
verdict: RebootPendingVerdict;
|
|
24
|
+
/** A reboot celilo requested that `verifyReturn` has not yet cleared. */
|
|
25
|
+
marker: RebootMarker | null;
|
|
26
|
+
/** Defaults to `Date.now()`. */
|
|
27
|
+
now?: () => number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const HOUR = 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
export function auditRebootPending(deps: RebootPendingAuditDeps): DriftFinding[] {
|
|
33
|
+
const findings: DriftFinding[] = [];
|
|
34
|
+
const now = (deps.now ?? Date.now)();
|
|
35
|
+
|
|
36
|
+
if (deps.marker !== null) {
|
|
37
|
+
const ageHours = Math.round((now - deps.marker.requestedAt) / HOUR);
|
|
38
|
+
findings.push({
|
|
39
|
+
category: 'reboot_pending',
|
|
40
|
+
severity: 'blocked',
|
|
41
|
+
code: 'reboot_unverified',
|
|
42
|
+
subject: 'system',
|
|
43
|
+
message: `celilo requested a reboot ${ageHours}h ago and nothing has confirmed the host came back.`,
|
|
44
|
+
details:
|
|
45
|
+
'The process that asks for a reboot goes down with the host, so the return is verified by a second call. An uncleared marker means that call never succeeded — either the host did not reboot, or it came back without its dispatcher.',
|
|
46
|
+
remediation:
|
|
47
|
+
'Run `celilo system reboot --verify`. It passes only once the boot id has changed and the dispatcher and CLI answer, and it names which of those is missing.',
|
|
48
|
+
actionable: true,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (deps.verdict.unmeasured) {
|
|
53
|
+
findings.push({
|
|
54
|
+
category: 'reboot_pending',
|
|
55
|
+
severity: 'unmeasured',
|
|
56
|
+
code: 'reboot_pending_unmeasured',
|
|
57
|
+
subject: 'system',
|
|
58
|
+
message:
|
|
59
|
+
'Neither the reboot-required marker nor a kernel pair could be read, so nothing was compared.',
|
|
60
|
+
details:
|
|
61
|
+
'An absent /var/run/reboot-required is not evidence of a current kernel — a host without that path simply never writes one.',
|
|
62
|
+
remediation:
|
|
63
|
+
'Run `celilo system audit` on the management server itself (Debian/Ubuntu), where /var/run/reboot-required and `uname -r` are readable.',
|
|
64
|
+
actionable: false,
|
|
65
|
+
});
|
|
66
|
+
return findings;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (deps.verdict.pending) {
|
|
70
|
+
findings.push({
|
|
71
|
+
category: 'reboot_pending',
|
|
72
|
+
severity: 'drift',
|
|
73
|
+
code: 'reboot_pending',
|
|
74
|
+
subject: 'system',
|
|
75
|
+
message: `The management server needs a reboot: ${deps.verdict.reasons.join('; ')}.`,
|
|
76
|
+
details:
|
|
77
|
+
'A kernel or service the host has installed is not the one it is running. Until it reboots, an upgrade that has been applied on disk has not taken effect.',
|
|
78
|
+
remediation:
|
|
79
|
+
'Reboot through celilo: `celilo system reboot --allow-reboot`. It refuses while a module operation is in flight, and `--verify` confirms the host came back.',
|
|
80
|
+
actionable: true,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return findings;
|
|
85
|
+
}
|
|
@@ -38,7 +38,9 @@ import { auditInterfaceClassification } from './interface-classification';
|
|
|
38
38
|
import { auditModuleIntegrity } from './module-integrity';
|
|
39
39
|
import { auditModuleVersions } from './module-versions';
|
|
40
40
|
import { auditPublicDns } from './public-dns';
|
|
41
|
+
import { auditRebootPending } from './reboot-pending';
|
|
41
42
|
import { auditSchema } from './schema';
|
|
43
|
+
import { auditServerBunPin } from './server-bun-pin';
|
|
42
44
|
import { auditTerraformPlan } from './terraform-plan';
|
|
43
45
|
import { auditTrustedSources } from './trusted-sources';
|
|
44
46
|
import type { DriftCategory, DriftFinding, DriftSeverity } from './types';
|
|
@@ -68,6 +70,25 @@ const GATE: Record<DriftCategory, GateEntry> = {
|
|
|
68
70
|
because:
|
|
69
71
|
'compares capability versions claimed in installed module manifests against the framework registry and deployed providers, all read from celilo own DB and local manifests — the subject is celilo itself and is always reachable',
|
|
70
72
|
},
|
|
73
|
+
reboot_pending: {
|
|
74
|
+
probe: () =>
|
|
75
|
+
auditRebootPending({
|
|
76
|
+
verdict: { pending: false, unmeasured: true, reasons: [] },
|
|
77
|
+
marker: null,
|
|
78
|
+
}),
|
|
79
|
+
because:
|
|
80
|
+
'probed: a host with no /var/run/reboot-required and no readable kernel pair was not measured, and an absent marker is not a current kernel',
|
|
81
|
+
},
|
|
82
|
+
server_bun_pin: {
|
|
83
|
+
probe: () =>
|
|
84
|
+
auditServerBunPin({
|
|
85
|
+
pinnedVersion: null,
|
|
86
|
+
runningVersion: '1.4.2',
|
|
87
|
+
pinPath: '/etc/celilo/mise.pin.toml',
|
|
88
|
+
}),
|
|
89
|
+
because:
|
|
90
|
+
'probed: the deb pin file can be absent (a non-deb install), and then nothing was compared',
|
|
91
|
+
},
|
|
71
92
|
browser_pin: {
|
|
72
93
|
because:
|
|
73
94
|
'compares a bundled client playwright revision against the locally provisioned browser binary, both on this machine — always reachable',
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { auditServerBunPin, readPinnedBunVersion } from './server-bun-pin';
|
|
6
|
+
|
|
7
|
+
const PIN = '/etc/celilo/mise.pin.toml';
|
|
8
|
+
|
|
9
|
+
describe('server bun pin audit', () => {
|
|
10
|
+
test('silent when the running bun is the pinned bun', () => {
|
|
11
|
+
expect(
|
|
12
|
+
auditServerBunPin({ pinnedVersion: '1.4.2', runningVersion: '1.4.2', pinPath: PIN }),
|
|
13
|
+
).toEqual([]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('reports drift naming both versions — the celilo#1379 shape', () => {
|
|
17
|
+
const [finding] = auditServerBunPin({
|
|
18
|
+
pinnedVersion: '1.4.2',
|
|
19
|
+
runningVersion: '1.3.3',
|
|
20
|
+
pinPath: PIN,
|
|
21
|
+
});
|
|
22
|
+
expect(finding?.code).toBe('server_bun_pin_drift');
|
|
23
|
+
expect(finding?.severity).toBe('drift');
|
|
24
|
+
expect(finding?.message).toContain('1.3.3');
|
|
25
|
+
expect(finding?.message).toContain('1.4.2');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('an unreadable pin is unmeasured, never green', () => {
|
|
29
|
+
const [finding] = auditServerBunPin({
|
|
30
|
+
pinnedVersion: null,
|
|
31
|
+
runningVersion: '1.4.2',
|
|
32
|
+
pinPath: PIN,
|
|
33
|
+
});
|
|
34
|
+
expect(finding?.severity).toBe('unmeasured');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('reads the bun line out of the shipped pin file', () => {
|
|
38
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-pin-'));
|
|
39
|
+
const path = join(dir, 'mise.pin.toml');
|
|
40
|
+
writeFileSync(path, '# comment\n[tools]\nbun = "1.4.2"\nterraform = "1.10.0"\n');
|
|
41
|
+
expect(readPinnedBunVersion(path)).toBe('1.4.2');
|
|
42
|
+
expect(readPinnedBunVersion(join(dir, 'absent.toml'))).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server bun-pin drift (celilo#1379).
|
|
3
|
+
*
|
|
4
|
+
* The deb pins the bun celilo was built and tested against, and the CLI runs
|
|
5
|
+
* on whatever bun mise resolves at /var/celilo. Those can disagree: for three
|
|
6
|
+
* months celilo-mgr ran on bun 1.3.3 while the shipped pin said 1.4.2,
|
|
7
|
+
* because the postinst seeded the pin file once and an upgrade never
|
|
8
|
+
* rewrote it. 1.3.3's `Bun.YAML.parse` reads a bare `on:` key as `true`, so
|
|
9
|
+
* production was running a parser no gate had ever exercised.
|
|
10
|
+
*
|
|
11
|
+
* The postinst now rewrites the pin on every configure, which closes the way
|
|
12
|
+
* that drift arose. This check is what makes the drift VISIBLE if it arises
|
|
13
|
+
* some other way — an operator's `mise use`, a hand-edited config, a
|
|
14
|
+
* dispatcher still holding an old runtime.
|
|
15
|
+
*
|
|
16
|
+
* It compares the pin the deb shipped against the bun THIS process is
|
|
17
|
+
* running on. Not `mise which bun`, which answers what mise would resolve
|
|
18
|
+
* now rather than what is actually executing (measured 2026-09-10: mise
|
|
19
|
+
* named 1.4.2 and ran 1.3.3 on the same box).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import type { DriftFinding } from './types';
|
|
24
|
+
|
|
25
|
+
export interface ServerBunPinAuditDeps {
|
|
26
|
+
/** The bun version the installed deb pins, or `null` when unreadable. */
|
|
27
|
+
pinnedVersion: string | null;
|
|
28
|
+
/** The bun this process is running on, or `null` when not under bun. */
|
|
29
|
+
runningVersion: string | null;
|
|
30
|
+
/** Where `pinnedVersion` was read from — named in the finding. */
|
|
31
|
+
pinPath: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function auditServerBunPin(deps: ServerBunPinAuditDeps): DriftFinding[] {
|
|
35
|
+
const { pinnedVersion, runningVersion, pinPath } = deps;
|
|
36
|
+
|
|
37
|
+
if (pinnedVersion === null || runningVersion === null) {
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
category: 'server_bun_pin',
|
|
41
|
+
severity: 'unmeasured',
|
|
42
|
+
code: 'server_bun_pin_unmeasured',
|
|
43
|
+
subject: 'system',
|
|
44
|
+
message:
|
|
45
|
+
pinnedVersion === null
|
|
46
|
+
? `No bun pin could be read from ${pinPath}, so the running bun was compared against nothing.`
|
|
47
|
+
: 'This process is not running under bun, so the running bun version is unknown.',
|
|
48
|
+
remediation:
|
|
49
|
+
pinnedVersion === null
|
|
50
|
+
? `Confirm the celilo deb is installed and ${pinPath} exists (\`sudo apt install --reinstall celilo\`).`
|
|
51
|
+
: 'Run `celilo system audit` from the deb-installed celilo (/usr/local/bin/celilo).',
|
|
52
|
+
actionable: false,
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (pinnedVersion === runningVersion) return [];
|
|
58
|
+
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
category: 'server_bun_pin',
|
|
62
|
+
severity: 'drift',
|
|
63
|
+
code: 'server_bun_pin_drift',
|
|
64
|
+
subject: 'system',
|
|
65
|
+
message: `celilo is running on bun ${runningVersion}, but the installed deb pins bun ${pinnedVersion} (${pinPath}).`,
|
|
66
|
+
details:
|
|
67
|
+
'celilo is tested against the pinned bun only. A different runtime is not a version-number mismatch — bun 1.3.3 and 1.4.2 parse the same YAML differently, which is how a deploy shipped on an untested parser (celilo#1379).',
|
|
68
|
+
remediation:
|
|
69
|
+
'Reconcile the runtime: `sudo apt install --reinstall celilo` rewrites /var/celilo/mise.local.toml from the deb pin, then `celilo events restart-daemon` puts the dispatcher on it.',
|
|
70
|
+
actionable: false,
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The pin the deb ships. Read at audit time — never a copy in celilo's code. */
|
|
76
|
+
export const DEB_PIN_PATH = '/etc/celilo/mise.pin.toml';
|
|
77
|
+
|
|
78
|
+
export function readPinnedBunVersion(path: string = DEB_PIN_PATH): string | null {
|
|
79
|
+
try {
|
|
80
|
+
// One `bun = "x.y.z"` line under [tools]. A TOML parser for one key would
|
|
81
|
+
// be a dependency to read a file this file's own format gate keeps simple.
|
|
82
|
+
const match = readFileSync(path, 'utf-8').match(/^\s*bun\s*=\s*"([^"]+)"/m);
|
|
83
|
+
return match?.[1] ?? null;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function collectServerBunPinDeps(pinPath: string = DEB_PIN_PATH): ServerBunPinAuditDeps {
|
|
90
|
+
return {
|
|
91
|
+
pinnedVersion: readPinnedBunVersion(pinPath),
|
|
92
|
+
runningVersion: process.versions.bun ?? null,
|
|
93
|
+
pinPath,
|
|
94
|
+
};
|
|
95
|
+
}
|