@celilo/cli 2.2.1 → 3.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/CELILO_CORE_MODULES.md +2 -1
- package/CELILO_SUBSYSTEMS.md +1 -1
- package/README.md +1 -1
- package/drizzle/0032_module_jail_policies.sql +28 -0
- package/drizzle/0033_build_bus_hook_runs.sql +37 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/__integration__/container-services-cli.integration.test.ts +1 -1
- package/src/api/serve.ts +13 -1
- package/src/api/sessions.test.ts +3 -3
- package/src/api-clients/proxmox.ts +10 -7
- package/src/cli/backup-rename.test.ts +3 -3
- package/src/cli/cli.test.ts +1 -1
- package/src/cli/commands/events.test.ts +2 -2
- package/src/cli/commands/firewall-interface-list.test.ts +2 -2
- package/src/cli/commands/machine-list.test.ts +57 -0
- package/src/cli/commands/machine-list.ts +35 -4
- package/src/cli/commands/module-health.test.ts +35 -0
- package/src/cli/commands/module-health.ts +12 -3
- package/src/cli/commands/module-import-registry.test.ts +1 -1
- package/src/cli/commands/module-jail.test.ts +242 -0
- package/src/cli/commands/module-jail.ts +227 -0
- package/src/cli/commands/module-list-jail.test.ts +136 -0
- package/src/cli/commands/module-list.ts +30 -3
- package/src/cli/commands/module-publish.test.ts +28 -6
- package/src/cli/commands/module-publish.ts +14 -12
- package/src/cli/commands/module-update.test.ts +22 -7
- package/src/cli/commands/module-update.ts +73 -18
- package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
- package/src/cli/commands/module-upgrade.test.ts +115 -1
- package/src/cli/commands/module-upgrade.ts +66 -4
- package/src/cli/commands/module-verify.test.ts +1 -1
- package/src/cli/commands/publish/execute.ts +4 -1
- package/src/cli/commands/publish/helpers.ts +4 -3
- package/src/cli/commands/publish/index.ts +23 -1
- package/src/cli/commands/publish/module-registry.test.ts +24 -1
- package/src/cli/commands/publish/module-registry.ts +23 -4
- package/src/cli/commands/publish/plan.test.ts +64 -0
- package/src/cli/commands/publish/plan.ts +52 -19
- package/src/cli/commands/publish/types.ts +20 -0
- package/src/cli/commands/registry-owner.test.ts +1 -1
- package/src/cli/commands/registry-token.test.ts +1 -1
- package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
- package/src/cli/commands/subscribers-install-daemon.ts +107 -0
- package/src/cli/commands/subscribers-serve.test.ts +22 -0
- package/src/cli/commands/subscribers-serve.ts +22 -4
- package/src/cli/commands/system-audit.ts +34 -11
- package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
- package/src/cli/commands/system-doctor.test.ts +76 -10
- package/src/cli/commands/system-doctor.ts +34 -3
- package/src/cli/commands/system-init-deprecation.test.ts +1 -1
- package/src/cli/commands/system-update.ts +16 -6
- package/src/cli/completion.ts +18 -3
- package/src/cli/flag-surface-gate.test.ts +279 -0
- package/src/cli/fuel-gauge.ts +12 -4
- package/src/cli/index.ts +80 -2
- package/src/cli/json-output.test.ts +81 -0
- package/src/cli/parser.test.ts +37 -1
- package/src/cli/restore-command.test.ts +3 -3
- package/src/cli/restore-migration-failure.test.ts +2 -2
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/cli/types.ts +9 -0
- package/src/config/paths.test.ts +19 -19
- package/src/db/client.test.ts +46 -1
- package/src/db/client.ts +26 -0
- package/src/db/schema.ts +63 -0
- package/src/hooks/broker.test.ts +9 -3
- package/src/hooks/capability-loader-firewall.test.ts +9 -1
- package/src/hooks/capability-loader.ts +8 -0
- package/src/hooks/executor.test.ts +91 -14
- package/src/hooks/executor.ts +31 -1
- package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
- package/src/hooks/hook-jail-unreachability.test.ts +27 -20
- package/src/hooks/hook-trespass.test.ts +22 -6
- package/src/hooks/jail.test.ts +105 -17
- package/src/hooks/jail.ts +47 -10
- package/src/hooks/run-named-hook.ts +19 -16
- package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
- package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
- package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
- package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
- package/src/hooks/test-fixtures/silent-hook.ts +0 -1
- package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
- package/src/hooks/test-fixtures/success-hook.ts +4 -4
- package/src/manifest/contracts/v1.ts +32 -14
- package/src/manifest/json-schema-roundtrip.test.ts +1 -1
- package/src/manifest/schema.ts +66 -16
- package/src/manifest/validate.test.ts +47 -0
- package/src/policy/capability-shape-baseline.ts +63 -21
- package/src/policy/capability-shape-drift.test.ts +53 -1
- package/src/policy/capability-shape.test.ts +105 -0
- package/src/policy/capability-shape.ts +283 -2
- package/src/policy/module-script-scan.ts +52 -68
- package/src/policy/no-hand-built-ssh.test.ts +5 -1
- package/src/policy/no-swallowed-refusal.test.ts +14 -14
- package/src/registry/client.test.ts +67 -2
- package/src/registry/client.ts +7 -7
- package/src/secrets/storage.test.ts +70 -4
- package/src/secrets/storage.ts +71 -1
- package/src/services/alerting/keys.test.ts +4 -0
- package/src/services/alerting/keys.ts +12 -2
- package/src/services/audit/health.test.ts +155 -2
- package/src/services/audit/health.ts +76 -2
- package/src/services/audit/index.test.ts +23 -1
- package/src/services/audit/index.ts +8 -2
- package/src/services/audit/interface-classification.test.ts +16 -5
- package/src/services/audit/interface-classification.ts +25 -2
- package/src/services/audit/jail-exemptions.test.ts +42 -0
- package/src/services/audit/jail-exemptions.ts +44 -0
- package/src/services/audit/module-integrity.test.ts +23 -1
- package/src/services/audit/module-integrity.ts +7 -2
- package/src/services/audit/module-versions.ts +5 -1
- package/src/services/audit/public-dns.test.ts +20 -0
- package/src/services/audit/public-dns.ts +7 -2
- package/src/services/audit/recurrence-gate.test.ts +225 -0
- package/src/services/audit/trusted-sources.test.ts +17 -0
- package/src/services/audit/trusted-sources.ts +21 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-create.ts +9 -0
- package/src/services/backup-envelope-roundtrip.test.ts +1 -1
- package/src/services/backup-in-flight-refusal.test.ts +1 -1
- package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +105 -105
- package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
- package/src/services/build-bus/hook-dispatch.test.ts +86 -116
- package/src/services/build-bus/hook-dispatch.ts +99 -121
- package/src/services/build-bus/hook-dispatcher.ts +143 -17
- package/src/services/build-bus/receiver-daemon.test.ts +189 -0
- package/src/services/build-bus/receiver-daemon.ts +355 -0
- package/src/services/build-bus/self-update.ts +156 -0
- package/src/services/bus-ensure-flow.test.ts +1 -1
- package/src/services/bus-interview-park.test.ts +2 -2
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/capability-compat.test.ts +90 -0
- package/src/services/capability-compat.ts +128 -0
- package/src/services/celilo-events.test.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +23 -5
- package/src/services/container-service.test.ts +1 -1
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-preflight.ts +7 -0
- package/src/services/deploy-terraform.ts +38 -1
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/events-daemon.test.ts +57 -0
- package/src/services/events-daemon.ts +76 -0
- package/src/services/firewall-reach.ts +21 -8
- package/src/services/fleet-checks.test.ts +159 -4
- package/src/services/fleet-checks.ts +206 -3
- package/src/services/health-runner.test.ts +87 -2
- package/src/services/health-runner.ts +83 -16
- package/src/services/infrastructure-selector.test.ts +1 -1
- package/src/services/jail-exemptions.test.ts +125 -0
- package/src/services/jail-exemptions.ts +81 -0
- package/src/services/machine-pool.test.ts +1 -1
- package/src/services/module-deploy-prune.test.ts +89 -0
- package/src/services/module-deploy.ts +84 -129
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/module-types-drift.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +13 -2
- package/src/services/network-discovery.test.ts +64 -1
- package/src/services/network-discovery.ts +30 -5
- package/src/services/responder-probe.test.ts +1 -1
- package/src/services/restore-from-file.test.ts +4 -4
- package/src/services/restore-preflight.test.ts +1 -1
- package/src/services/ssh-key-manager.test.ts +2 -2
- package/src/services/system-state-stage.test.ts +2 -2
- package/src/services/terraform-safety.test.ts +83 -0
- package/src/services/terraform-safety.ts +53 -0
- package/src/services/update/orchestrator.test.ts +3 -1
- package/src/test-utils/bus-responder.ts +1 -1
- package/tsconfig.json +2 -13
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo subscribers install-daemon` — write a systemd unit (Linux) or
|
|
3
|
+
* launchd plist (macOS) that runs the build-bus webhook receiver
|
|
4
|
+
* (`celilo subscribers serve`) under supervision. Mirrors
|
|
5
|
+
* `celilo events install-daemon` for the dispatcher: default is a
|
|
6
|
+
* per-user unit; `--system` writes the system-scope unit that survives
|
|
7
|
+
* logout/reboot — the management-plane shape. Writes the file but
|
|
8
|
+
* doesn't touch supervisor state — the operator (or the celilo-mgmt
|
|
9
|
+
* Ansible role) runs the enable/bootstrap steps themselves.
|
|
10
|
+
*
|
|
11
|
+
* The rendered unit runs `subscribers serve --no-dispatch` by default:
|
|
12
|
+
* the standing dispatcher (celilo-events.service) owns dispatch on a
|
|
13
|
+
* management box, and a second dispatcher polling the same bus is the
|
|
14
|
+
* celilo#580/#610 failure. Pass --dispatch for a combined unit on a box
|
|
15
|
+
* that has no events daemon.
|
|
16
|
+
*
|
|
17
|
+
* celilo#1304: nothing installed the receiver anywhere, so celilo-mgr's
|
|
18
|
+
* on_upstream_publish self-update could never fire. This command is the
|
|
19
|
+
* missing installer.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
installReceiverDaemon,
|
|
24
|
+
planReceiverInstall,
|
|
25
|
+
uninstallReceiverDaemon,
|
|
26
|
+
} from '../../services/build-bus/receiver-daemon';
|
|
27
|
+
import type { CommandResult } from '../types';
|
|
28
|
+
import { resolveServeSecret } from './subscribers-serve';
|
|
29
|
+
|
|
30
|
+
function shortenPath(p: string): string {
|
|
31
|
+
return p.startsWith(process.env.HOME ?? '\0') ? p.replace(process.env.HOME ?? '', '~') : p;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function daemonScopeFrom(flags: Record<string, string | boolean>): 'user' | 'system' {
|
|
35
|
+
return flags.system === true ? 'system' : 'user';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function handleSubscribersInstallDaemon(
|
|
39
|
+
_args: string[],
|
|
40
|
+
flags: Record<string, string | boolean>,
|
|
41
|
+
env: Record<string, string | undefined> = process.env,
|
|
42
|
+
): Promise<CommandResult> {
|
|
43
|
+
try {
|
|
44
|
+
const options = {
|
|
45
|
+
celiloPath: typeof flags['celilo-path'] === 'string' ? flags['celilo-path'] : undefined,
|
|
46
|
+
port: flags.port ? Number(flags.port) : undefined,
|
|
47
|
+
// Same resolution as `serve`: the flag wins, CELILO_BUS_SECRET is
|
|
48
|
+
// the documented fallback (the doctor remediation names it), and
|
|
49
|
+
// null means the renderer refuses rather than invent a secret.
|
|
50
|
+
secret: resolveServeSecret(flags, env) ?? undefined,
|
|
51
|
+
scope: daemonScopeFrom(flags),
|
|
52
|
+
dispatch: flags.dispatch === true,
|
|
53
|
+
};
|
|
54
|
+
// --print: render-only, raw unit content on stdout. The celilo-mgmt
|
|
55
|
+
// Ansible role captures this and does the root-owned write itself
|
|
56
|
+
// (the deb wrapper runs celilo as the unprivileged celilo user, so
|
|
57
|
+
// a direct --system write would EACCES on /etc/systemd/system).
|
|
58
|
+
if (flags.print) {
|
|
59
|
+
const plan = planReceiverInstall(options);
|
|
60
|
+
return { success: true, message: plan.unitContent, rawOutput: true, data: plan };
|
|
61
|
+
}
|
|
62
|
+
const result = installReceiverDaemon(options);
|
|
63
|
+
const lines = [
|
|
64
|
+
`Wrote ${result.platform} ${result.scope} unit: ${shortenPath(result.unitPath)}`,
|
|
65
|
+
` celilo: ${shortenPath(result.celiloPath)}`,
|
|
66
|
+
` port: ${result.port}`,
|
|
67
|
+
` dispatch: ${result.dispatch ? 'combined (receiver + hooks)' : 'receiver-only (--no-dispatch)'}`,
|
|
68
|
+
...(result.runAsUser ? [` runs as: ${result.runAsUser}`] : []),
|
|
69
|
+
'',
|
|
70
|
+
'The unit embeds the shared secret the publisher must sign webhooks with —',
|
|
71
|
+
"register this host in the publisher's build-bus-subscribers.json with the",
|
|
72
|
+
'same value (`celilo subscribers add <this-host-url> --secret <same secret>`).',
|
|
73
|
+
'',
|
|
74
|
+
'Next steps (run these yourself — install does not touch supervisor state):',
|
|
75
|
+
...result.nextSteps.map((s) => ` ${s}`),
|
|
76
|
+
];
|
|
77
|
+
return { success: true, message: lines.join('\n'), data: result };
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return {
|
|
80
|
+
success: false,
|
|
81
|
+
error: err instanceof Error ? err.message : String(err),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function handleSubscribersUninstallDaemon(
|
|
87
|
+
_args: string[],
|
|
88
|
+
flags: Record<string, string | boolean>,
|
|
89
|
+
): Promise<CommandResult> {
|
|
90
|
+
try {
|
|
91
|
+
const result = uninstallReceiverDaemon({ scope: daemonScopeFrom(flags) });
|
|
92
|
+
const lines = [
|
|
93
|
+
result.removed
|
|
94
|
+
? `Removed unit: ${shortenPath(result.unitPath)}`
|
|
95
|
+
: `No unit file at ${shortenPath(result.unitPath)} — nothing to remove.`,
|
|
96
|
+
'',
|
|
97
|
+
'Next steps:',
|
|
98
|
+
...result.nextSteps.map((s) => ` ${s}`),
|
|
99
|
+
];
|
|
100
|
+
return { success: true, message: lines.join('\n'), data: result };
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return {
|
|
103
|
+
success: false,
|
|
104
|
+
error: err instanceof Error ? err.message : String(err),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { resolveServeSecret } from './subscribers-serve';
|
|
3
|
+
|
|
4
|
+
describe('resolveServeSecret', () => {
|
|
5
|
+
it('prefers the --secret flag over the environment', () => {
|
|
6
|
+
expect(resolveServeSecret({ secret: 'flag-secret' }, { CELILO_BUS_SECRET: 'env-secret' })).toBe(
|
|
7
|
+
'flag-secret',
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('falls back to CELILO_BUS_SECRET when the flag is absent', () => {
|
|
12
|
+
expect(resolveServeSecret({}, { CELILO_BUS_SECRET: 'env-secret' })).toBe('env-secret');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('ignores an empty env value', () => {
|
|
16
|
+
expect(resolveServeSecret({}, { CELILO_BUS_SECRET: '' })).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns null when neither source has a secret', () => {
|
|
20
|
+
expect(resolveServeSecret({}, {})).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -16,6 +16,24 @@ import { startHookDispatcher } from '../../services/build-bus/hook-dispatcher';
|
|
|
16
16
|
import { startReceiverServer } from '../../services/build-bus/receiver-server';
|
|
17
17
|
import type { CommandResult } from '../types';
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the receiver's shared HMAC secret. The `--secret` flag wins;
|
|
21
|
+
* otherwise `CELILO_BUS_SECRET` from the environment — which is what
|
|
22
|
+
* the supervisor unit (celilo-build-bus-receiver.service / the launchd
|
|
23
|
+
* plist) sets, and what the doctor's build-bus-publishing remediation
|
|
24
|
+
* has always told operators to export. Returns null when neither has a
|
|
25
|
+
* usable value; the caller turns that into the flag-required error.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveServeSecret(
|
|
28
|
+
flags: Record<string, string | boolean>,
|
|
29
|
+
env: Record<string, string | undefined> = process.env,
|
|
30
|
+
): string | null {
|
|
31
|
+
if (typeof flags.secret === 'string' && flags.secret) return flags.secret;
|
|
32
|
+
const fromEnv = env.CELILO_BUS_SECRET;
|
|
33
|
+
if (typeof fromEnv === 'string' && fromEnv) return fromEnv;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
19
37
|
export async function handleSubscribersServe(
|
|
20
38
|
_args: string[],
|
|
21
39
|
flags: Record<string, string | boolean>,
|
|
@@ -27,11 +45,11 @@ export async function handleSubscribersServe(
|
|
|
27
45
|
error: `Invalid --port value: ${flags.port}. Must be 1–65535.`,
|
|
28
46
|
};
|
|
29
47
|
}
|
|
30
|
-
const secret = flags
|
|
31
|
-
if (
|
|
48
|
+
const secret = resolveServeSecret(flags);
|
|
49
|
+
if (!secret) {
|
|
32
50
|
return {
|
|
33
51
|
success: false,
|
|
34
|
-
error: '--secret <shared-hmac-secret> is required.',
|
|
52
|
+
error: '--secret <shared-hmac-secret> is required (or export CELILO_BUS_SECRET).',
|
|
35
53
|
};
|
|
36
54
|
}
|
|
37
55
|
|
|
@@ -47,7 +65,7 @@ export async function handleSubscribersServe(
|
|
|
47
65
|
secret,
|
|
48
66
|
onEvent: dispatcher
|
|
49
67
|
? async (envelope) => {
|
|
50
|
-
// handleEvent returns
|
|
68
|
+
// handleEvent returns UpstreamHookResult[]; the receiver's
|
|
51
69
|
// onEvent contract is void | Promise<void>. Discard the
|
|
52
70
|
// array — the dispatcher logs its own outcomes.
|
|
53
71
|
await dispatcher.handleEvent(envelope.event);
|
|
@@ -45,7 +45,6 @@ import {
|
|
|
45
45
|
type LatestCliVersionFetcher,
|
|
46
46
|
fetchLatestCliVersion,
|
|
47
47
|
} from '../../services/audit/cli-version';
|
|
48
|
-
import type { MachineReachableResult } from '../../services/audit/machines-reachable';
|
|
49
48
|
import type { ModuleVersionFetcher } from '../../services/audit/module-versions';
|
|
50
49
|
import {
|
|
51
50
|
loadPublicDnsEvidence,
|
|
@@ -57,8 +56,10 @@ import type { ServiceCredentialsResult } from '../../services/audit/services-cre
|
|
|
57
56
|
import type { ServiceReachableResult } from '../../services/audit/services-reachable';
|
|
58
57
|
import type { TerraformPlanRunner } from '../../services/audit/terraform-plan';
|
|
59
58
|
import { getServiceCredentials, listContainerServices } from '../../services/container-service';
|
|
59
|
+
import { probeDiskUsage } from '../../services/disk-probe';
|
|
60
60
|
import { collectFirewallReach } from '../../services/firewall-reach';
|
|
61
61
|
import { runAllHealthChecks } from '../../services/health-runner';
|
|
62
|
+
import { collectJailExemptions } from '../../services/jail-exemptions';
|
|
62
63
|
import { probeMachines } from '../../services/machine-probe';
|
|
63
64
|
import { parseStoredConfigValue } from '../../services/module-config';
|
|
64
65
|
import { createPublicDnsProbe, loadPublicDnsProbeSettings } from '../../services/public-dns-probe';
|
|
@@ -153,8 +154,14 @@ function findMigrationsFolderSafe(): string | null {
|
|
|
153
154
|
* indicators (FuelGauge) inside the audit subroutines and routes
|
|
154
155
|
* progress messages to the caller instead — used by the TUI so
|
|
155
156
|
* audit progress appears inside the alt-screen render.
|
|
157
|
+
*
|
|
158
|
+
* `quiet` (when true) silences them outright: the caller is going to
|
|
159
|
+
* emit a JSON document on stdout, and a gauge's frames are not JSON
|
|
160
|
+
* (celilo#1362).
|
|
156
161
|
*/
|
|
157
|
-
async function buildAuditDeps(
|
|
162
|
+
async function buildAuditDeps(
|
|
163
|
+
options: { onProgress?: (msg: string) => void; quiet?: boolean } = {},
|
|
164
|
+
) {
|
|
158
165
|
const db = getDb();
|
|
159
166
|
|
|
160
167
|
const installed = db.select().from(modules).all();
|
|
@@ -209,7 +216,10 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
|
|
|
209
216
|
envVars: terraformEnvByModule.get(m.id) ?? {},
|
|
210
217
|
}));
|
|
211
218
|
|
|
212
|
-
const healthResults = await runAllHealthChecks(db, {
|
|
219
|
+
const healthResults = await runAllHealthChecks(db, {
|
|
220
|
+
onProgress: options.onProgress,
|
|
221
|
+
quiet: options.quiet,
|
|
222
|
+
});
|
|
213
223
|
|
|
214
224
|
// Module integrity. Shallow by default: the installed tree against its
|
|
215
225
|
// baseline, and the generated project against the installed tree. Both are
|
|
@@ -341,11 +351,22 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
|
|
|
341
351
|
}),
|
|
342
352
|
);
|
|
343
353
|
|
|
344
|
-
// Machines-reachable
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
|
|
354
|
+
// Machines-reachable and disk-space both SSH every system, so they run
|
|
355
|
+
// concurrently here rather than paying each other's latency. The machine
|
|
356
|
+
// probe is shared with the monitor sweep (services/machine-probe.ts) —
|
|
357
|
+
// this block used to be a second copy of the same SSH logic, and both
|
|
358
|
+
// copies reported the local management box as unreachable because celilo
|
|
359
|
+
// does not hold an SSH key for itself.
|
|
360
|
+
//
|
|
361
|
+
// The disk probe measures every system's root filesystem — the machine
|
|
362
|
+
// pool AND every provisioned instance, the systems celilo#1133 filled —
|
|
363
|
+
// in the same pre-computed measurement shape the disk_space monitor
|
|
364
|
+
// sweep consumes, so an audit finding and an alert page carry the same
|
|
365
|
+
// numbers.
|
|
366
|
+
const [machineReachableResults, diskUsageResults] = await Promise.all([
|
|
367
|
+
probeMachines(),
|
|
368
|
+
probeDiskUsage(db),
|
|
369
|
+
]);
|
|
349
370
|
|
|
350
371
|
const migrationsFolder = findMigrationsFolderSafe();
|
|
351
372
|
|
|
@@ -384,6 +405,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
|
|
|
384
405
|
manifest: m.manifestData as ModuleManifest,
|
|
385
406
|
})),
|
|
386
407
|
},
|
|
408
|
+
jailExemptions: { exemptions: collectJailExemptions(db) },
|
|
387
409
|
health: { results: healthResults },
|
|
388
410
|
backups: { modules: installedBackupInfo },
|
|
389
411
|
abandonedOperations: { records: loadAbandonedOperations(db) },
|
|
@@ -417,6 +439,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
|
|
|
417
439
|
probe: createPublicDnsProbe(loadPublicDnsProbeSettings(db)),
|
|
418
440
|
evidence: loadPublicDnsEvidence(db),
|
|
419
441
|
},
|
|
442
|
+
diskSpace: { results: diskUsageResults },
|
|
420
443
|
// Reads the record the poller already writes — this check never performs a
|
|
421
444
|
// read of its own. One that did would drain the queue and eat the
|
|
422
445
|
// acknowledgement it exists to protect (#541).
|
|
@@ -428,7 +451,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
|
|
|
428
451
|
// still catching a transport that has genuinely stopped being readable.
|
|
429
452
|
staleAfterMs: 30 * 60_000,
|
|
430
453
|
},
|
|
431
|
-
trustedSources:
|
|
454
|
+
trustedSources: collectFirewallReach(db),
|
|
432
455
|
};
|
|
433
456
|
}
|
|
434
457
|
|
|
@@ -528,7 +551,7 @@ export async function handleSystemAudit(
|
|
|
528
551
|
// and so the spinner caption keeps changing during the
|
|
529
552
|
// parallel-audit phase (which doesn't emit its own).
|
|
530
553
|
onProgress('Reading system state…');
|
|
531
|
-
const deps = await buildAuditDeps(onProgress);
|
|
554
|
+
const deps = await buildAuditDeps({ onProgress });
|
|
532
555
|
onProgress('Analyzing drift across categories…');
|
|
533
556
|
return runAudit(deps, onCategory);
|
|
534
557
|
},
|
|
@@ -545,7 +568,7 @@ export async function handleSystemAudit(
|
|
|
545
568
|
|
|
546
569
|
let deps: Awaited<ReturnType<typeof buildAuditDeps>>;
|
|
547
570
|
try {
|
|
548
|
-
deps = await buildAuditDeps();
|
|
571
|
+
deps = await buildAuditDeps({ quiet: json });
|
|
549
572
|
} catch (err) {
|
|
550
573
|
return {
|
|
551
574
|
success: false,
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor remediation-arrow gate (celilo#1308).
|
|
3
|
+
*
|
|
4
|
+
* `celilo system doctor` renders `→ <command>` remediation lines under failing
|
|
5
|
+
* checks. Those strings are built by hand in three files and were never checked
|
|
6
|
+
* against the CLI's own command table, so doctor prescribed commands that
|
|
7
|
+
* cannot run:
|
|
8
|
+
*
|
|
9
|
+
* - `celilo module update <module-id>` — `module update` takes a PATH, so a
|
|
10
|
+
* module id names an argument form that does not exist (the lunacycle case
|
|
11
|
+
* in #1308).
|
|
12
|
+
* - `celilo system doctor --deep` — the doctor's own instruction, rejected by
|
|
13
|
+
* `checkFlags` because `deep` was never declared on `system doctor`.
|
|
14
|
+
* - `celilo capability chain <module> <var>` — a command the ISS-0115 rework
|
|
15
|
+
* removed from the registry.
|
|
16
|
+
*
|
|
17
|
+
* The gate scans the three files that produce the doctor's `→` surface
|
|
18
|
+
* (system-doctor.ts, fleet-checks.ts, module-integrity.ts) for `celilo ...`
|
|
19
|
+
* command spans and validates each against COMMANDS from @celilo/core:
|
|
20
|
+
*
|
|
21
|
+
* 1. every command word must resolve along the registry tree,
|
|
22
|
+
* 2. every flag must be declared on the leaf command,
|
|
23
|
+
* 3. positional count must fit the leaf command's declared args,
|
|
24
|
+
* 4. a value known to be a module id must never fill a path slot (an arg whose
|
|
25
|
+
* completion is `directories` or `files`), variadic or not.
|
|
26
|
+
*
|
|
27
|
+
* Commands are parsed, never executed. A span is "strict" when a quote or
|
|
28
|
+
* backtick delimits it (the author wrote a command); a `celilo ...` occurrence
|
|
29
|
+
* without a delimiter is a hint embedded in a sentence and is only validated
|
|
30
|
+
* when everything after the command path is argument-shaped — trailing plain
|
|
31
|
+
* words mean the line talks ABOUT celilo ("celilo knows where its control
|
|
32
|
+
* plane runs"), which is prose, not a prescription.
|
|
33
|
+
*
|
|
34
|
+
* Reach floors: the scan asserts it resolved a real number of command spans
|
|
35
|
+
* and contains the anchors it exists to police, because a scan that resolves
|
|
36
|
+
* nothing exits 0 and proves nothing.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { describe, expect, it } from 'bun:test';
|
|
40
|
+
import { readFileSync } from 'node:fs';
|
|
41
|
+
import { join } from 'node:path';
|
|
42
|
+
import { type ArgDef, COMMANDS, type CommandDef } from '@celilo/core';
|
|
43
|
+
|
|
44
|
+
const SOURCES = [
|
|
45
|
+
join(import.meta.dir, 'system-doctor.ts'),
|
|
46
|
+
join(import.meta.dir, '../../services/fleet-checks.ts'),
|
|
47
|
+
join(import.meta.dir, '../../services/audit/module-integrity.ts'),
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** Completion kinds whose value is a filesystem path, not an id. */
|
|
51
|
+
const PATH_COMPLETIONS = new Set(['directories', 'files']);
|
|
52
|
+
|
|
53
|
+
const INTERP = '\u0000';
|
|
54
|
+
|
|
55
|
+
/** `${...}` expressions found by masking, in order. */
|
|
56
|
+
const interps: string[] = [];
|
|
57
|
+
|
|
58
|
+
function maskInterpolations(source: string): string {
|
|
59
|
+
interps.length = 0;
|
|
60
|
+
return source.replace(/\$\{([^{}]*)\}/g, (_m, expr: string) => {
|
|
61
|
+
interps.push(expr);
|
|
62
|
+
return `${INTERP}${interps.length - 1}${INTERP}`;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function interpText(masked: string): string {
|
|
67
|
+
const m = masked.match(new RegExp(`${INTERP}(\\d+)${INTERP}`));
|
|
68
|
+
return m ? (interps[Number(m[1])] ?? masked) : masked;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface ParsedToken {
|
|
72
|
+
kind: 'word' | 'flag' | 'positional';
|
|
73
|
+
text: string;
|
|
74
|
+
/** True when the token's runtime value is a module id. */
|
|
75
|
+
isModuleId: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One `celilo ...` occurrence: the tokens after the word `celilo`, plus
|
|
80
|
+
* whether a quote delimited it (strict).
|
|
81
|
+
*/
|
|
82
|
+
interface Span {
|
|
83
|
+
tokens: ParsedToken[];
|
|
84
|
+
strict: boolean;
|
|
85
|
+
raw: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const FLAG = /^--[a-z0-9][a-z0-9-]*/;
|
|
89
|
+
const PLACEHOLDER = /^<[^<>\s]+>/;
|
|
90
|
+
|
|
91
|
+
function tokenize(text: string): ParsedToken[] {
|
|
92
|
+
const tokens: ParsedToken[] = [];
|
|
93
|
+
let rest = text;
|
|
94
|
+
while (rest.length > 0) {
|
|
95
|
+
rest = rest.replace(/^[\s,;]+/, '');
|
|
96
|
+
if (rest.length === 0) break;
|
|
97
|
+
let m = rest.match(FLAG);
|
|
98
|
+
if (m) {
|
|
99
|
+
tokens.push({ kind: 'flag', text: m[0].slice(2), isModuleId: false });
|
|
100
|
+
rest = rest.slice(m[0].length);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
m = rest.match(PLACEHOLDER);
|
|
104
|
+
if (m) {
|
|
105
|
+
tokens.push({ kind: 'positional', text: m[0], isModuleId: false });
|
|
106
|
+
rest = rest.slice(m[0].length);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
m = rest.match(/^\u0000(\d+)\u0000/);
|
|
110
|
+
if (m) {
|
|
111
|
+
const expr = interps[Number(m[1])] ?? '';
|
|
112
|
+
rest = rest.slice(m[0].length);
|
|
113
|
+
// ANSI chrome (${ANSI.reset} and friends) renders as an escape code,
|
|
114
|
+
// not as a value the operator types.
|
|
115
|
+
if (/^ANSI\.\w+$/.test(expr.trim())) continue;
|
|
116
|
+
tokens.push({ kind: 'positional', text: expr, isModuleId: /\bmoduleId\b/.test(expr) });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
m = rest.match(/^[a-z][a-z0-9-]*(?:\.\u0000\d+\u0000[a-z0-9-]*)*/);
|
|
120
|
+
if (m) {
|
|
121
|
+
// A word that carries an interpolation (a key built at runtime, like
|
|
122
|
+
// network.<zone>.subnet) is a value, never a subcommand.
|
|
123
|
+
const isInterp = m[0].includes(INTERP);
|
|
124
|
+
const isModuleId = isInterp
|
|
125
|
+
? (m[0].match(/\u0000(\d+)\u0000/g) ?? []).some((mark) =>
|
|
126
|
+
/\bmoduleId\b/.test(interpText(mark)),
|
|
127
|
+
)
|
|
128
|
+
: false;
|
|
129
|
+
tokens.push({ kind: isInterp ? 'positional' : 'word', text: m[0], isModuleId });
|
|
130
|
+
rest = rest.slice(m[0].length);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
break; // punctuation, a closing quote, prose — the command part is over
|
|
134
|
+
}
|
|
135
|
+
return tokens;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Extract every `celilo ` occurrence with its span, per the rules above. */
|
|
139
|
+
function extractSpans(maskedLine: string): Span[] {
|
|
140
|
+
const spans: Span[] = [];
|
|
141
|
+
const re = /\bcelilo(?=\s)/g;
|
|
142
|
+
let m: RegExpExecArray | null = re.exec(maskedLine);
|
|
143
|
+
while (m !== null) {
|
|
144
|
+
const before = m.index > 0 ? maskedLine[m.index - 1] : '';
|
|
145
|
+
const delimiter = before === '`' || before === "'" || before === '"' ? before : null;
|
|
146
|
+
const start = m.index + 'celilo'.length;
|
|
147
|
+
let content: string;
|
|
148
|
+
let strict: boolean;
|
|
149
|
+
if (delimiter) {
|
|
150
|
+
const closer = maskedLine.indexOf(delimiter, start);
|
|
151
|
+
strict = true;
|
|
152
|
+
content = closer === -1 ? maskedLine.slice(start) : maskedLine.slice(start, closer);
|
|
153
|
+
if (closer !== -1) re.lastIndex = closer + 1;
|
|
154
|
+
} else {
|
|
155
|
+
strict = false;
|
|
156
|
+
content = maskedLine.slice(start);
|
|
157
|
+
}
|
|
158
|
+
const tokens = tokenize(content);
|
|
159
|
+
if (tokens.length > 0) spans.push({ tokens, strict, raw: content.trim() });
|
|
160
|
+
if (re.lastIndex <= m.index) re.lastIndex = start;
|
|
161
|
+
m = re.exec(maskedLine);
|
|
162
|
+
}
|
|
163
|
+
return spans;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function walkPath(tokens: ParsedToken[]): { path: CommandDef[]; rest: ParsedToken[] } {
|
|
167
|
+
const path: CommandDef[] = [];
|
|
168
|
+
let node: CommandDef | undefined = COMMANDS.find((c) => c.name === tokens[0]?.text);
|
|
169
|
+
if (!node) return { path, rest: tokens };
|
|
170
|
+
path.push(node);
|
|
171
|
+
let i = 1;
|
|
172
|
+
while (i < tokens.length && tokens[i].kind === 'word') {
|
|
173
|
+
const next: CommandDef | undefined = node.subcommands?.find((s) => s.name === tokens[i].text);
|
|
174
|
+
if (!next) break;
|
|
175
|
+
node = next;
|
|
176
|
+
path.push(next);
|
|
177
|
+
i++;
|
|
178
|
+
}
|
|
179
|
+
return { path, rest: tokens.slice(i) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Which declared arg the i-th positional fills (variadic absorbs the tail). */
|
|
183
|
+
function slotFor(args: ArgDef[], i: number): ArgDef | undefined {
|
|
184
|
+
const variadicIndex = args.findIndex((a) => a.variadic);
|
|
185
|
+
const fixed = variadicIndex === -1 ? args.length : variadicIndex;
|
|
186
|
+
if (i < fixed) return args[i];
|
|
187
|
+
return variadicIndex === -1 ? undefined : args[variadicIndex];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function validateSpan(span: Span): string[] {
|
|
191
|
+
const { path, rest } = walkPath(span.tokens);
|
|
192
|
+
if (path.length === 0) return []; // first word is not a command: prose about celilo
|
|
193
|
+
|
|
194
|
+
// A hint embedded in prose is only a prescription when everything after the
|
|
195
|
+
// command path is argument-shaped. Trailing plain words mean the sentence
|
|
196
|
+
// continues ("celilo system migrate` to apply pending migrations...").
|
|
197
|
+
if (!span.strict && rest.some((t) => t.kind === 'word')) return [];
|
|
198
|
+
|
|
199
|
+
const name = `celilo ${path.map((c) => c.name).join(' ')}`;
|
|
200
|
+
const leaf = path[path.length - 1];
|
|
201
|
+
const violations: string[] = [];
|
|
202
|
+
|
|
203
|
+
for (const t of rest.filter((t) => t.kind === 'word')) {
|
|
204
|
+
violations.push(`${name}: unknown command word '${t.text}'`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const declaredFlags = new Set([...(leaf.flags ?? []).map((f) => f.name), 'help', 'h']);
|
|
208
|
+
for (const t of rest.filter((t) => t.kind === 'flag')) {
|
|
209
|
+
if (!declaredFlags.has(t.text)) {
|
|
210
|
+
violations.push(`${name}: flag --${t.text} is not declared in the command registry`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const args = leaf.args ?? [];
|
|
215
|
+
const positionals = rest.filter((t) => t.kind === 'positional');
|
|
216
|
+
if (!args.some((a) => a.variadic) && positionals.length > args.length) {
|
|
217
|
+
violations.push(
|
|
218
|
+
`${name}: ${positionals.length} positional value(s) but only ${args.length} declared arg(s)`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
positionals.forEach((token, i) => {
|
|
222
|
+
const slot = slotFor(args, i);
|
|
223
|
+
if (!slot) return;
|
|
224
|
+
if (
|
|
225
|
+
token.isModuleId &&
|
|
226
|
+
slot.completion !== undefined &&
|
|
227
|
+
PATH_COMPLETIONS.has(slot.completion)
|
|
228
|
+
) {
|
|
229
|
+
violations.push(
|
|
230
|
+
`${name}: module id '${token.text}' fills the '${slot.name}' slot, which takes a path (${slot.completion})`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return violations;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
describe('doctor remediation arrow gate (celilo#1308)', () => {
|
|
239
|
+
it('flags a module id passed into a path slot, even a variadic one', () => {
|
|
240
|
+
// Rule 7.6 anchor: the exact defect shape from #1308 (`module update
|
|
241
|
+
// <module-id>`), pinned synthetically so the rule cannot silently stop
|
|
242
|
+
// matching the registry.
|
|
243
|
+
maskInterpolations(''); // reset
|
|
244
|
+
interps.push('result.moduleId');
|
|
245
|
+
const violations = validateSpan({
|
|
246
|
+
tokens: tokenize(`module update ${INTERP}0${INTERP}`),
|
|
247
|
+
strict: true,
|
|
248
|
+
raw: 'module update ${result.moduleId}',
|
|
249
|
+
});
|
|
250
|
+
expect(violations.some((v) => v.includes('takes a path'))).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('every command the doctor prescribes exists, with declared flags and fitting args', () => {
|
|
254
|
+
const violations: string[] = [];
|
|
255
|
+
let resolvedCount = 0;
|
|
256
|
+
const resolvedHeads = new Set<string>();
|
|
257
|
+
|
|
258
|
+
for (const file of SOURCES) {
|
|
259
|
+
const raw = readFileSync(file, 'utf8');
|
|
260
|
+
// Strip block comments and whole-line // comments so doc headers that
|
|
261
|
+
// mention commands historically are not prescribed commands now.
|
|
262
|
+
const stripped = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
|
263
|
+
const masked = maskInterpolations(stripped);
|
|
264
|
+
const shortFile = file.split('/').pop() ?? file;
|
|
265
|
+
|
|
266
|
+
for (const line of masked.split('\n')) {
|
|
267
|
+
for (const span of extractSpans(line)) {
|
|
268
|
+
const found = validateSpan(span);
|
|
269
|
+
if (found.length === 0 && span.strict) resolvedCount++;
|
|
270
|
+
for (const v of found) violations.push(`[${shortFile}] ${v} (span: "${span.raw}")`);
|
|
271
|
+
const head = span.tokens
|
|
272
|
+
.slice(0, 2)
|
|
273
|
+
.map((t) => t.text)
|
|
274
|
+
.join(' ');
|
|
275
|
+
if (head) resolvedHeads.add(head);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Reach floors. A scan that resolves nothing proves nothing.
|
|
281
|
+
expect(resolvedCount).toBeGreaterThanOrEqual(12);
|
|
282
|
+
for (const anchor of [
|
|
283
|
+
'system doctor',
|
|
284
|
+
'module verify',
|
|
285
|
+
'monitor add',
|
|
286
|
+
'events repair',
|
|
287
|
+
'system migrate',
|
|
288
|
+
]) {
|
|
289
|
+
const found = [...resolvedHeads].some((h) => h.startsWith(anchor));
|
|
290
|
+
expect(found).toBe(true);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
expect(violations).toEqual([]);
|
|
294
|
+
});
|
|
295
|
+
});
|