@yourrealm/sdk 0.0.0-alpha.1378.ce03c8dc
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/LICENSE-EXCEPTION +26 -0
- package/README.md +34 -0
- package/dist/chunk-3R6KEFOI.js +22 -0
- package/dist/chunk-67BDAMYX.js +376 -0
- package/dist/chunk-AITGS27R.js +139 -0
- package/dist/chunk-CMAEHQ4F.js +6 -0
- package/dist/chunk-MLKGABMK.js +9 -0
- package/dist/chunk-O3UIK34B.js +1430 -0
- package/dist/chunk-VHTUSZUX.js +176 -0
- package/dist/cli.js +9006 -0
- package/dist/docker.js +54 -0
- package/dist/index.js +1053 -0
- package/dist/ingress.js +436 -0
- package/dist/jsx-runtime.js +2 -0
- package/dist/preact.js +22 -0
- package/dist/service-url.js +11 -0
- package/dist/services.js +213 -0
- package/dist/test.js +5069 -0
- package/dist/ui.js +254 -0
- package/dist/worker-protocol.js +89 -0
- package/package.json +112 -0
- package/src/app-icon.ts +74 -0
- package/src/apply.ts +52 -0
- package/src/bundle-ui.ts +211 -0
- package/src/bundle.ts +546 -0
- package/src/cli/build-dev-landing.ts +84 -0
- package/src/cli/build-install-panel.ts +90 -0
- package/src/cli/build-runtime.ts +133 -0
- package/src/cli/build.ts +63 -0
- package/src/cli/dev.ts +107 -0
- package/src/cli/down.ts +60 -0
- package/src/cli/main.ts +56 -0
- package/src/cli/test.ts +61 -0
- package/src/cli/validate.ts +37 -0
- package/src/compile.ts +100 -0
- package/src/create-app.ts +982 -0
- package/src/deep-merge.ts +33 -0
- package/src/dev/fixture.ts +48 -0
- package/src/dev/install-handler.ts +383 -0
- package/src/dev/server.ts +968 -0
- package/src/dev/stack.ts +108 -0
- package/src/dev/state.ts +159 -0
- package/src/docker.ts +96 -0
- package/src/errors.ts +61 -0
- package/src/factory.ts +481 -0
- package/src/garage.ts +56 -0
- package/src/generator/hash.ts +6 -0
- package/src/generator/resolve-refs.ts +207 -0
- package/src/grants-builder.ts +100 -0
- package/src/grants.ts +260 -0
- package/src/home-api.ts +172 -0
- package/src/home-cookie.ts +10 -0
- package/src/home-registry.ts +120 -0
- package/src/home.ts +187 -0
- package/src/host-registry.ts +143 -0
- package/src/http-header.ts +20 -0
- package/src/identifiers.ts +49 -0
- package/src/index.ts +66 -0
- package/src/info.ts +64 -0
- package/src/ini.ts +50 -0
- package/src/json.ts +14 -0
- package/src/jsx-runtime/index.ts +5 -0
- package/src/keys.ts +29 -0
- package/src/mysql.ts +56 -0
- package/src/pack-build.ts +108 -0
- package/src/pack.ts +115 -0
- package/src/peer-registry.ts +121 -0
- package/src/peer-world.ts +117 -0
- package/src/permission-meta.ts +116 -0
- package/src/permissions.ts +108 -0
- package/src/pkg-root.ts +34 -0
- package/src/postgres.ts +79 -0
- package/src/preact/index.ts +27 -0
- package/src/redis.ts +43 -0
- package/src/ref.ts +271 -0
- package/src/remote-registry.ts +33 -0
- package/src/required-host-kinds.ts +110 -0
- package/src/resolve-context.ts +111 -0
- package/src/resolve.ts +471 -0
- package/src/runtime/docker/compose/compose-schema.ts +118 -0
- package/src/runtime/docker/compose/convert-to-docker-compose.ts +278 -0
- package/src/runtime/docker/compose/default-hardening.ts +21 -0
- package/src/runtime/docker/compose/effects.ts +107 -0
- package/src/runtime/docker/compose/index.ts +17 -0
- package/src/runtime/docker/compose/labels.ts +39 -0
- package/src/runtime/docker/compose/types.ts +78 -0
- package/src/runtime/docker/home-proxy-server/index.ts +177 -0
- package/src/runtime/docker/home-proxy-server/protocol.ts +53 -0
- package/src/runtime/docker/ingress/build-config.ts +819 -0
- package/src/runtime/docker/ingress/index.ts +16 -0
- package/src/runtime/docker/ingress/types.ts +182 -0
- package/src/runtime/docker/log-frames.ts +121 -0
- package/src/runtime/docker/logs-server/index.ts +366 -0
- package/src/runtime/docker/spawn-server/context-key.ts +82 -0
- package/src/runtime/docker/spawn-server/index.ts +492 -0
- package/src/runtime/docker/spawn-server/protocol.ts +70 -0
- package/src/runtime/docker/spawn-server/spec.ts +122 -0
- package/src/runtime/docker/stats-server/disks.ts +161 -0
- package/src/runtime/docker/stats-server/index.ts +322 -0
- package/src/runtime/home-client/index.ts +71 -0
- package/src/runtime/log-line.ts +24 -0
- package/src/runtime/logs-client/index.ts +175 -0
- package/src/runtime/spawn-client/index.ts +93 -0
- package/src/runtime/spawn-client/kaniko.ts +79 -0
- package/src/runtime/stats-client/index.ts +65 -0
- package/src/runtime/stats-sample.ts +95 -0
- package/src/safe-json.ts +37 -0
- package/src/sandbox.ts +1209 -0
- package/src/schema/build-apps.ts +38 -0
- package/src/schema/home.ts +217 -0
- package/src/schema/host.ts +792 -0
- package/src/schema/inputs.ts +26 -0
- package/src/schema/nodes.ts +75 -0
- package/src/schema/peer.ts +400 -0
- package/src/schema/public-domain.ts +116 -0
- package/src/schema/remote.ts +74 -0
- package/src/security.ts +594 -0
- package/src/serialize.ts +21 -0
- package/src/service-definition.ts +284 -0
- package/src/service-url.ts +64 -0
- package/src/service.ts +39 -0
- package/src/services/index.ts +5 -0
- package/src/shared.ts +65 -0
- package/src/speculative-silo.ts +96 -0
- package/src/stable-secret.ts +14 -0
- package/src/surfaces.ts +264 -0
- package/src/tailwind-bin.ts +56 -0
- package/src/test/boot.ts +192 -0
- package/src/test/compile.ts +501 -0
- package/src/test/describe.ts +117 -0
- package/src/test/index.ts +11 -0
- package/src/test/ingress-fixture.ts +37 -0
- package/src/test/types.ts +318 -0
- package/src/trusted-headers.ts +79 -0
- package/src/types.ts +126 -0
- package/src/ui/components/aside.tsx +96 -0
- package/src/ui/components/badge.tsx +42 -0
- package/src/ui/components/button.tsx +59 -0
- package/src/ui/components/card.tsx +106 -0
- package/src/ui/components/checkbox.tsx +37 -0
- package/src/ui/components/code-block.tsx +29 -0
- package/src/ui/components/combobox.tsx +119 -0
- package/src/ui/components/config-field.tsx +284 -0
- package/src/ui/components/dialog.tsx +137 -0
- package/src/ui/components/domain-editor.tsx +317 -0
- package/src/ui/components/domain-list.ts +205 -0
- package/src/ui/components/dropdown-menu.tsx +73 -0
- package/src/ui/components/empty-state.tsx +51 -0
- package/src/ui/components/highlight.ts +59 -0
- package/src/ui/components/icons.tsx +101 -0
- package/src/ui/components/image-posture.tsx +85 -0
- package/src/ui/components/input.tsx +28 -0
- package/src/ui/components/label.tsx +16 -0
- package/src/ui/components/line-chart.tsx +113 -0
- package/src/ui/components/page.tsx +39 -0
- package/src/ui/components/permission-row.tsx +262 -0
- package/src/ui/components/permission-section.tsx +51 -0
- package/src/ui/components/pie-chart.tsx +245 -0
- package/src/ui/components/schema-block.tsx +60 -0
- package/src/ui/components/separator.tsx +42 -0
- package/src/ui/components/sheet.tsx +130 -0
- package/src/ui/components/sidebar.tsx +650 -0
- package/src/ui/components/silo-picker.tsx +510 -0
- package/src/ui/components/skeleton.tsx +13 -0
- package/src/ui/components/sonner.tsx +33 -0
- package/src/ui/components/switch.tsx +58 -0
- package/src/ui/components/table-view.ts +219 -0
- package/src/ui/components/table.tsx +506 -0
- package/src/ui/components/tabs.tsx +59 -0
- package/src/ui/components/timeago.tsx +84 -0
- package/src/ui/components/tooltip.tsx +56 -0
- package/src/ui/components.json +21 -0
- package/src/ui/dev/dev-shell.tsx +145 -0
- package/src/ui/home/home-grid.tsx +177 -0
- package/src/ui/hooks/use-mobile.ts +19 -0
- package/src/ui/index.ts +189 -0
- package/src/ui/install/adapter.ts +193 -0
- package/src/ui/install/deploy-targets-picker.tsx +185 -0
- package/src/ui/install/install-app-panel.tsx +912 -0
- package/src/ui/install/install-error.tsx +62 -0
- package/src/ui/install/perms-inactive.ts +50 -0
- package/src/ui/install/required-fields.ts +37 -0
- package/src/ui/install/tabs-form.tsx +557 -0
- package/src/ui/tokens.css +279 -0
- package/src/ui/utils.ts +78 -0
- package/src/ui-error.ts +139 -0
- package/src/ui-runtime/dev-landing-entry.tsx +258 -0
- package/src/ui-runtime/index.ts +83 -0
- package/src/ui-runtime/install-entry.tsx +119 -0
- package/src/ui-runtime/mount.ts +367 -0
- package/src/ui-runtime/runtime.css +56 -0
- package/src/ui-surface.ts +451 -0
- package/src/valkey.ts +43 -0
- package/src/worker/extract-tar.ts +125 -0
- package/src/worker/identity.ts +183 -0
- package/src/worker/index.ts +16 -0
- package/src/worker/public-ip.ts +92 -0
- package/src/worker/queue.ts +54 -0
- package/src/worker/reconcile.ts +127 -0
- package/src/worker/start-worker.ts +227 -0
- package/src/worker/ui-proxy.ts +335 -0
- package/src/worker/uplink.ts +523 -0
- package/src/worker/validate.ts +34 -0
- package/src/worker/verify-relay.ts +102 -0
- package/src/worker-protocol/app-dirs.ts +94 -0
- package/src/worker-protocol/home-proxy-paths.ts +76 -0
- package/src/worker-protocol/index.ts +55 -0
- package/src/worker-protocol/ingress.ts +166 -0
- package/src/worker-protocol/logs-socket-path.ts +50 -0
- package/src/worker-protocol/networks.ts +39 -0
- package/src/worker-protocol/node-version.ts +70 -0
- package/src/worker-protocol/silo-paths.ts +114 -0
- package/src/worker-protocol/spawn-paths.ts +165 -0
- package/src/worker-protocol/stats-socket-path.ts +40 -0
- package/src/worker-protocol/update-files.ts +77 -0
- package/src/worker-protocol/uplink/constants.ts +86 -0
- package/src/worker-protocol/uplink/errors.ts +61 -0
- package/src/worker-protocol/uplink/frame.ts +114 -0
- package/src/worker-protocol/uplink/identity.ts +40 -0
- package/src/worker-protocol/uplink/index.ts +63 -0
- package/src/worker-protocol/uplink/methods.ts +462 -0
- package/src/worker-protocol/uplink/mux.ts +646 -0
- package/src/worker-protocol/uplink/protocol.ts +11 -0
- package/src/worker-protocol/uplink/rpc.ts +309 -0
- package/src/worker-protocol/uplink/session.ts +540 -0
- package/src/worker-protocol/uplink/test-support.ts +188 -0
- package/src/worker-protocol/worker-mount.ts +27 -0
- package/src/worker-protocol/worker-state.ts +190 -0
- package/src/xml.ts +49 -0
- package/src/yaml.ts +15 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The broker's mount defense, split into the two halves the back-room seal
|
|
2
|
+
// runs at two different times:
|
|
3
|
+
//
|
|
4
|
+
// 1. validateContextKeyShape(key) — string-only. Runs FIRST, before any fs
|
|
5
|
+
// touch, on the caller-supplied key. Rejects every traversal / absolute /
|
|
6
|
+
// blocklist shape so the only thing we ever `rename` is a clean single
|
|
7
|
+
// segment under the scratch root.
|
|
8
|
+
// 2. assertSealedDir(dir, root) — real-filesystem. Runs AFTER the broker has
|
|
9
|
+
// renamed that segment into its per-box back room, on the MOVED inode.
|
|
10
|
+
// Rejects a symlink and confirms the real path stays inside the back room.
|
|
11
|
+
//
|
|
12
|
+
// The split is what makes the seal sound: a string check can't stop a symlink
|
|
13
|
+
// planted in the shared scratch root (Docker follows symlinks in a bind source
|
|
14
|
+
// at mount time, so `spawn/evil -> /var/run/docker.sock` would escape), and a
|
|
15
|
+
// real-fs check done *before* the rename is a TOCTOU (the caller can swap the
|
|
16
|
+
// validated dir for a symlink in the validate→mount window). Renaming the
|
|
17
|
+
// segment into a dir the caller can't reach, then re-validating the moved
|
|
18
|
+
// inode, closes that window — `rename(2)` never follows symlinks on either
|
|
19
|
+
// path and is atomic w.r.t. concurrent fs ops.
|
|
20
|
+
|
|
21
|
+
import { isValidHostDirectoryPath } from "../../../schema/host.ts";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Shape-check a caller-supplied context key as text only — no filesystem
|
|
25
|
+
* access. `key` must be a single, clean path segment: non-empty, not `.` or
|
|
26
|
+
* `..`, no separators, no `..` substring. As defense in depth it must also
|
|
27
|
+
* clear the same dangerous-root blocklist operator-set Silo paths use
|
|
28
|
+
* (so a segment named `etc`, `proc`, … is refused even though joining it under
|
|
29
|
+
* the scratch root would be harmless). Those segment checks ARE the logical
|
|
30
|
+
* confinement: a key with no separator and no `..` cannot escape whatever root
|
|
31
|
+
* it's joined under. Throws on any violation; the broker turns the throw into a
|
|
32
|
+
* terminal error line. Runs before the rename, so a bad key never moves a file.
|
|
33
|
+
*/
|
|
34
|
+
export function validateContextKeyShape(key: string): void {
|
|
35
|
+
if (!key || key === "." || key === "..") {
|
|
36
|
+
throw new Error(`invalid context key: ${JSON.stringify(key)}`);
|
|
37
|
+
}
|
|
38
|
+
if (key.includes("/") || key.includes("\\")) {
|
|
39
|
+
throw new Error(`context key must be a single path segment: ${JSON.stringify(key)}`);
|
|
40
|
+
}
|
|
41
|
+
if (key.includes("..")) {
|
|
42
|
+
throw new Error(`context key must not contain "..": ${JSON.stringify(key)}`);
|
|
43
|
+
}
|
|
44
|
+
// Mirror the host-path blocklist: a key that maps onto a dangerous root
|
|
45
|
+
// (e.g. `etc` → `/etc`) is refused even though joining it under the scratch
|
|
46
|
+
// root would be harmless.
|
|
47
|
+
if (!isValidHostDirectoryPath(`/${key}`)) {
|
|
48
|
+
throw new Error(`context key resolves to a blocked root: ${JSON.stringify(key)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Real-filesystem check on a directory the broker has just renamed into its
|
|
54
|
+
* per-box back room, confirming it's safe to hand to Docker as a bind source.
|
|
55
|
+
* `dir` must exist, must NOT be a symlink, and its canonical real path must
|
|
56
|
+
* stay inside the canonical `root` (the back-room box dir). Throws otherwise.
|
|
57
|
+
*
|
|
58
|
+
* This is the post-rename half of the seal. If the caller planted a symlink at
|
|
59
|
+
* `scratch/<key>`, the broker's `rename` moved the *symlink object* in, so the
|
|
60
|
+
* `lstat` here catches it. A swap racing the rename either landed before it
|
|
61
|
+
* (caught here) or after (on a path no longer in scratch — this inode is
|
|
62
|
+
* already sealed). Docker follows symlinks in a bind source at mount time, so
|
|
63
|
+
* an un-caught symlink (`-> /` or `-> /var/run/docker.sock`) would be a full
|
|
64
|
+
* host escape; rejecting it here, on a path the caller has no mount into, is
|
|
65
|
+
* what closes the window between validation and mount.
|
|
66
|
+
*/
|
|
67
|
+
export async function assertSealedDir(dir: string, root: string): Promise<void> {
|
|
68
|
+
const realRoot = await Deno.realPath(root);
|
|
69
|
+
let info: Deno.FileInfo;
|
|
70
|
+
try {
|
|
71
|
+
info = await Deno.lstat(dir);
|
|
72
|
+
} catch {
|
|
73
|
+
throw new Error(`sealed context not found: ${JSON.stringify(dir)}`);
|
|
74
|
+
}
|
|
75
|
+
if (info.isSymlink) {
|
|
76
|
+
throw new Error(`sealed context must not be a symlink: ${JSON.stringify(dir)}`);
|
|
77
|
+
}
|
|
78
|
+
const real = await Deno.realPath(dir);
|
|
79
|
+
if (real !== realRoot && !real.startsWith(`${realRoot}/`)) {
|
|
80
|
+
throw new Error(`sealed context escapes the box dir: ${JSON.stringify(dir)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
// Worker-side `spawn.sock` broker. Binds a unix socket; a granted app
|
|
2
|
+
// connects, sends one request line, and gets back a JSON Lines stream of
|
|
3
|
+
// its box's output plus one terminal line. One connection == one build.
|
|
4
|
+
//
|
|
5
|
+
// The broker is the one path that bypasses Home eval, so it re-imposes
|
|
6
|
+
// every guarantee the compose path gets from Home: it CONSTRUCTS a fixed,
|
|
7
|
+
// fully-hardened container spec (spec.ts) from a tiny allowlist of params
|
|
8
|
+
// and never accepts a HostConfig, an absolute mount, or a network name
|
|
9
|
+
// from the caller. `spawn.sock` is strictly less powerful than
|
|
10
|
+
// `docker.sock`. Treat this as the highest-scrutiny code in the worker.
|
|
11
|
+
|
|
12
|
+
import { dirname, join } from "@std/path";
|
|
13
|
+
import { KINDS, LABELS, ode } from "@local/docker";
|
|
14
|
+
import {
|
|
15
|
+
spawnArtifactsPath,
|
|
16
|
+
spawnPrivatePath,
|
|
17
|
+
spawnScratchPath,
|
|
18
|
+
} from "../../../worker-protocol/spawn-paths.ts";
|
|
19
|
+
import { UNTRUSTED_NET } from "../../../worker-protocol/networks.ts";
|
|
20
|
+
import { dechunkedReader, streamDockerLogFrames } from "../log-frames.ts";
|
|
21
|
+
import {
|
|
22
|
+
spawnRequestSchema,
|
|
23
|
+
type SpawnLogLine,
|
|
24
|
+
type SpawnRequest,
|
|
25
|
+
type SpawnTerminal,
|
|
26
|
+
} from "./protocol.ts";
|
|
27
|
+
import { assertSealedDir, validateContextKeyShape } from "./context-key.ts";
|
|
28
|
+
import { buildSpawnSpec, type SpawnLimits } from "./spec.ts";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The slice of the Docker layer the build path uses. Threaded through
|
|
32
|
+
* {@link BuildConfig} (defaulting to the real `ode`) so a unit test can drive
|
|
33
|
+
* `runBuild` with a mock and assert the seal's ordering without a live daemon —
|
|
34
|
+
* a TOCTOU race can't be reliably won against real Docker, so that coverage
|
|
35
|
+
* lives here, not in e2e.
|
|
36
|
+
*/
|
|
37
|
+
export type SpawnOde = Pick<
|
|
38
|
+
typeof ode,
|
|
39
|
+
| "inspectImage"
|
|
40
|
+
| "pullImage"
|
|
41
|
+
| "createContainer"
|
|
42
|
+
| "startContainer"
|
|
43
|
+
| "followContainerLogs"
|
|
44
|
+
| "waitContainer"
|
|
45
|
+
| "removeContainer"
|
|
46
|
+
>;
|
|
47
|
+
|
|
48
|
+
// Conservative per-box resource caps. Env-overridable so an operator can lift
|
|
49
|
+
// them for a heavy build without a code change.
|
|
50
|
+
const DEFAULT_MEMORY_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB
|
|
51
|
+
const DEFAULT_NANO_CPUS = 2_000_000_000; // 2 cores
|
|
52
|
+
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000; // 30 min wall-clock per build
|
|
53
|
+
// Caps the raw request line so a hostile caller can't exhaust memory
|
|
54
|
+
// before the schema runs.
|
|
55
|
+
const MAX_REQUEST_BYTES = 64 * 1024;
|
|
56
|
+
// A caller must send its (small) request line within this window; bounds
|
|
57
|
+
// a slow-loris that connects and stalls to pin a connection/fd. The
|
|
58
|
+
// build wall-clock starts only after the request is parsed, so it does
|
|
59
|
+
// not cover this phase.
|
|
60
|
+
const REQUEST_READ_TIMEOUT_MS = 30 * 1000;
|
|
61
|
+
|
|
62
|
+
const encoder = new TextEncoder();
|
|
63
|
+
const decoder = new TextDecoder();
|
|
64
|
+
|
|
65
|
+
interface StartOpts {
|
|
66
|
+
/**
|
|
67
|
+
* App this broker fronts. Identity is the socket the request arrived on,
|
|
68
|
+
* never the wire — the broker derives this app's scratch dir from the
|
|
69
|
+
* appId, so a caller can't reach another app's tree. Mirrors
|
|
70
|
+
* `home-proxy-server`'s `opts.appId`.
|
|
71
|
+
*/
|
|
72
|
+
appId: string;
|
|
73
|
+
/**
|
|
74
|
+
* Host path to bind this app's spawn socket at. The worker passes the
|
|
75
|
+
* per-app `spawn/sock/<appId>/spawn.sock` path (the socket sits alone in its
|
|
76
|
+
* own per-app dir; the server mkdirs the dir before binding).
|
|
77
|
+
*/
|
|
78
|
+
socketPath: string;
|
|
79
|
+
/** Per-box resource caps. Defaults read from env, then constants. */
|
|
80
|
+
limits?: SpawnLimits;
|
|
81
|
+
/** Per-build wall-clock budget, ms. Defaults from env, then constant. */
|
|
82
|
+
timeoutMs?: number;
|
|
83
|
+
/** Override the scratch root. Defaults to `spawnScratchPath(appId)`. For tests. */
|
|
84
|
+
scratchRoot?: string;
|
|
85
|
+
/** Override the artifacts root. Defaults to `spawnArtifactsPath(appId)`. For tests. */
|
|
86
|
+
artifactsRoot?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Override the broker-private back-room root. Defaults to
|
|
89
|
+
* `spawnPrivatePath(appId)`. For tests.
|
|
90
|
+
*/
|
|
91
|
+
privateRoot?: string;
|
|
92
|
+
/**
|
|
93
|
+
* Test seam: fired once in the validate→mount window — after the seal renames
|
|
94
|
+
* the context into the back room, before the box is created. Lets a test
|
|
95
|
+
* attempt a swap in that window and confirm it hits a path the box never
|
|
96
|
+
* mounts. Undefined in production.
|
|
97
|
+
*/
|
|
98
|
+
beforeMount?: () => Promise<void>;
|
|
99
|
+
/** Injectable Docker layer. Defaults to the real `ode`. For tests. */
|
|
100
|
+
ode?: SpawnOde;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface ServerHandle {
|
|
104
|
+
/** Stop the server: close the listener, drop active conns, remove the socket file. */
|
|
105
|
+
stop(): Promise<void>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface BuildConfig {
|
|
109
|
+
/** The app this broker fronts — stamped on each box as `realm.spawned-by`. */
|
|
110
|
+
appId: string;
|
|
111
|
+
scratchRoot: string;
|
|
112
|
+
artifactsRoot: string;
|
|
113
|
+
/** Broker-private back room: per-box subdirs hold the sealed bind sources. */
|
|
114
|
+
privateRoot: string;
|
|
115
|
+
limits: SpawnLimits;
|
|
116
|
+
timeoutMs: number;
|
|
117
|
+
ode: SpawnOde;
|
|
118
|
+
beforeMount?: () => Promise<void>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Bind one app's spawn broker socket. The global infra (the untrusted-net DMZ
|
|
123
|
+
* + orphaned-box GC) is set up once at boot by {@link bootSpawnInfra}, NOT
|
|
124
|
+
* here — this runs per granted app on every reconcile, so it must stay free of
|
|
125
|
+
* host-wide side effects. Mirrors `startHomeProxyServer`.
|
|
126
|
+
*/
|
|
127
|
+
export async function startSpawnServer(opts: StartOpts): Promise<ServerHandle> {
|
|
128
|
+
const { appId, socketPath } = opts;
|
|
129
|
+
const scratchRoot = opts.scratchRoot ?? spawnScratchPath(appId);
|
|
130
|
+
const artifactsRoot = opts.artifactsRoot ?? spawnArtifactsPath(appId);
|
|
131
|
+
const privateRoot = opts.privateRoot ?? spawnPrivatePath(appId);
|
|
132
|
+
const odeApi = opts.ode ?? ode;
|
|
133
|
+
const limits = opts.limits ?? {
|
|
134
|
+
memoryBytes: envInt("REALM_SPAWN_MEMORY_BYTES", DEFAULT_MEMORY_BYTES),
|
|
135
|
+
nanoCpus: envInt("REALM_SPAWN_NANO_CPUS", DEFAULT_NANO_CPUS),
|
|
136
|
+
};
|
|
137
|
+
const timeoutMs = opts.timeoutMs ?? envInt("REALM_SPAWN_TIMEOUT_MS", DEFAULT_TIMEOUT_MS);
|
|
138
|
+
|
|
139
|
+
// Per-app scratch (input) + artifacts (output) + private (the back room)
|
|
140
|
+
// dirs. Created if missing and otherwise left alone, contents included: the
|
|
141
|
+
// first two are bind-mount sources a running app holds by inode, so a rebind
|
|
142
|
+
// across a worker restart must find the same dirs. The worker's reconcile
|
|
143
|
+
// removes the whole `data/<appId>` subtree once the app is off the wire. All
|
|
144
|
+
// three are siblings under `data/<appId>/` so the seal's renames stay same-fs.
|
|
145
|
+
await Deno.mkdir(scratchRoot, { recursive: true }).catch(() => {});
|
|
146
|
+
await Deno.mkdir(artifactsRoot, { recursive: true }).catch(() => {});
|
|
147
|
+
await Deno.mkdir(privateRoot, { recursive: true }).catch(() => {});
|
|
148
|
+
|
|
149
|
+
// The socket lives alone in its own per-app dir under `spawn/sock/<appId>`
|
|
150
|
+
// (the app mounts the dir, not the socket file — a file bind-mount of a unix
|
|
151
|
+
// socket doesn't wire across containers on macOS Docker). Ensure it exists
|
|
152
|
+
// before binding.
|
|
153
|
+
await Deno.mkdir(dirname(socketPath), { recursive: true }).catch(() => {});
|
|
154
|
+
await Deno.remove(socketPath).catch(() => {});
|
|
155
|
+
const listener = Deno.listen({ transport: "unix", path: socketPath });
|
|
156
|
+
const conns = new Set<Deno.Conn>();
|
|
157
|
+
|
|
158
|
+
void acceptLoop(listener, conns, {
|
|
159
|
+
appId,
|
|
160
|
+
scratchRoot,
|
|
161
|
+
artifactsRoot,
|
|
162
|
+
privateRoot,
|
|
163
|
+
limits,
|
|
164
|
+
timeoutMs,
|
|
165
|
+
ode: odeApi,
|
|
166
|
+
beforeMount: opts.beforeMount,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
async stop() {
|
|
171
|
+
try {
|
|
172
|
+
listener.close();
|
|
173
|
+
} catch {}
|
|
174
|
+
for (const c of conns) {
|
|
175
|
+
try {
|
|
176
|
+
c.close();
|
|
177
|
+
} catch {}
|
|
178
|
+
}
|
|
179
|
+
conns.clear();
|
|
180
|
+
await Deno.remove(socketPath).catch(() => {});
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Boot-once global spawn infra, independent of any app: ensure the shared
|
|
187
|
+
* untrusted-net DMZ exists, and force-remove every spawn box orphaned by a
|
|
188
|
+
* previous worker process (a restart mid-build leaves the box running, and
|
|
189
|
+
* every spawn-box-kind container is disposable). Split out of the per-app
|
|
190
|
+
* {@link startSpawnServer} so these host-wide steps run exactly once at boot,
|
|
191
|
+
* not once per granted app per reconcile. The per-app socket + tree (scratch +
|
|
192
|
+
* artifacts) is reclaimed worker-side by `reconcileSpawnSockets` once the app
|
|
193
|
+
* is off the wire.
|
|
194
|
+
*/
|
|
195
|
+
export async function bootSpawnInfra(): Promise<void> {
|
|
196
|
+
// Idempotent create (mirrors management-net) keeps the broker self-sufficient
|
|
197
|
+
// regardless of install ordering.
|
|
198
|
+
await ensureUntrustedNet();
|
|
199
|
+
await gcStaleBoxes();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function acceptLoop(
|
|
203
|
+
listener: Deno.Listener,
|
|
204
|
+
conns: Set<Deno.Conn>,
|
|
205
|
+
cfg: BuildConfig,
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
try {
|
|
208
|
+
for await (const conn of listener) {
|
|
209
|
+
conns.add(conn);
|
|
210
|
+
handleConnection(conn as Deno.UnixConn, cfg).finally(() => {
|
|
211
|
+
conns.delete(conn);
|
|
212
|
+
try {
|
|
213
|
+
conn.close();
|
|
214
|
+
} catch {}
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
// Listener was closed via stop().
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// One connection == one build. Any caller or build failure is turned
|
|
223
|
+
// into a terminal error line; nothing throws across the accept loop.
|
|
224
|
+
async function handleConnection(conn: Deno.UnixConn, cfg: BuildConfig): Promise<void> {
|
|
225
|
+
const writer = conn.writable.getWriter();
|
|
226
|
+
let terminal: SpawnTerminal;
|
|
227
|
+
try {
|
|
228
|
+
// Bound the read so a stalled caller can't hold the conn open forever;
|
|
229
|
+
// on timeout the throw lands in the catch below and the finally closes
|
|
230
|
+
// the conn, which unblocks the pending read.
|
|
231
|
+
const req = await withTimeout(
|
|
232
|
+
readRequest(conn),
|
|
233
|
+
REQUEST_READ_TIMEOUT_MS,
|
|
234
|
+
"spawn request read timed out",
|
|
235
|
+
);
|
|
236
|
+
terminal = await runBuild(req, cfg, (line) => writeLine(writer, line));
|
|
237
|
+
} catch (err) {
|
|
238
|
+
terminal = { exit: -1, error: (err as Error).message ?? String(err) };
|
|
239
|
+
}
|
|
240
|
+
await writeLine(writer, terminal).catch(() => {});
|
|
241
|
+
try {
|
|
242
|
+
writer.releaseLock();
|
|
243
|
+
} catch {}
|
|
244
|
+
try {
|
|
245
|
+
conn.close();
|
|
246
|
+
} catch {}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Read one `\n`-terminated request line, bounded, then hardened-parse +
|
|
250
|
+
// Zod-validate. Mirrors the logs server's readSinceLine.
|
|
251
|
+
async function readRequest(conn: Deno.UnixConn): Promise<SpawnRequest> {
|
|
252
|
+
const buf = new Uint8Array(1);
|
|
253
|
+
const collected: number[] = [];
|
|
254
|
+
while (collected.length < MAX_REQUEST_BYTES) {
|
|
255
|
+
const n = await conn.read(buf);
|
|
256
|
+
if (n === null) break;
|
|
257
|
+
if (n === 0) continue;
|
|
258
|
+
if (buf[0] === 0x0a) break;
|
|
259
|
+
collected.push(buf[0]!);
|
|
260
|
+
}
|
|
261
|
+
if (collected.length >= MAX_REQUEST_BYTES) throw new Error("spawn request too large");
|
|
262
|
+
const raw = decoder.decode(new Uint8Array(collected)).trim();
|
|
263
|
+
if (!raw) throw new Error("empty spawn request");
|
|
264
|
+
return spawnRequestSchema.parse(JSON.parse(raw));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function runBuild(
|
|
268
|
+
req: SpawnRequest,
|
|
269
|
+
cfg: BuildConfig,
|
|
270
|
+
onLog: (line: SpawnLogLine) => Promise<void>,
|
|
271
|
+
): Promise<SpawnTerminal> {
|
|
272
|
+
// Shape-check the caller's key as text only, before the try — a bad key is a
|
|
273
|
+
// clean terminal error that created nothing on disk.
|
|
274
|
+
validateContextKeyShape(req.context);
|
|
275
|
+
|
|
276
|
+
// One id per box: names the container (`realm-spawn-${boxId}`), the
|
|
277
|
+
// broker-private box dir, and (with output) the artifacts subdir, so the
|
|
278
|
+
// three correlate. A FULL uuid, because these name directories where a
|
|
279
|
+
// collision would cross two builds.
|
|
280
|
+
const boxId = crypto.randomUUID();
|
|
281
|
+
const boxDir = join(cfg.privateRoot, boxId);
|
|
282
|
+
const ctx = join(boxDir, "ctx");
|
|
283
|
+
const out = req.output ? join(boxDir, "out") : undefined;
|
|
284
|
+
// Broker-minted basename the caller reads its output back under (success
|
|
285
|
+
// only); the box never names it. Build-only requests get none.
|
|
286
|
+
const outputKey = req.output ? `output-${boxId}` : undefined;
|
|
287
|
+
|
|
288
|
+
let id: string | undefined;
|
|
289
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
290
|
+
let timedOut = false;
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
// From here the finally owns boxDir: it holds `ctx` always, and `out` until
|
|
294
|
+
// the success rename below moves it into the artifacts tree.
|
|
295
|
+
await Deno.mkdir(boxDir, { recursive: true });
|
|
296
|
+
|
|
297
|
+
// THE SEAL. Move the caller's staged context out of the shared,
|
|
298
|
+
// caller-writable scratch root into this broker-private box dir — a dir the
|
|
299
|
+
// calling app has no mount into — then re-validate the MOVED inode. rename(2)
|
|
300
|
+
// never follows symlinks on either path and is atomic w.r.t. concurrent fs
|
|
301
|
+
// ops: a symlink the caller planted at scratch/<key> moves as the symlink
|
|
302
|
+
// OBJECT (caught by assertSealedDir below); a swap racing the rename lands
|
|
303
|
+
// either before it (caught) or after (on a path no longer in scratch — this
|
|
304
|
+
// inode is already sealed). Nothing the caller can reach can swap `ctx`
|
|
305
|
+
// between here and the Docker mount, so there is no validate→mount TOCTOU.
|
|
306
|
+
try {
|
|
307
|
+
await Deno.rename(join(cfg.scratchRoot, req.context), ctx);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
if (err instanceof Deno.errors.NotFound) {
|
|
310
|
+
throw new Error(`spawn context not found: ${JSON.stringify(req.context)}`);
|
|
311
|
+
}
|
|
312
|
+
// EXDEV: scratch and the back room are on different filesystems. NEVER
|
|
313
|
+
// fall back to a copy — a copy re-opens the very TOCTOU the rename closes.
|
|
314
|
+
// Fail loud so the operator co-locates the spawn data tree.
|
|
315
|
+
if (isCrossDevice(err)) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
"spawn private root must be on the same filesystem as scratch — " +
|
|
318
|
+
"both live under spawn/data/<app>/; do not mount them separately",
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
throw err;
|
|
322
|
+
}
|
|
323
|
+
await assertSealedDir(ctx, boxDir);
|
|
324
|
+
|
|
325
|
+
// The box's writable output dir, also inside the back room (broker-owned,
|
|
326
|
+
// broker-named — the caller never names it). Created only for an `output`
|
|
327
|
+
// run; moved into artifacts on success below.
|
|
328
|
+
if (out) await Deno.mkdir(out);
|
|
329
|
+
|
|
330
|
+
// Test seam: fires in the validate→mount window the seal above closes.
|
|
331
|
+
await cfg.beforeMount?.();
|
|
332
|
+
|
|
333
|
+
const spec = buildSpawnSpec(req, {
|
|
334
|
+
scratchHostPath: ctx,
|
|
335
|
+
outputHostPath: out,
|
|
336
|
+
limits: cfg.limits,
|
|
337
|
+
spawnedBy: cfg.appId,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Bound the pull on its own budget — the build wall-clock below can't cover
|
|
341
|
+
// it (no container to remove yet), so a tarpit registry behind a
|
|
342
|
+
// caller-chosen `image:` would otherwise hang here unbounded. A stall here
|
|
343
|
+
// widens no swap window: ctx/out are already sealed in the back room.
|
|
344
|
+
await withTimeout(ensureImage(cfg.ode, req.image), cfg.timeoutMs, "spawn image pull timed out");
|
|
345
|
+
|
|
346
|
+
const created = await cfg.ode.createContainer(`realm-spawn-${boxId}`, spec);
|
|
347
|
+
id = created.Id;
|
|
348
|
+
|
|
349
|
+
// Wall-clock cap on the whole run. On expiry we force-remove the box, which
|
|
350
|
+
// unblocks the log relay + wait below (their docker conns drop), so a
|
|
351
|
+
// hung/forking build can't pin a slot forever.
|
|
352
|
+
timer = setTimeout(() => {
|
|
353
|
+
timedOut = true;
|
|
354
|
+
cfg.ode.removeContainer(id!, { force: true }).catch(() => {});
|
|
355
|
+
}, cfg.timeoutMs);
|
|
356
|
+
|
|
357
|
+
await cfg.ode.startContainer(id);
|
|
358
|
+
await relayLogs(cfg.ode, id, onLog);
|
|
359
|
+
const result = await cfg.ode.waitContainer(id);
|
|
360
|
+
if (timedOut) return { exit: -1, error: "spawn build timed out" };
|
|
361
|
+
const exit = Number(result.StatusCode);
|
|
362
|
+
|
|
363
|
+
// Success: move the box's output OUT of the back room into the app's
|
|
364
|
+
// artifacts tree under the broker-minted key, then hand the key back. The
|
|
365
|
+
// box is already torn down (waitContainer resolved on its exit), so no live
|
|
366
|
+
// writer races this rename. On every failure path nothing reaches artifacts
|
|
367
|
+
// at all — the finally wipes the whole boxDir, `out` included.
|
|
368
|
+
if (out && outputKey) await Deno.rename(out, join(cfg.artifactsRoot, outputKey));
|
|
369
|
+
return { exit, outputKey, error: null };
|
|
370
|
+
} catch (err) {
|
|
371
|
+
if (timedOut) return { exit: -1, error: "spawn build timed out" };
|
|
372
|
+
throw err;
|
|
373
|
+
} finally {
|
|
374
|
+
clearTimeout(timer);
|
|
375
|
+
// Disposable teardown: remove the box (it holds the binds), THEN wipe the
|
|
376
|
+
// whole back room. boxDir always holds ctx; it holds out too unless the
|
|
377
|
+
// success rename already moved it into artifacts. Nothing the caller can
|
|
378
|
+
// reach is touched. The box leaves untrusted-net with it; the DMZ persists.
|
|
379
|
+
if (id) await cfg.ode.removeContainer(id, { force: true }).catch(() => {});
|
|
380
|
+
await Deno.remove(boxDir, { recursive: true }).catch(() => {});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// EXDEV detection. Deno doesn't expose a `CrossDevice` error class in this
|
|
385
|
+
// version, so match the OS error a cross-device rename(2) surfaces ("Invalid
|
|
386
|
+
// cross-device link" / raw os error 18) by message. Used to fail the seal loud
|
|
387
|
+
// rather than silently copy (a copy would reintroduce the TOCTOU).
|
|
388
|
+
function isCrossDevice(err: unknown): boolean {
|
|
389
|
+
return err instanceof Error && /cross-device|os error 18/i.test(err.message);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function ensureImage(odeApi: SpawnOde, image: string): Promise<void> {
|
|
393
|
+
try {
|
|
394
|
+
await odeApi.inspectImage(image);
|
|
395
|
+
return; // already present
|
|
396
|
+
} catch {
|
|
397
|
+
// Not present — pull it.
|
|
398
|
+
}
|
|
399
|
+
await odeApi.pullImage(image);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Follow the box's logs and relay each line back over the socket. Uses
|
|
403
|
+
// timestamps:true to match the shared frame demux (which strips the
|
|
404
|
+
// leading RFC3339); we keep only message + stream. Returns when the
|
|
405
|
+
// stream ends — i.e. the container exited.
|
|
406
|
+
async function relayLogs(
|
|
407
|
+
odeApi: SpawnOde,
|
|
408
|
+
id: string,
|
|
409
|
+
onLog: (line: SpawnLogLine) => Promise<void>,
|
|
410
|
+
): Promise<void> {
|
|
411
|
+
let conn: Deno.Conn;
|
|
412
|
+
try {
|
|
413
|
+
({ conn } = await odeApi.followContainerLogs(id, {
|
|
414
|
+
stdout: true,
|
|
415
|
+
stderr: true,
|
|
416
|
+
timestamps: true,
|
|
417
|
+
}));
|
|
418
|
+
} catch {
|
|
419
|
+
return; // can't follow — fall through to waitContainer
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
for await (const frame of streamDockerLogFrames(dechunkedReader(conn))) {
|
|
423
|
+
await onLog({ log: frame.message, stream: frame.stream });
|
|
424
|
+
}
|
|
425
|
+
} catch {
|
|
426
|
+
// Stream ended or errored — proceed to wait for the exit code.
|
|
427
|
+
} finally {
|
|
428
|
+
try {
|
|
429
|
+
conn.close();
|
|
430
|
+
} catch {}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function writeLine(
|
|
435
|
+
writer: WritableStreamDefaultWriter<Uint8Array>,
|
|
436
|
+
obj: SpawnLogLine | SpawnTerminal,
|
|
437
|
+
): Promise<void> {
|
|
438
|
+
await writer.write(encoder.encode(JSON.stringify(obj) + "\n"));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Idempotent create of the standing DMZ bridge. "already exists" is the
|
|
442
|
+
// expected steady state.
|
|
443
|
+
async function ensureUntrustedNet(): Promise<void> {
|
|
444
|
+
try {
|
|
445
|
+
await ode.createNetwork({ Name: UNTRUSTED_NET });
|
|
446
|
+
} catch (err) {
|
|
447
|
+
if (!(err instanceof Error && err.message.toLowerCase().includes("already exists"))) {
|
|
448
|
+
throw err;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function gcStaleBoxes(): Promise<void> {
|
|
454
|
+
let boxes: Awaited<ReturnType<typeof ode.listContainers>>;
|
|
455
|
+
try {
|
|
456
|
+
boxes = await ode.listContainers({
|
|
457
|
+
all: true,
|
|
458
|
+
filters: { label: [`${LABELS.KIND}=${KINDS.SPAWN_BOX}`] },
|
|
459
|
+
});
|
|
460
|
+
} catch (err) {
|
|
461
|
+
console.warn(`spawn boot GC: list failed (${(err as Error).message})`);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
for (const c of boxes) {
|
|
465
|
+
if (!c.Id) continue;
|
|
466
|
+
await ode.removeContainer(c.Id, { force: true }).catch(() => {});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Race `p` against a wall-clock budget. The loser keeps running in the
|
|
471
|
+
// background (the ode calls hold their own conns); callers that need the
|
|
472
|
+
// abandoned work torn down do so separately (e.g. force-removing the box).
|
|
473
|
+
async function withTimeout<T>(p: Promise<T>, ms: number, message: string): Promise<T> {
|
|
474
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
475
|
+
try {
|
|
476
|
+
return await Promise.race([
|
|
477
|
+
p,
|
|
478
|
+
new Promise<never>((_, reject) => {
|
|
479
|
+
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
480
|
+
}),
|
|
481
|
+
]);
|
|
482
|
+
} finally {
|
|
483
|
+
clearTimeout(timer);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function envInt(name: string, fallback: number): number {
|
|
488
|
+
const raw = Deno.env.get(name);
|
|
489
|
+
if (!raw) return fallback;
|
|
490
|
+
const n = Number(raw);
|
|
491
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
492
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The `spawn.sock` wire contract. Newline-delimited JSON over a unix
|
|
2
|
+
// conn, mirroring `logs.sock`: a single request line in, a stream of log
|
|
3
|
+
// lines + one terminal line out.
|
|
4
|
+
//
|
|
5
|
+
// The request carries only the *payload* (image / argv / env) plus a
|
|
6
|
+
// context key and an optional output flag. It carries NO envelope config
|
|
7
|
+
// — the broker constructs the hardened spec itself (see spec.ts). The
|
|
8
|
+
// broker's read buffer caps the raw bytes; these schemas cap the parsed
|
|
9
|
+
// shape.
|
|
10
|
+
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
const MAX_IMAGE = 1024;
|
|
14
|
+
const MAX_KEY = 255;
|
|
15
|
+
const MAX_ARGV = 1024;
|
|
16
|
+
const MAX_ARG = 16384;
|
|
17
|
+
const MAX_ENV_KEYS = 512;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One build request. `image`/`argv`/`env` are the payload to run inside
|
|
21
|
+
* the fixed envelope, never envelope config. `context` is a relative,
|
|
22
|
+
* single-segment key resolved under the worker scratch root (see
|
|
23
|
+
* context-key.ts). `output` asks the broker for a writable per-box output
|
|
24
|
+
* dir, bind-mounted RW at OUTPUT_MOUNT: a generic "give me a place to write
|
|
25
|
+
* artifacts and tell me where it is" flag. The broker stays
|
|
26
|
+
* image-agnostic — it never parses `argv`, so what the box writes to that
|
|
27
|
+
* dir (a kaniko `--tar-path` image, say) and what the caller does with it
|
|
28
|
+
* are entirely the caller's concern. The broker only hands back the dir's
|
|
29
|
+
* basename via `outputKey`; it never reads a box-authored file.
|
|
30
|
+
*/
|
|
31
|
+
export const spawnRequestSchema = z
|
|
32
|
+
.object({
|
|
33
|
+
image: z.string().min(1).max(MAX_IMAGE),
|
|
34
|
+
context: z.string().min(1).max(MAX_KEY),
|
|
35
|
+
argv: z.array(z.string().max(MAX_ARG)).max(MAX_ARGV).optional(),
|
|
36
|
+
env: z
|
|
37
|
+
.record(z.string().max(MAX_KEY), z.string().max(MAX_ARG))
|
|
38
|
+
.refine((e) => Object.keys(e).length <= MAX_ENV_KEYS, {
|
|
39
|
+
message: `too many env keys (max ${MAX_ENV_KEYS})`,
|
|
40
|
+
})
|
|
41
|
+
.optional(),
|
|
42
|
+
output: z.literal(true).optional(),
|
|
43
|
+
})
|
|
44
|
+
.strict();
|
|
45
|
+
|
|
46
|
+
export type SpawnRequest = z.infer<typeof spawnRequestSchema>;
|
|
47
|
+
|
|
48
|
+
/** One line of the box's output, relayed back over the socket. */
|
|
49
|
+
export const spawnLogLineSchema = z.object({
|
|
50
|
+
log: z.string(),
|
|
51
|
+
stream: z.enum(["stdout", "stderr"]),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export type SpawnLogLine = z.infer<typeof spawnLogLineSchema>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The final line before the broker closes the conn (close == teardown
|
|
58
|
+
* complete). `outputKey` is the basename of the broker-owned output dir
|
|
59
|
+
* (`output-<boxId>`) when `output` was requested — the caller reads its
|
|
60
|
+
* artifacts back from `${artifactsRoot}/${outputKey}`. It is broker-authored
|
|
61
|
+
* (a basename the broker minted), never a path or value the box wrote.
|
|
62
|
+
* `error` is set on any failure.
|
|
63
|
+
*/
|
|
64
|
+
export const spawnTerminalSchema = z.object({
|
|
65
|
+
exit: z.number().int(),
|
|
66
|
+
outputKey: z.string().optional(),
|
|
67
|
+
error: z.string().nullable().optional(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export type SpawnTerminal = z.infer<typeof spawnTerminalSchema>;
|