@celilo/cli 1.14.0 → 2.1.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 -2
- package/CELILO_SUBSYSTEMS.md +26 -3
- package/README.md +0 -2
- package/drizzle/0030_drop_module_builds_environment.sql +8 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/public-web-publish.test.ts +18 -0
- package/src/cli/commands/alerts-sweep.ts +3 -0
- package/src/cli/commands/monitor.ts +15 -2
- package/src/cli/commands/system-doctor.test.ts +121 -1
- package/src/cli/commands/system-doctor.ts +151 -1
- package/src/cli/completion.ts +9 -2
- package/src/cli/index.ts +1 -1
- package/src/console/control-plane-boundary.test.ts +82 -4
- package/src/db/schema.ts +0 -1
- package/src/hooks/capability-loader.ts +15 -2
- package/src/hooks/executor.ts +116 -17
- package/src/hooks/hook-jail-toolchain-reach.test.ts +273 -0
- package/src/hooks/hook-jail-unreachability.test.ts +77 -26
- package/src/hooks/hook-protocol.ts +44 -0
- package/src/hooks/hook-runner-entry.ts +23 -0
- package/src/hooks/hook-runner.ts +10 -0
- package/src/hooks/hook-trespass.test.ts +74 -12
- package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
- package/src/hooks/jail.test.ts +92 -0
- package/src/hooks/jail.ts +304 -32
- package/src/hooks/mount-set.test.ts +116 -7
- package/src/hooks/mount-set.ts +189 -20
- package/src/hooks/remote-broker.test.ts +350 -0
- package/src/hooks/remote-broker.ts +404 -0
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
- package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
- package/src/hooks/unjailed-lint.test.ts +254 -0
- package/src/hooks/unjailed-lint.ts +395 -0
- package/src/policy/module-business-baseline.ts +13 -1
- package/src/policy/module-script-scan.ts +60 -1
- package/src/policy/no-hand-built-ssh.test.ts +39 -1
- package/src/policy/no-module-business-in-core.test.ts +1 -1
- package/src/services/alerting/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -1
- package/src/services/alerting/hook-jail.test.ts +66 -0
- package/src/services/alerting/hook-jail.ts +70 -0
- package/src/services/alerting/run-monitor.test.ts +62 -0
- package/src/services/alerting/run-monitor.ts +12 -0
- package/src/services/alerting/sweep-runner.test.ts +1 -0
- package/src/services/backup-create.ts +3 -0
- package/src/services/backup-restore.ts +2 -0
- package/src/services/control-plane-bootstrap.test.ts +177 -0
- package/src/services/control-plane-bootstrap.ts +176 -0
- package/src/services/control-plane-health.test.ts +66 -0
- package/src/services/control-plane-health.ts +67 -0
- package/src/services/deploy-ansible.ts +9 -1
- package/src/services/deployed-systems.ts +12 -0
- package/src/services/dns-discovery.test.ts +93 -0
- package/src/services/dns-discovery.ts +92 -0
- package/src/services/fleet-checks.ts +6 -2
- package/src/services/health-runner.ts +36 -3
- package/src/services/module-build.test.ts +1 -64
- package/src/services/module-build.ts +10 -86
- package/src/services/module-deploy.ts +71 -0
- package/src/services/remote-access.test.ts +223 -0
- package/src/services/remote-access.ts +149 -0
- package/src/services/restore-from-file.ts +6 -1
- package/src/services/static-content-converge.test.ts +338 -0
- package/src/services/static-content-converge.ts +299 -0
|
@@ -32,13 +32,27 @@
|
|
|
32
32
|
import { spawnSync } from 'node:child_process';
|
|
33
33
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
34
34
|
import { createRequire } from 'node:module';
|
|
35
|
+
import { hostname } from 'node:os';
|
|
35
36
|
import { dirname, join, resolve } from 'node:path';
|
|
36
37
|
import { resolveBrowser } from '@celilo/capabilities';
|
|
37
38
|
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
38
39
|
import cliPkg from '../../../package.json' with { type: 'json' };
|
|
39
40
|
import { getDbPath, getEventBusPath } from '../../config/paths';
|
|
40
41
|
import { getDb } from '../../db/client';
|
|
42
|
+
import {
|
|
43
|
+
JAIL_OFF_REASON,
|
|
44
|
+
type JailAvailability,
|
|
45
|
+
type JailModeRecord,
|
|
46
|
+
type JailPolicy,
|
|
47
|
+
SANDBOX_EXEC_AUTO_DEFERRED_REASON,
|
|
48
|
+
autoJailDefers,
|
|
49
|
+
detectJailBackend,
|
|
50
|
+
jailPolicy,
|
|
51
|
+
readJailMode,
|
|
52
|
+
} from '../../hooks/jail';
|
|
41
53
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
54
|
+
import { HOOK_JAIL_CHECK } from '../../services/alerting/hook-jail';
|
|
55
|
+
import { findMonitorByTarget } from '../../services/alerting/monitors';
|
|
42
56
|
import {
|
|
43
57
|
type FleetFinding,
|
|
44
58
|
type FleetFindingStatus,
|
|
@@ -336,6 +350,112 @@ function provisionedBrowserFlavor(): string | null {
|
|
|
336
350
|
}
|
|
337
351
|
}
|
|
338
352
|
|
|
353
|
+
export interface HookExecutionInput {
|
|
354
|
+
availability: JailAvailability;
|
|
355
|
+
policy: JailPolicy;
|
|
356
|
+
/** What the last real module-hook run recorded, if any. */
|
|
357
|
+
record: JailModeRecord | undefined;
|
|
358
|
+
host: string;
|
|
359
|
+
/**
|
|
360
|
+
* Whether a `hook_jail` monitor exists. `null` when there is no celilo DB to
|
|
361
|
+
* ask (a dev box), which also suppresses the add-the-monitor hint.
|
|
362
|
+
*/
|
|
363
|
+
monitored: boolean | null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The hook-execution section (hook-process-boundary task 4.6).
|
|
368
|
+
*
|
|
369
|
+
* Reports design D8's three states the way the design assigns them: jailed is
|
|
370
|
+
* a ✔ and nothing more; unjailed-and-always-was is reported with the reason in
|
|
371
|
+
* one actionable sentence and is a warning, not an event; a host that USED to
|
|
372
|
+
* jail and has stopped is a failure, and the self-monitor (`celilo monitor add
|
|
373
|
+
* hook_jail`) is what turns it into an alert.
|
|
374
|
+
*
|
|
375
|
+
* The headline is the LIVE answer — the probe runs bubblewrap the way a real
|
|
376
|
+
* spawn would — while the recorded mode says what the last hook actually did.
|
|
377
|
+
* They disagree exactly when the host changed since the last hook ran, which
|
|
378
|
+
* is worth seeing, not reconciling away.
|
|
379
|
+
*
|
|
380
|
+
* Pure so every branch is testable on a host with no jail (Rule 10.4); the
|
|
381
|
+
* caller injects what the machine says.
|
|
382
|
+
*/
|
|
383
|
+
export function renderHookExecutionSection(input: HookExecutionInput): {
|
|
384
|
+
lines: string[];
|
|
385
|
+
failCount: number;
|
|
386
|
+
warnCount: number;
|
|
387
|
+
} {
|
|
388
|
+
const { availability, policy, record, host, monitored } = input;
|
|
389
|
+
const lines: string[] = ['Hook execution'];
|
|
390
|
+
let failCount = 0;
|
|
391
|
+
let warnCount = 0;
|
|
392
|
+
|
|
393
|
+
// ce-29z: auto defers on sandbox-exec, so the doctor must not report a jail
|
|
394
|
+
// the executor will not build. One predicate, two consumers.
|
|
395
|
+
const autoDeferred = policy === 'auto' && autoJailDefers(availability);
|
|
396
|
+
const liveJailed = policy !== 'off' && availability.backend !== 'none' && !autoDeferred;
|
|
397
|
+
if (policy === 'required' && availability.backend === 'none') {
|
|
398
|
+
lines.push(
|
|
399
|
+
` ${ANSI.red}✗${ANSI.reset} CELILO_HOOK_JAIL=required and no jail is available — every module hook on this host fails rather than run unjailed`,
|
|
400
|
+
);
|
|
401
|
+
lines.push(` ${ANSI.dim}${availability.reason ?? 'no reason was recorded'}${ANSI.reset}`);
|
|
402
|
+
failCount++;
|
|
403
|
+
} else if (policy === 'off') {
|
|
404
|
+
lines.push(` ${ANSI.yellow}⚠${ANSI.reset} hooks run unjailed — ${JAIL_OFF_REASON}`);
|
|
405
|
+
warnCount++;
|
|
406
|
+
} else if (availability.backend === 'none') {
|
|
407
|
+
lines.push(
|
|
408
|
+
` ${ANSI.yellow}⚠${ANSI.reset} hooks run unjailed — ${availability.reason ?? 'no reason was recorded'}`,
|
|
409
|
+
);
|
|
410
|
+
warnCount++;
|
|
411
|
+
} else if (autoDeferred) {
|
|
412
|
+
lines.push(
|
|
413
|
+
` ${ANSI.yellow}⚠${ANSI.reset} hooks run unjailed — ${SANDBOX_EXEC_AUTO_DEFERRED_REASON}`,
|
|
414
|
+
);
|
|
415
|
+
warnCount++;
|
|
416
|
+
} else {
|
|
417
|
+
lines.push(` ${ANSI.green}✔${ANSI.reset} hooks run jailed (${availability.backend})`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const regressed =
|
|
421
|
+
record && record.host === host && record.mode === 'unjailed' && record.lastJailed;
|
|
422
|
+
if (regressed && record.lastJailed) {
|
|
423
|
+
lines.push(
|
|
424
|
+
` ${ANSI.red}✗${ANSI.reset} this host ran hooks jailed (${record.lastJailed.backend}) until ${record.lastJailed.recordedAt} and has stopped`,
|
|
425
|
+
);
|
|
426
|
+
if (record.reason) lines.push(` ${ANSI.dim}${record.reason}${ANSI.reset}`);
|
|
427
|
+
failCount++;
|
|
428
|
+
if (monitored === false) {
|
|
429
|
+
lines.push(
|
|
430
|
+
` ${ANSI.dim}→ nothing alerts on this — celilo monitor add ${HOOK_JAIL_CHECK}${ANSI.reset}`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
} else if (!record) {
|
|
434
|
+
lines.push(
|
|
435
|
+
` ${ANSI.dim}no execution mode recorded yet — the first real module-hook run writes it${ANSI.reset}`,
|
|
436
|
+
);
|
|
437
|
+
} else if (record.host !== host) {
|
|
438
|
+
lines.push(
|
|
439
|
+
` ${ANSI.dim}last recorded mode belongs to host "${record.host}" (this data directory moved) — the next hook run re-records${ANSI.reset}`,
|
|
440
|
+
);
|
|
441
|
+
} else {
|
|
442
|
+
const backend = record.mode === 'jailed' ? ` (${record.backend})` : '';
|
|
443
|
+
lines.push(
|
|
444
|
+
` ${ANSI.dim}last hook ran ${record.mode}${backend} at ${record.recordedAt}${ANSI.reset}`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// The moment this hint pays for itself is BEFORE a regression: the monitor
|
|
449
|
+
// has to exist for the transition to page anyone.
|
|
450
|
+
if (liveJailed && monitored === false) {
|
|
451
|
+
lines.push(
|
|
452
|
+
` ${ANSI.dim}→ a host that stops jailing alerts only if monitored: celilo monitor add ${HOOK_JAIL_CHECK}${ANSI.reset}`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return { lines, failCount, warnCount };
|
|
457
|
+
}
|
|
458
|
+
|
|
339
459
|
/**
|
|
340
460
|
* mtime (ms) of the installed dispatcher code (`@celilo/event-bus`
|
|
341
461
|
* package.json). The fleet dispatcher check compares this against the
|
|
@@ -616,6 +736,33 @@ export async function handleSystemDoctor(
|
|
|
616
736
|
lines.push(...prereqResult.lines);
|
|
617
737
|
lines.push('');
|
|
618
738
|
|
|
739
|
+
// Hook execution mode (hook-process-boundary task 4.6). The policy read
|
|
740
|
+
// throws on a typo'd CELILO_HOOK_JAIL — report that rather than crash the
|
|
741
|
+
// doctor, since the same throw is about to fail every hook invocation too.
|
|
742
|
+
let hookExec: { lines: string[]; failCount: number; warnCount: number };
|
|
743
|
+
try {
|
|
744
|
+
hookExec = renderHookExecutionSection({
|
|
745
|
+
availability: detectJailBackend(),
|
|
746
|
+
policy: jailPolicy(),
|
|
747
|
+
record: readJailMode(),
|
|
748
|
+
host: hostname(),
|
|
749
|
+
monitored: existsSync(getDbPath())
|
|
750
|
+
? findMonitorByTarget(getDb(), HOOK_JAIL_CHECK) !== undefined
|
|
751
|
+
: null,
|
|
752
|
+
});
|
|
753
|
+
} catch (error) {
|
|
754
|
+
hookExec = {
|
|
755
|
+
lines: [
|
|
756
|
+
'Hook execution',
|
|
757
|
+
` ${ANSI.red}✗${ANSI.reset} ${error instanceof Error ? error.message : String(error)}`,
|
|
758
|
+
],
|
|
759
|
+
failCount: 1,
|
|
760
|
+
warnCount: 0,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
lines.push(...hookExec.lines);
|
|
764
|
+
lines.push('');
|
|
765
|
+
|
|
619
766
|
const workspaceRoot = findWorkspaceRoot(process.cwd());
|
|
620
767
|
const workspaceVersions = workspaceRoot ? collectWorkspaceVersions(workspaceRoot) : [];
|
|
621
768
|
const workspaceMap = new Map(workspaceVersions.map((w) => [w.name, w]));
|
|
@@ -748,6 +895,9 @@ export async function handleSystemDoctor(
|
|
|
748
895
|
}
|
|
749
896
|
if (driftCount > 0) problems.push(`${driftCount} package(s) behind workspace`);
|
|
750
897
|
if (unresolvedCount > 0) problems.push(`${unresolvedCount} unresolved`);
|
|
898
|
+
if (hookExec.failCount > 0) {
|
|
899
|
+
problems.push(`${hookExec.failCount} hook-execution problem(s)`);
|
|
900
|
+
}
|
|
751
901
|
if (fleet.failCount > 0) problems.push(`${fleet.failCount} fleet check(s) failing`);
|
|
752
902
|
if (aspectFailCount > 0) problems.push(`${aspectFailCount} system(s) missing a fleet aspect`);
|
|
753
903
|
|
|
@@ -760,7 +910,7 @@ export async function handleSystemDoctor(
|
|
|
760
910
|
}
|
|
761
911
|
|
|
762
912
|
// Fleet warnings don't fail the run, but they shouldn't read as a clean bill.
|
|
763
|
-
const warnTotal = fleet.warnCount + aspectWarnCount;
|
|
913
|
+
const warnTotal = fleet.warnCount + aspectWarnCount + hookExec.warnCount;
|
|
764
914
|
if (warnTotal > 0) {
|
|
765
915
|
lines.push(`${ANSI.yellow}OK with warnings${ANSI.reset} — ${warnTotal} warning(s); see above`);
|
|
766
916
|
} else {
|
package/src/cli/completion.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { getDb } from '../db/client';
|
|
|
8
8
|
import { capabilities, modules } from '../db/schema';
|
|
9
9
|
import type { ModuleManifest } from '../manifest/schema';
|
|
10
10
|
import { SCHEDULABLE_BUILTIN_CHECKS } from '../services/alerting/builtin-source';
|
|
11
|
+
import { HEALTH_COVERAGE_CHECK } from '../services/alerting/health-coverage';
|
|
12
|
+
import { HOOK_JAIL_CHECK } from '../services/alerting/hook-jail';
|
|
11
13
|
import { listPrincipals } from '../services/api-access';
|
|
12
14
|
import { listBackups } from '../services/backup-metadata';
|
|
13
15
|
import { listBackupStorages } from '../services/backup-storage';
|
|
@@ -532,7 +534,9 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
532
534
|
|
|
533
535
|
// Monitor targets - a module ID or one of celilo's own schedulable checks.
|
|
534
536
|
// Sourced from SCHEDULABLE_BUILTIN_CHECKS rather than a hand-copied list, so
|
|
535
|
-
// a new built-in check is completable the moment it is schedulable.
|
|
537
|
+
// a new built-in check is completable the moment it is schedulable. The two
|
|
538
|
+
// local-state checks (health coverage, hook jail) are deliberately not in
|
|
539
|
+
// that list — it is the audit categories — so they ride alongside by name.
|
|
536
540
|
if (
|
|
537
541
|
command === 'monitor' &&
|
|
538
542
|
(args[1] === 'add' ||
|
|
@@ -548,7 +552,10 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
548
552
|
.from(modules)
|
|
549
553
|
.all()
|
|
550
554
|
.map((m) => m.id);
|
|
551
|
-
return filterSuggestions(
|
|
555
|
+
return filterSuggestions(
|
|
556
|
+
[...SCHEDULABLE_BUILTIN_CHECKS, HEALTH_COVERAGE_CHECK, HOOK_JAIL_CHECK, ...moduleIds],
|
|
557
|
+
args[2] || '',
|
|
558
|
+
);
|
|
552
559
|
}
|
|
553
560
|
|
|
554
561
|
// Alerts subcommands
|
package/src/cli/index.ts
CHANGED
|
@@ -619,7 +619,7 @@ Subcommands:
|
|
|
619
619
|
show-config <id> Show all config including auto-derived values
|
|
620
620
|
show-zone <id> Show module's network zone and config
|
|
621
621
|
|
|
622
|
-
build <module-id> Execute build scripts (
|
|
622
|
+
build <module-id> Execute module build scripts (Ansible)
|
|
623
623
|
|
|
624
624
|
generate <id> Generate infrastructure code for module
|
|
625
625
|
Options:
|
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
* caddy-internal" for a further round after that. Prose did not hold this.
|
|
16
16
|
*/
|
|
17
17
|
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
18
20
|
import { parse } from 'yaml';
|
|
19
21
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
import.meta.url,
|
|
23
|
-
).pathname;
|
|
22
|
+
const MODULE_DIR = new URL('../../../../modules/celilo-web-console/', import.meta.url).pathname;
|
|
23
|
+
const MANIFEST_PATH = join(MODULE_DIR, 'manifest.yml');
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Capabilities that give something a way in from the data plane.
|
|
@@ -73,3 +73,81 @@ describe('celilo-web-console stays in the control plane', () => {
|
|
|
73
73
|
expect(manifest.provides?.capabilities ?? []).toEqual([]);
|
|
74
74
|
});
|
|
75
75
|
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The build output the manifest's `build:` step writes, which is not source.
|
|
79
|
+
*
|
|
80
|
+
* Excluded from the walk below for two reasons, and the second is the one that
|
|
81
|
+
* matters. It holds two ~100MB compiled binaries, so reading it as text is
|
|
82
|
+
* absurd. And it holds a minified React bundle, which contains almost every
|
|
83
|
+
* short string you could think to search for — so scanning it reports ingress
|
|
84
|
+
* that nobody wrote. Both are gitignored, so on a fresh checkout the directory
|
|
85
|
+
* is absent entirely and the walk would silently be checking a different set of
|
|
86
|
+
* files than it checks on a machine that has run a build.
|
|
87
|
+
*/
|
|
88
|
+
const BUILD_OUTPUT_DIR = 'files';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Every source file in the module's deployable body.
|
|
92
|
+
*
|
|
93
|
+
* A walk rather than a list, because the hole this guards is a file somebody
|
|
94
|
+
* ADDS. Listing today's files would pass over exactly the new Ansible task that
|
|
95
|
+
* opens the port.
|
|
96
|
+
*/
|
|
97
|
+
function deployableBody(): { path: string; text: string }[] {
|
|
98
|
+
const found: { path: string; text: string }[] = [];
|
|
99
|
+
const walk = (dir: string) => {
|
|
100
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
101
|
+
if (entry.isDirectory()) {
|
|
102
|
+
if (entry.name !== BUILD_OUTPUT_DIR) walk(join(dir, entry.name));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const path = join(dir, entry.name);
|
|
106
|
+
found.push({ path, text: readFileSync(path, 'utf8') });
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
for (const sub of ['terraform', 'ansible']) walk(join(MODULE_DIR, sub));
|
|
110
|
+
return found;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* How a deployable body would open a path in from the data plane.
|
|
115
|
+
*
|
|
116
|
+
* A reverse-proxy vhost, a DNAT or port-forward rule, a `natIp`, or a dedicated
|
|
117
|
+
* ingress address. None belongs on a `secure-mgmt` module, and every one is a
|
|
118
|
+
* plausible one-line addition by someone who wanted to reach the console
|
|
119
|
+
* without bringing up the VPN.
|
|
120
|
+
*/
|
|
121
|
+
const INGRESS_TOKENS = [
|
|
122
|
+
'caddy',
|
|
123
|
+
'reverse_proxy',
|
|
124
|
+
'natip',
|
|
125
|
+
'exposeservice',
|
|
126
|
+
'port_forward',
|
|
127
|
+
'portforward',
|
|
128
|
+
'dnat',
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
describe("the console's deployable body opens no path in", () => {
|
|
132
|
+
test('the walk reaches the terraform and ansible it is checking', () => {
|
|
133
|
+
// Without this the assertion below is a confident statement about an empty
|
|
134
|
+
// set as soon as either directory is renamed — and the same is true on a
|
|
135
|
+
// fresh checkout, where the excluded build output does not exist at all.
|
|
136
|
+
const paths = deployableBody().map((file) => file.path);
|
|
137
|
+
expect(paths.some((path) => path.endsWith('terraform/main.tf.tpl'))).toBe(true);
|
|
138
|
+
expect(paths.some((path) => path.includes('/ansible/roles/'))).toBe(true);
|
|
139
|
+
expect(paths.some((path) => path.endsWith('.service.j2'))).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('reads no build output, so a local build cannot change the verdict', () => {
|
|
143
|
+
const paths = deployableBody().map((file) => file.path);
|
|
144
|
+
expect(paths.filter((path) => path.includes(`/${BUILD_OUTPUT_DIR}/`))).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('names no reverse proxy, port forward or ingress address', () => {
|
|
148
|
+
const offenders = deployableBody()
|
|
149
|
+
.filter((file) => INGRESS_TOKENS.some((token) => file.text.toLowerCase().includes(token)))
|
|
150
|
+
.map((file) => file.path.slice(MODULE_DIR.length));
|
|
151
|
+
expect(offenders).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
});
|
package/src/db/schema.ts
CHANGED
|
@@ -447,7 +447,6 @@ export const moduleBuilds = sqliteTable('module_builds', {
|
|
|
447
447
|
version: text('version').notNull(),
|
|
448
448
|
builtAt: integer('built_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
449
449
|
artifacts: text('artifacts', { mode: 'json' }).$type<string[]>().notNull(), // Array of artifact paths
|
|
450
|
-
environment: text('environment').$type<'nix' | 'system'>(),
|
|
451
450
|
status: text('status').$type<BuildStatus>().notNull(),
|
|
452
451
|
buildLog: text('build_log'), // Build output for debugging
|
|
453
452
|
});
|
|
@@ -45,7 +45,7 @@ import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
|
45
45
|
import { buildControlPlaneApi } from '../services/api-principal-enrolment';
|
|
46
46
|
import { recordCapabilityBinding, withBindingRecord } from '../services/capability-bindings';
|
|
47
47
|
import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
|
|
48
|
-
import { getModuleSystems } from '../services/deployed-systems';
|
|
48
|
+
import { CONTROL_PLANE_MODULE_ID, getModuleSystems } from '../services/deployed-systems';
|
|
49
49
|
import { withDnsInternalLedger } from '../services/dns-internal-records';
|
|
50
50
|
import { withDnsRegistrationLedger } from '../services/dns-registrations';
|
|
51
51
|
import { buildPortForwardStore } from '../services/port-forwards';
|
|
@@ -605,6 +605,20 @@ export async function loadCapabilityFunctions(
|
|
|
605
605
|
caddyModuleId: provider.moduleId,
|
|
606
606
|
dnsManagedDomains,
|
|
607
607
|
dnsRegistrarModuleId,
|
|
608
|
+
// Design D10: the bytes move through the provider's Ansible converge,
|
|
609
|
+
// not a hand-built ssh tar pipe. Core implements it over
|
|
610
|
+
// executeAnsible against the provider's generated project (task 4.3);
|
|
611
|
+
// awaited with everything else, so a publish returns only once the
|
|
612
|
+
// host matches. A failure here fails the deploy loudly — a publish
|
|
613
|
+
// that reports ready while the host never received the bytes is the
|
|
614
|
+
// exact "served but silently unreachable" anti-pattern.
|
|
615
|
+
convergeStaticContent: async () => {
|
|
616
|
+
const { convergeStaticContent } = await import('../services/static-content-converge');
|
|
617
|
+
const result = await convergeStaticContent(db, provider.moduleId);
|
|
618
|
+
if (!result.success) {
|
|
619
|
+
throw new Error(result.error ?? 'static-content converge failed');
|
|
620
|
+
}
|
|
621
|
+
},
|
|
608
622
|
// ISS-0035: register_route/unregister_routes emit this coarse signal
|
|
609
623
|
// instead of SSHing caddy; the caddy provider's reconcile_routes
|
|
610
624
|
// subscription re-renders the Caddyfile from web_routes. We await the
|
|
@@ -938,7 +952,6 @@ interface FirewallZones {
|
|
|
938
952
|
}
|
|
939
953
|
|
|
940
954
|
/** The module that IS celilo's control plane; its network is what we trust. */
|
|
941
|
-
const CONTROL_PLANE_MODULE_ID = 'celilo-mgmt';
|
|
942
955
|
|
|
943
956
|
function readZoneSubnet(db: DbClient, zone: string): string | undefined {
|
|
944
957
|
const row = db
|
package/src/hooks/executor.ts
CHANGED
|
@@ -31,17 +31,19 @@
|
|
|
31
31
|
* exists (a Mac today, task 4.8) the hook runs unjailed and the mode is
|
|
32
32
|
* RECORDED, so a host that quietly stops jailing is visible rather than silent.
|
|
33
33
|
*
|
|
34
|
-
* Hooks still do NOT execute on the target machine
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
34
|
+
* Hooks still do NOT execute on the target machine — a remote operation is
|
|
35
|
+
* initiated from the celilo host. **Stage 3 has landed and scopes that
|
|
36
|
+
* reachability by the credential (design D12):** `~/.ssh` is not bound into
|
|
37
|
+
* the jail, so a hand-built `ssh` cannot authenticate, and the remote
|
|
38
|
+
* primitives cross to the remote-ops broker (`remote-broker.ts`), which holds
|
|
39
|
+
* the key and checks every target against the policy in
|
|
40
|
+
* `services/remote-access.ts` — the module's own systems, or the module's own
|
|
41
|
+
* credential.
|
|
39
42
|
*
|
|
40
43
|
* Execution function (Rule 10.1) - performs side effects (script execution)
|
|
41
44
|
*/
|
|
42
45
|
|
|
43
46
|
import { existsSync, mkdirSync, readdirSync, rmdirSync, statSync } from 'node:fs';
|
|
44
|
-
import { homedir } from 'node:os';
|
|
45
47
|
import { dirname, join, resolve } from 'node:path';
|
|
46
48
|
import {
|
|
47
49
|
type DeployedSystem,
|
|
@@ -59,8 +61,10 @@ import { isPrivilegedCapability } from '../manifest/validate';
|
|
|
59
61
|
import { pruneModuleArtifacts } from './artifact-retention';
|
|
60
62
|
import { startBroker } from './broker';
|
|
61
63
|
import {
|
|
64
|
+
HOOK_MOUNT_SET_ENV,
|
|
62
65
|
HOOK_PROTOCOL_VERSION,
|
|
63
66
|
HOOK_PROTOCOL_VERSION_ENV,
|
|
67
|
+
HOOK_REMOTE_SOCKET_ENV,
|
|
64
68
|
HOOK_SOCKET_ENV,
|
|
65
69
|
createLineReader,
|
|
66
70
|
deserializeError,
|
|
@@ -74,8 +78,15 @@ import {
|
|
|
74
78
|
recordJailMode,
|
|
75
79
|
runtimeModulePathsFor,
|
|
76
80
|
} from './jail';
|
|
77
|
-
import {
|
|
81
|
+
import {
|
|
82
|
+
type DeclaredPathInput,
|
|
83
|
+
type MountSet,
|
|
84
|
+
type MountSetRequest,
|
|
85
|
+
deriveMountSet,
|
|
86
|
+
} from './mount-set';
|
|
87
|
+
import { type RemoteAccessPolicy, startRemoteBroker } from './remote-broker';
|
|
78
88
|
import type { HookContext, HookDefinition, HookLogger, HookResult } from './types';
|
|
89
|
+
import { mountSetEnvValue } from './unjailed-lint';
|
|
79
90
|
|
|
80
91
|
/** Default total timeout: 60 seconds */
|
|
81
92
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
@@ -160,6 +171,14 @@ const FORWARDED_ENV = [
|
|
|
160
171
|
|
|
161
172
|
/** The shim celilo spawns. Resolved from here so an npm install finds it too. */
|
|
162
173
|
const HOOK_RUNNER_PATH = join(import.meta.dir, 'hook-runner.ts');
|
|
174
|
+
/**
|
|
175
|
+
* What celilo actually SPAWNS: the entry that installs the advisory lint
|
|
176
|
+
* before the runner shim's own import graph can load `node:fs` (task 4.7).
|
|
177
|
+
* The runner path above is still what the mount set binds — the entry and the
|
|
178
|
+
* shim live in the same directory and resolve through the same
|
|
179
|
+
* `node_modules`.
|
|
180
|
+
*/
|
|
181
|
+
const HOOK_RUNNER_SPAWN_PATH = join(import.meta.dir, 'hook-runner-entry.ts');
|
|
163
182
|
|
|
164
183
|
/**
|
|
165
184
|
* Validate hook inputs against a contract signature.
|
|
@@ -265,6 +284,13 @@ export interface ExecuteHookOptions {
|
|
|
265
284
|
idleTimeoutMs?: number;
|
|
266
285
|
/** What to jail this run with. Absent runs the hook unjailed. */
|
|
267
286
|
jail?: HookJailInputs;
|
|
287
|
+
/**
|
|
288
|
+
* D12's target policy for this run's remote operations, built by the caller
|
|
289
|
+
* (`services/remote-access.ts`) so the executor stays decoupled from the
|
|
290
|
+
* database the same way it is for `systems`. Absent means every
|
|
291
|
+
* fleet-credential operation is REFUSED, naming the gap (Rule 6.4).
|
|
292
|
+
*/
|
|
293
|
+
remoteAccess?: RemoteAccessPolicy;
|
|
268
294
|
}
|
|
269
295
|
|
|
270
296
|
/**
|
|
@@ -308,12 +334,27 @@ export async function executeHookScript(
|
|
|
308
334
|
logger,
|
|
309
335
|
onActivity: markActive,
|
|
310
336
|
});
|
|
337
|
+
let remoteBroker: Awaited<ReturnType<typeof startRemoteBroker>> | undefined;
|
|
311
338
|
|
|
312
339
|
try {
|
|
340
|
+
// The remote-ops socket lives beside the capability socket, so the jail's
|
|
341
|
+
// one socket-directory mount covers both channels (design D12).
|
|
342
|
+
const roots = remoteRoots(context, options.jail);
|
|
343
|
+
remoteBroker = await startRemoteBroker({
|
|
344
|
+
socketDir: dirname(broker.socketPath),
|
|
345
|
+
policy: options.remoteAccess,
|
|
346
|
+
readableRoots: roots.readable,
|
|
347
|
+
writableRoots: roots.writable,
|
|
348
|
+
logger,
|
|
349
|
+
onActivity: markActive,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const mountSet = options.jail
|
|
353
|
+
? deriveMountSet(realpathRequest(jailRequest(options.jail, context, broker.socketPath)))
|
|
354
|
+
: undefined;
|
|
313
355
|
const jail = planJailedSpawn(
|
|
314
|
-
[process.execPath,
|
|
315
|
-
|
|
316
|
-
deriveMountSet(realpathRequest(jailRequest(options.jail, context, broker.socketPath))),
|
|
356
|
+
[process.execPath, HOOK_RUNNER_SPAWN_PATH],
|
|
357
|
+
mountSet,
|
|
317
358
|
detectJailBackend(),
|
|
318
359
|
jailPolicy(),
|
|
319
360
|
);
|
|
@@ -326,7 +367,19 @@ export async function executeHookScript(
|
|
|
326
367
|
|
|
327
368
|
const child = Bun.spawn({
|
|
328
369
|
cmd: [...jail.cmd],
|
|
329
|
-
|
|
370
|
+
// `sandbox-exec` has no `--chdir`, so the plan names one and the spawn
|
|
371
|
+
// sets it. Without it the child inherits the operator's shell directory,
|
|
372
|
+
// which the profile does not name — and `bun` reads its cwd before it
|
|
373
|
+
// runs anything, so the hook dies with a message about nothing in
|
|
374
|
+
// particular. bubblewrap plans leave this absent and use `--chdir`.
|
|
375
|
+
...(jail.cwd ? { cwd: jail.cwd } : {}),
|
|
376
|
+
env: hookChildEnv(
|
|
377
|
+
broker.socketPath,
|
|
378
|
+
remoteBroker.socketPath,
|
|
379
|
+
// The lint rides only on an unjailed run — its presence in the child's
|
|
380
|
+
// environment is the shim's signal to install it (task 4.7).
|
|
381
|
+
jail.mode === 'unjailed' ? mountSet : undefined,
|
|
382
|
+
),
|
|
330
383
|
stdout: 'pipe',
|
|
331
384
|
stderr: 'pipe',
|
|
332
385
|
});
|
|
@@ -346,8 +399,10 @@ export async function executeHookScript(
|
|
|
346
399
|
killedFor = reason;
|
|
347
400
|
// Refuse capability calls FIRST. A hook between `kill` and its own death
|
|
348
401
|
// can still have a call in flight, and answering it is exactly the
|
|
349
|
-
// failure this replaces.
|
|
402
|
+
// failure this replaces. The remote channel refuses for the same reason:
|
|
403
|
+
// a killed hook must not go on reaching systems (celilo#1003, D12).
|
|
350
404
|
broker.stop();
|
|
405
|
+
remoteBroker?.stop();
|
|
351
406
|
child.kill('SIGTERM');
|
|
352
407
|
setTimeout(() => child.kill('SIGKILL'), SIGKILL_GRACE_MS).unref();
|
|
353
408
|
};
|
|
@@ -398,10 +453,41 @@ export async function executeHookScript(
|
|
|
398
453
|
if (!outcome.ok) throw deserializeError(outcome.error);
|
|
399
454
|
return outcome.outputs;
|
|
400
455
|
} finally {
|
|
456
|
+
// Remote first: its socket file lives inside the directory the capability
|
|
457
|
+
// broker's close removes.
|
|
458
|
+
remoteBroker?.close();
|
|
401
459
|
broker.close();
|
|
402
460
|
}
|
|
403
461
|
}
|
|
404
462
|
|
|
463
|
+
/**
|
|
464
|
+
* The local directories this run's stream primitives may touch (design D12).
|
|
465
|
+
*
|
|
466
|
+
* Derived from the same inputs as the jail's mount set, which is what keeps
|
|
467
|
+
* the two views from drifting: everything writable here is writable there,
|
|
468
|
+
* and a path outside these roots is a path the jailed hook could not see
|
|
469
|
+
* anyway — the broker writing to it would produce a file the hook cannot
|
|
470
|
+
* read, reported as success.
|
|
471
|
+
*
|
|
472
|
+
* Planning function (Rule 10.4) — pure.
|
|
473
|
+
*/
|
|
474
|
+
function remoteRoots(
|
|
475
|
+
context: HookContext,
|
|
476
|
+
jail: HookJailInputs | undefined,
|
|
477
|
+
): { readable: string[]; writable: string[] } {
|
|
478
|
+
const writable: string[] = [context.stateDir, context.screenshotDir];
|
|
479
|
+
const readable: string[] = [];
|
|
480
|
+
if (jail) {
|
|
481
|
+
readable.push(jail.modulePath);
|
|
482
|
+
writable.push(join(jail.modulePath, 'generated'));
|
|
483
|
+
for (const input of jail.pathInputs) {
|
|
484
|
+
(input.access === 'write' ? writable : readable).push(input.value);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// Whatever a hook may write, it may also read back.
|
|
488
|
+
return { readable: [...readable, ...writable], writable };
|
|
489
|
+
}
|
|
490
|
+
|
|
405
491
|
/**
|
|
406
492
|
* The child's environment, built from an allow-list (design D5).
|
|
407
493
|
*
|
|
@@ -422,7 +508,11 @@ export async function executeHookScript(
|
|
|
422
508
|
*
|
|
423
509
|
* Planning function (Rule 10.4) — pure.
|
|
424
510
|
*/
|
|
425
|
-
export function hookChildEnv(
|
|
511
|
+
export function hookChildEnv(
|
|
512
|
+
socketPath: string,
|
|
513
|
+
remoteSocketPath: string,
|
|
514
|
+
mountSet: MountSet | undefined,
|
|
515
|
+
): Record<string, string> {
|
|
426
516
|
const env: Record<string, string> = {};
|
|
427
517
|
|
|
428
518
|
for (const name of FORWARDED_ENV) {
|
|
@@ -431,6 +521,10 @@ export function hookChildEnv(socketPath: string): Record<string, string> {
|
|
|
431
521
|
}
|
|
432
522
|
|
|
433
523
|
env[HOOK_SOCKET_ENV] = socketPath;
|
|
524
|
+
env[HOOK_REMOTE_SOCKET_ENV] = remoteSocketPath;
|
|
525
|
+
// The unjailed advisory lint's input (task 4.7). Absent when jailed — the
|
|
526
|
+
// shim installs the lint when and only when this variable is set.
|
|
527
|
+
if (mountSet) env[HOOK_MOUNT_SET_ENV] = mountSetEnvValue(mountSet);
|
|
434
528
|
env[HOOK_PROTOCOL_VERSION_ENV] = String(HOOK_PROTOCOL_VERSION);
|
|
435
529
|
// Forwarded when the operator set it, not synthesised from `debug` — a hook
|
|
436
530
|
// reads the flag off `ctx.debug`, which crosses in the context frame.
|
|
@@ -465,10 +559,6 @@ function jailRequest(
|
|
|
465
559
|
runnerPath: HOOK_RUNNER_PATH,
|
|
466
560
|
runtimeModulePaths: runtimeModulePathsFor(HOOK_RUNNER_PATH),
|
|
467
561
|
pathInputs: inputs.pathInputs,
|
|
468
|
-
// Stage 2 only. `remote.ts` still runs inside the hook and needs the key;
|
|
469
|
-
// stage 3 brokers those calls and drops this row (D9, D12). Dropping it
|
|
470
|
-
// early hardens nothing — it stops every hook reaching its own systems.
|
|
471
|
-
sshDir: join(homedir(), '.ssh'),
|
|
472
562
|
};
|
|
473
563
|
}
|
|
474
564
|
|
|
@@ -544,6 +634,14 @@ export interface InvokeHookOptions {
|
|
|
544
634
|
* manifest hook's own `timeout`, being the more specific declaration.
|
|
545
635
|
*/
|
|
546
636
|
timeoutMs?: number;
|
|
637
|
+
/**
|
|
638
|
+
* D12's target policy for the hook's remote operations, built by the caller
|
|
639
|
+
* via `remoteAccessPolicy(moduleId, db)` — the same caller-loads-it pattern
|
|
640
|
+
* as `systems`, and for the same reason: the executor stays decoupled from
|
|
641
|
+
* the database. Absent means every fleet-credential remote operation is
|
|
642
|
+
* refused (Rule 6.4).
|
|
643
|
+
*/
|
|
644
|
+
remoteAccess?: RemoteAccessPolicy;
|
|
547
645
|
}
|
|
548
646
|
|
|
549
647
|
/**
|
|
@@ -846,6 +944,7 @@ export async function invokeHook(
|
|
|
846
944
|
timeoutMs,
|
|
847
945
|
idleTimeoutMs,
|
|
848
946
|
jail: { modulePath, pathInputs: declaredPathInputs(signature, inputs) },
|
|
947
|
+
remoteAccess: options.remoteAccess,
|
|
849
948
|
});
|
|
850
949
|
|
|
851
950
|
// Validate outputs against the contract signature
|