@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,122 @@
|
|
|
1
|
+
// The security crux. `buildSpawnSpec` *constructs* the `ode.createContainer`
|
|
2
|
+
// body from the tiny allowlist of caller params (`image`, `argv`, `env`,
|
|
3
|
+
// the resolved context path). The caller supplies no `HostConfig` — the
|
|
4
|
+
// broker imposes the entire hardened envelope here.
|
|
5
|
+
//
|
|
6
|
+
// This is to `spawn.sock` what `checkAppGrants` + `HARDENING` + the SDK
|
|
7
|
+
// authoring surface are to the compose path: every security-relevant
|
|
8
|
+
// field is forced, and everything in the `docker.sock` deny-list
|
|
9
|
+
// (privileged, host namespaces, arbitrary mounts/networks, the socket
|
|
10
|
+
// itself) is simply not expressible — it is never read from the caller.
|
|
11
|
+
|
|
12
|
+
import { KINDS, LABELS } from "@local/docker";
|
|
13
|
+
import { HARDENING } from "../compose/default-hardening.ts";
|
|
14
|
+
import { UNTRUSTED_NET } from "../../../worker-protocol/networks.ts";
|
|
15
|
+
import type { SpawnRequest } from "./protocol.ts";
|
|
16
|
+
|
|
17
|
+
/** Where the validated build context is bind-mounted, read-only, in the box. */
|
|
18
|
+
export const CONTEXT_MOUNT = "/workspace";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where the broker's per-box output dir is bind-mounted, read-write, in the
|
|
22
|
+
* box. Mounted ONLY when `output` is requested; the box writes its build
|
|
23
|
+
* artifacts (e.g. a kaniko `--tar-path` image) here and the caller reads them
|
|
24
|
+
* back off the per-app artifacts dir. The dir is broker-owned and
|
|
25
|
+
* broker-created (the caller never names it), so this RW bind is as
|
|
26
|
+
* trusted as the RO context bind. The broker itself never reads anything
|
|
27
|
+
* out of this dir — it only reports the dir's basename via `outputKey`.
|
|
28
|
+
*/
|
|
29
|
+
export const OUTPUT_MOUNT = "/output";
|
|
30
|
+
|
|
31
|
+
export interface SpawnLimits {
|
|
32
|
+
/** Hard memory ceiling, bytes. */
|
|
33
|
+
memoryBytes: number;
|
|
34
|
+
/** CPU quota in nano-CPUs (1e9 = one home). */
|
|
35
|
+
nanoCpus: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildSpawnSpec(
|
|
39
|
+
req: SpawnRequest,
|
|
40
|
+
opts: {
|
|
41
|
+
scratchHostPath: string;
|
|
42
|
+
outputHostPath?: string;
|
|
43
|
+
limits: SpawnLimits;
|
|
44
|
+
spawnedBy: string;
|
|
45
|
+
},
|
|
46
|
+
): Record<string, unknown> {
|
|
47
|
+
const env = req.env ? Object.entries(req.env).map(([k, v]) => `${k}=${v}`) : undefined;
|
|
48
|
+
return {
|
|
49
|
+
Image: req.image,
|
|
50
|
+
...(req.argv ? { Cmd: req.argv } : {}),
|
|
51
|
+
...(env ? { Env: env } : {}),
|
|
52
|
+
Labels: {
|
|
53
|
+
// realm.kind=spawn-box drives the reconcile / teardown / GC loops (boot
|
|
54
|
+
// gcStaleBoxes reaps orphans by it); it's broker-owned, not a wire
|
|
55
|
+
// service. realm.spawned-by records which granted app drove this build,
|
|
56
|
+
// as provenance for `docker ps` and forensics.
|
|
57
|
+
//
|
|
58
|
+
// Deliberately NOT realm.managed: the logs server tails every managed
|
|
59
|
+
// container, and a build's (attacker-controlled) stdout must not land in
|
|
60
|
+
// the logger — the build app (e.g. Forge) collects it over the spawn
|
|
61
|
+
// socket relay. spawned-by is its OWN key, distinct from realm.app
|
|
62
|
+
// (which would re-trigger logger attribution), so it's pure metadata:
|
|
63
|
+
// nothing taps or buckets by it.
|
|
64
|
+
[LABELS.KIND]: KINDS.SPAWN_BOX,
|
|
65
|
+
[LABELS.SPAWNED_BY]: opts.spawnedBy,
|
|
66
|
+
},
|
|
67
|
+
HostConfig: {
|
|
68
|
+
// Full HARDENING, plus the file-extraction caps a builder needs. A
|
|
69
|
+
// builder unpacks arbitrary base rootfs as uid 0, and real bases ship
|
|
70
|
+
// files owned by other uids (node:22-alpine's /home/node): chmod/utimes
|
|
71
|
+
// on a non-owned file needs FOWNER, writing through restrictive mode
|
|
72
|
+
// bits needs DAC_OVERRIDE, security.capability xattrs (setcap'd
|
|
73
|
+
// binaries in the tar) need SETFCAP, and FSETID keeps setgid bits
|
|
74
|
+
// intact — without it, chmod of a foreign-group file (Debian's 2775
|
|
75
|
+
// root:staff /usr/local, sgid crontab/wall) silently strips the bit
|
|
76
|
+
// and the built image drifts from its base. All four act only on file
|
|
77
|
+
// metadata inside the box; the escape-relevant fences — default
|
|
78
|
+
// seccomp (blocks unshare(CLONE_NEWUSER)) and no-new-privileges —
|
|
79
|
+
// stay exactly as HARDENING sets them.
|
|
80
|
+
CapDrop: [...HARDENING.cap_drop],
|
|
81
|
+
// DAC_OVERRIDE arrives via HARDENING.cap_add; listing it here too would
|
|
82
|
+
// just duplicate the entry.
|
|
83
|
+
CapAdd: [...HARDENING.cap_add, "FOWNER", "SETFCAP", "FSETID"],
|
|
84
|
+
SecurityOpt: [...HARDENING.security_opt],
|
|
85
|
+
PidsLimit: HARDENING.pids_limit,
|
|
86
|
+
Ulimits: [
|
|
87
|
+
{ Name: "nofile", Soft: HARDENING.ulimits.nofile, Hard: HARDENING.ulimits.nofile },
|
|
88
|
+
{ Name: "nproc", Soft: HARDENING.ulimits.nproc, Hard: HARDENING.ulimits.nproc },
|
|
89
|
+
],
|
|
90
|
+
LogConfig: {
|
|
91
|
+
Type: HARDENING.logging.driver,
|
|
92
|
+
Config: { ...HARDENING.logging.options },
|
|
93
|
+
},
|
|
94
|
+
// One-shot: a failed build must NOT loop. The single documented
|
|
95
|
+
// deviation from HARDENING.restart (on-failure) — lifecycle, not
|
|
96
|
+
// security; the broker tears the box down on exit.
|
|
97
|
+
RestartPolicy: { Name: "no" },
|
|
98
|
+
// Resource caps beyond HARDENING — bound a single box.
|
|
99
|
+
Memory: opts.limits.memoryBytes,
|
|
100
|
+
NanoCpus: opts.limits.nanoCpus,
|
|
101
|
+
// Every bind is broker-constructed. The context dir is read-only —
|
|
102
|
+
// RO is load-bearing, the scratch root is shared host fs, so RW
|
|
103
|
+
// would let a build tamper with the caller's clone or plant data
|
|
104
|
+
// another build reads. The ONLY RW bind is the broker's own per-box
|
|
105
|
+
// output dir, and only when `output` is requested: the box writes its
|
|
106
|
+
// build artifacts there for the caller to read back off the per-app
|
|
107
|
+
// artifacts dir. The caller never names that dir (same trust property as
|
|
108
|
+
// the context dir).
|
|
109
|
+
Binds: [
|
|
110
|
+
`${opts.scratchHostPath}:${CONTEXT_MOUNT}:ro`,
|
|
111
|
+
...(opts.outputHostPath ? [`${opts.outputHostPath}:${OUTPUT_MOUNT}:rw`] : []),
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
// Attach at create time to untrusted-net ONLY — never the default
|
|
115
|
+
// bridge. Docker's DOCKER-ISOLATION-STAGE-2 DROPs FORWARD between
|
|
116
|
+
// user bridges, so the box physically cannot route to management-net
|
|
117
|
+
// or a peer realm_<app>. Segmentation by topology, not by caps.
|
|
118
|
+
NetworkingConfig: {
|
|
119
|
+
EndpointsConfig: { [UNTRUSTED_NET]: {} },
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// One entry per real filesystem mounted on the host.
|
|
2
|
+
//
|
|
3
|
+
// The worker sees the host's root at /host (a read-only recursive bind — see
|
|
4
|
+
// install-script.ts). So the mount table it reads through its own
|
|
5
|
+
// /proc/self/mounts lists every host filesystem twice over: once at its real
|
|
6
|
+
// path, once under /host. The /host copies are the ones we can actually
|
|
7
|
+
// statfs, and stripping the prefix back off is what makes `path` read as the
|
|
8
|
+
// operator's real path rather than a container detail.
|
|
9
|
+
|
|
10
|
+
// `statfsSync` is a deliberate exception to the Deno-native rule in CLAUDE.md:
|
|
11
|
+
// there is no Deno equivalent (`Deno.statfs` does not exist), so the node
|
|
12
|
+
// compat path is the only way to read a filesystem's size. Needs
|
|
13
|
+
// --allow-sys=statfs plus --allow-read on the path.
|
|
14
|
+
import { statfsSync } from "node:fs";
|
|
15
|
+
import type { DiskSample } from "../../stats-sample.ts";
|
|
16
|
+
|
|
17
|
+
const HOST_ROOT = "/host";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Our own mount table, reached through the host bind rather than at
|
|
21
|
+
* `/proc/self/mounts` directly. Deno refuses ANY path under `/proc` unless the
|
|
22
|
+
* process holds blanket `--allow-read` — `--allow-read=/proc` is not enough
|
|
23
|
+
* (`/proc/self/mem` would hand over process memory, so the whole tree is
|
|
24
|
+
* all-or-nothing). Reading the identical file via `/host/proc/...` is covered
|
|
25
|
+
* by the scoped `--allow-read=/host` the worker already needs for statfs, so
|
|
26
|
+
* the stats broker costs no permission widening.
|
|
27
|
+
*
|
|
28
|
+
* Same bytes either way: the host's procfs resolves `self` by the reading
|
|
29
|
+
* task's host-side pid, and `mounts` is a property of that task's mount
|
|
30
|
+
* namespace — ours.
|
|
31
|
+
*/
|
|
32
|
+
const MOUNTS = `${HOST_ROOT}/proc/self/mounts`;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Filesystems the kernel synthesizes rather than stores bytes on. Reporting a
|
|
36
|
+
* `total`/`available` for these is meaningless — tmpfs reports RAM, cgroup and
|
|
37
|
+
* proc report zero — so they're dropped by fstype rather than by path. The
|
|
38
|
+
* host's /proc, /sys and /dev all come along with the recursive bind, so
|
|
39
|
+
* without this the "disks" list would be mostly kernel interfaces.
|
|
40
|
+
*/
|
|
41
|
+
const PSEUDO_FSTYPES = new Set([
|
|
42
|
+
"autofs",
|
|
43
|
+
"binfmt_misc",
|
|
44
|
+
"bpf",
|
|
45
|
+
"configfs",
|
|
46
|
+
"debugfs",
|
|
47
|
+
"devpts",
|
|
48
|
+
"devtmpfs",
|
|
49
|
+
"efivarfs",
|
|
50
|
+
"fusectl",
|
|
51
|
+
"hugetlbfs",
|
|
52
|
+
"mqueue",
|
|
53
|
+
"nsfs",
|
|
54
|
+
"overlay",
|
|
55
|
+
"proc",
|
|
56
|
+
"pstore",
|
|
57
|
+
"ramfs",
|
|
58
|
+
"rpc_pipefs",
|
|
59
|
+
"securityfs",
|
|
60
|
+
"selinuxfs",
|
|
61
|
+
"squashfs",
|
|
62
|
+
"sysfs",
|
|
63
|
+
"tmpfs",
|
|
64
|
+
"tracefs",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const isPseudo = (fstype: string): boolean =>
|
|
68
|
+
PSEUDO_FSTYPES.has(fstype) || fstype.startsWith("cgroup") || fstype.startsWith("fuse.lxcfs");
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Mounts under `/run` are volatile runtime state, never a data disk (FHS), so
|
|
72
|
+
* the subtree is dropped by path — the one spot the fstype rule can't cover.
|
|
73
|
+
* Most `/run` submounts are already pseudo (tmpfs, nsfs), but a VM host exposes
|
|
74
|
+
* binary-emulation shares here that aren't: OrbStack / Docker Desktop's Rosetta
|
|
75
|
+
* mount (`/run/rosetta.orig`) has a non-pseudo fstype and its own anonymous
|
|
76
|
+
* st_dev, so neither the type filter nor the st_dev dedupe folds it, and statfs
|
|
77
|
+
* on it reports the whole VM host filesystem — a phantom multi-TiB "drive".
|
|
78
|
+
*/
|
|
79
|
+
const isUnderRun = (path: string): boolean => path.startsWith(`${HOST_ROOT}/run/`);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `overlay` is in the pseudo list because every running container's rootfs is
|
|
83
|
+
* an overlay mount with its own anonymous st_dev — the dedupe below can't
|
|
84
|
+
* collapse them, so without the drop a busy host reports one phantom disk per
|
|
85
|
+
* container. But the host's own root is a real filesystem whatever its fstype,
|
|
86
|
+
* and on overlay-root systems (the Docker Desktop / OrbStack VM, appliance
|
|
87
|
+
* images) that root IS an overlay. Judge the root by position, not fstype: a
|
|
88
|
+
* container rootfs is never mounted at HOST_ROOT exactly.
|
|
89
|
+
*/
|
|
90
|
+
const isHostRoot = (path: string): boolean => path === HOST_ROOT;
|
|
91
|
+
|
|
92
|
+
export async function readDisks(): Promise<DiskSample[]> {
|
|
93
|
+
let raw: string;
|
|
94
|
+
try {
|
|
95
|
+
raw = await Deno.readTextFile(MOUNTS);
|
|
96
|
+
} catch {
|
|
97
|
+
// No procfs under /host: a macOS dev host, or the host bind isn't wired.
|
|
98
|
+
// Report no disks rather than failing the whole sample.
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const disks: DiskSample[] = [];
|
|
103
|
+
// Dedupe by filesystem identity, not by path. statfs returns no fsid, so
|
|
104
|
+
// st_dev is the only key available. This is what collapses docker's own
|
|
105
|
+
// /etc/resolv.conf, /etc/hostname and /etc/hosts file-binds: each is a
|
|
106
|
+
// separate mount entry whose statfs reports its *containing* filesystem, so
|
|
107
|
+
// without this they'd each surface as a phantom disk the size of the root
|
|
108
|
+
// volume. Bind-mounted subtrees of one disk collapse the same way.
|
|
109
|
+
const seen = new Set<number>();
|
|
110
|
+
|
|
111
|
+
for (const line of raw.split("\n")) {
|
|
112
|
+
const mount = parseMountLine(line);
|
|
113
|
+
if (!mount) continue;
|
|
114
|
+
if (!isHostRoot(mount.path) && !mount.path.startsWith(HOST_ROOT + "/")) continue;
|
|
115
|
+
if (isPseudo(mount.fstype) && !isHostRoot(mount.path)) continue;
|
|
116
|
+
if (isUnderRun(mount.path)) continue;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const dev = Deno.statSync(mount.path).dev;
|
|
120
|
+
if (seen.has(dev)) continue;
|
|
121
|
+
seen.add(dev);
|
|
122
|
+
|
|
123
|
+
const fs = statfsSync(mount.path);
|
|
124
|
+
const total = fs.blocks * fs.bsize;
|
|
125
|
+
// A zero-capacity filesystem carries no signal — drop it rather than
|
|
126
|
+
// render an empty "0 B / 0 B" bar (some VM shim mounts report this).
|
|
127
|
+
if (total <= 0) continue;
|
|
128
|
+
disks.push({
|
|
129
|
+
path: stripHostPrefix(mount.path),
|
|
130
|
+
total,
|
|
131
|
+
available: fs.bavail * fs.bsize,
|
|
132
|
+
});
|
|
133
|
+
} catch {
|
|
134
|
+
// Raced an unmount, or the mount point isn't readable. Skip it.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return disks;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface Mount {
|
|
142
|
+
path: string;
|
|
143
|
+
fstype: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// /proc/self/mounts: `device mountpoint fstype options dump pass`, with
|
|
147
|
+
// whitespace inside a field escaped octally.
|
|
148
|
+
function parseMountLine(line: string): Mount | null {
|
|
149
|
+
const fields = line.split(" ");
|
|
150
|
+
if (fields.length < 3) return null;
|
|
151
|
+
const path = unescapeOctal(fields[1]!);
|
|
152
|
+
const fstype = fields[2]!;
|
|
153
|
+
if (!path || !fstype) return null;
|
|
154
|
+
return { path, fstype };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const unescapeOctal = (s: string): string =>
|
|
158
|
+
s.replace(/\\([0-7]{3})/g, (_, oct: string) => String.fromCharCode(parseInt(oct, 8)));
|
|
159
|
+
|
|
160
|
+
const stripHostPrefix = (path: string): string =>
|
|
161
|
+
path === HOST_ROOT ? "/" : path.slice(HOST_ROOT.length);
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// Worker-side stats server. Binds a unix socket; consumers connect, send a
|
|
2
|
+
// single line `{"sample":true}\n` (or just `\n`), receive one JSON
|
|
3
|
+
// `StatsSample`, and the connection closes.
|
|
4
|
+
//
|
|
5
|
+
// Request/response, not a stream — the deliberate divergence from
|
|
6
|
+
// logs-server. A log line is an event you can't predict, so the worker must
|
|
7
|
+
// push. A stats sample doesn't exist until someone asks for it, so the worker
|
|
8
|
+
// holds no timer, no history, and no cache: it reads the host on demand and
|
|
9
|
+
// answers. Cadence is entirely the consumer's choice.
|
|
10
|
+
//
|
|
11
|
+
// Everything here reads; nothing mutates. The socket has no verb that changes
|
|
12
|
+
// anything.
|
|
13
|
+
|
|
14
|
+
import { dirname } from "@std/path";
|
|
15
|
+
import { LABELS, ode } from "@local/docker";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { REALM_STATS_SOCKET } from "../../../worker-protocol/stats-socket-path.ts";
|
|
18
|
+
import {
|
|
19
|
+
type ContainerSample,
|
|
20
|
+
type DiskSample,
|
|
21
|
+
type HostSample,
|
|
22
|
+
OTHER_APP,
|
|
23
|
+
type StatsSample,
|
|
24
|
+
} from "../../stats-sample.ts";
|
|
25
|
+
import { readDisks } from "./disks.ts";
|
|
26
|
+
|
|
27
|
+
export { REALM_STATS_SOCKET };
|
|
28
|
+
export type { StatsSample };
|
|
29
|
+
|
|
30
|
+
// Request line a consumer sends before the sample: {"sample":true}. The sample
|
|
31
|
+
// is all-or-nothing, so the value is ignored; the field leaves room for a
|
|
32
|
+
// scoped request.
|
|
33
|
+
const sampleRequestSchema = z.object({ sample: z.boolean().optional() });
|
|
34
|
+
|
|
35
|
+
// A well-behaved consumer sends its line immediately. This bounds one that
|
|
36
|
+
// connects and then never does, so its read can't park the connection (and its
|
|
37
|
+
// fd) open forever. Only host.stats-granted apps reach the socket, so this
|
|
38
|
+
// guards against a buggy grantee, not an external attacker.
|
|
39
|
+
const REQUEST_READ_TIMEOUT_MS = 5_000;
|
|
40
|
+
|
|
41
|
+
interface StartOpts {
|
|
42
|
+
/** Defaults to {@link REALM_STATS_SOCKET}. Override for tests. */
|
|
43
|
+
socketPath?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ServerHandle {
|
|
47
|
+
/** Stop the server: close the listener and remove the socket file. */
|
|
48
|
+
stop(): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const encoder = new TextEncoder();
|
|
52
|
+
const decoder = new TextDecoder();
|
|
53
|
+
|
|
54
|
+
export async function startStatsServer(opts: StartOpts = {}): Promise<ServerHandle> {
|
|
55
|
+
const socketPath = opts.socketPath ?? REALM_STATS_SOCKET;
|
|
56
|
+
// The socket lives alone in its own directory (apps mount the dir, not the
|
|
57
|
+
// socket file — a file bind-mount of a unix socket doesn't wire across
|
|
58
|
+
// containers on macOS Docker). Ensure the dir exists before binding.
|
|
59
|
+
await Deno.mkdir(dirname(socketPath), { recursive: true });
|
|
60
|
+
await Deno.remove(socketPath).catch(() => {});
|
|
61
|
+
|
|
62
|
+
const listener = Deno.listen({ transport: "unix", path: socketPath });
|
|
63
|
+
|
|
64
|
+
void acceptLoop(listener);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
async stop() {
|
|
68
|
+
try {
|
|
69
|
+
listener.close();
|
|
70
|
+
} catch {}
|
|
71
|
+
await Deno.remove(socketPath).catch(() => {});
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function acceptLoop(listener: Deno.Listener): Promise<void> {
|
|
77
|
+
try {
|
|
78
|
+
for await (const conn of listener) {
|
|
79
|
+
void serve(conn as Deno.UnixConn);
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// Listener was closed via stop().
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function serve(conn: Deno.UnixConn): Promise<void> {
|
|
87
|
+
try {
|
|
88
|
+
await readRequestLine(conn);
|
|
89
|
+
const sample = await collectSample();
|
|
90
|
+
await conn.write(encoder.encode(JSON.stringify(sample) + "\n"));
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error("stats-server sample error:", err);
|
|
93
|
+
} finally {
|
|
94
|
+
try {
|
|
95
|
+
conn.close();
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function collectSample(): Promise<StatsSample> {
|
|
101
|
+
// `all: true` and no label filter: every container on the host, running and
|
|
102
|
+
// exited. Anything without a `realm.app` label lands under OTHER_APP — which
|
|
103
|
+
// is foreign containers AND Realm's own infrastructure (the worker, and Home
|
|
104
|
+
// where it's a container), since the installer starts those out-of-band and
|
|
105
|
+
// only the reconciler labels what it creates.
|
|
106
|
+
const [info, containers] = await Promise.all([ode.info(), ode.listContainers({ all: true })]);
|
|
107
|
+
|
|
108
|
+
const samples = await Promise.all(
|
|
109
|
+
containers.map((c) =>
|
|
110
|
+
sampleContainer(c.Id ?? "", c.Labels ?? {}, c.State ?? "", pickName(c.Names ?? [])),
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
ts: new Date().toISOString(),
|
|
116
|
+
host: await collectHost(info.KernelVersion ?? ""),
|
|
117
|
+
docker: {
|
|
118
|
+
version: info.ServerVersion ?? "",
|
|
119
|
+
// Counted from the list rather than read off /info: one source, so the
|
|
120
|
+
// counts can't disagree with the `containers` array beside them.
|
|
121
|
+
containersRunning: containers.filter((c) => c.State === "running").length,
|
|
122
|
+
containersTotal: containers.length,
|
|
123
|
+
},
|
|
124
|
+
containers: samples.filter((s): s is ContainerSample => s !== null),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function collectHost(kernelVersion: string): Promise<HostSample> {
|
|
129
|
+
const mem = Deno.systemMemoryInfo();
|
|
130
|
+
const load = Deno.loadavg();
|
|
131
|
+
return {
|
|
132
|
+
kernelVersion,
|
|
133
|
+
cores: navigator.hardwareConcurrency,
|
|
134
|
+
loadAvg: [load[0] ?? 0, load[1] ?? 0, load[2] ?? 0],
|
|
135
|
+
memoryTotal: mem.total,
|
|
136
|
+
memoryAvailable: mem.available,
|
|
137
|
+
swapTotal: mem.swapTotal,
|
|
138
|
+
swapUsed: mem.swapTotal - mem.swapFree,
|
|
139
|
+
uptime: Deno.osUptime(),
|
|
140
|
+
disks: await readDisks(),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Docker reports names as a list with a leading slash ("/nginx"), because a
|
|
145
|
+
// container can carry several via legacy links. The first is the one `docker
|
|
146
|
+
// ps` shows, which is the one an operator would recognize.
|
|
147
|
+
function pickName(names: string[]): string {
|
|
148
|
+
const first = names[0] ?? "";
|
|
149
|
+
return first.startsWith("/") ? first.slice(1) : first;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function sampleContainer(
|
|
153
|
+
id: string,
|
|
154
|
+
labels: Record<string, string>,
|
|
155
|
+
state: string,
|
|
156
|
+
name: string,
|
|
157
|
+
): Promise<ContainerSample | null> {
|
|
158
|
+
if (!id) return null;
|
|
159
|
+
try {
|
|
160
|
+
const inspect = await ode.inspectContainer(id);
|
|
161
|
+
const base = {
|
|
162
|
+
app: labels[LABELS.APP] || OTHER_APP,
|
|
163
|
+
service: labels[LABELS.SERVICE] ?? "",
|
|
164
|
+
name,
|
|
165
|
+
containerId: id,
|
|
166
|
+
state,
|
|
167
|
+
health: inspect.State?.Health?.Status ?? undefined,
|
|
168
|
+
startedAt: inspect.State?.StartedAt ?? "",
|
|
169
|
+
restartCount: inspect.RestartCount ?? 0,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Docker answers /stats for an exited container with HTTP 200 and
|
|
173
|
+
// all-zero counters, which is indistinguishable from a counter reset on a
|
|
174
|
+
// live container. Gate on state so a consumer diffing counters can't read
|
|
175
|
+
// an exit as "traffic went to zero" — skip the call entirely and report
|
|
176
|
+
// the zeros explicitly.
|
|
177
|
+
if (state !== "running") {
|
|
178
|
+
return {
|
|
179
|
+
...base,
|
|
180
|
+
cpuUsage: 0,
|
|
181
|
+
netRx: 0,
|
|
182
|
+
netTx: 0,
|
|
183
|
+
blockRead: 0,
|
|
184
|
+
blockWrite: 0,
|
|
185
|
+
memoryUsage: 0,
|
|
186
|
+
memoryLimit: 0,
|
|
187
|
+
pids: 0,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const stats = await ode.containerStats(id, { stream: false, oneShot: true });
|
|
192
|
+
return { ...base, ...readCounters(stats) };
|
|
193
|
+
} catch {
|
|
194
|
+
// Container was removed between the list and the inspect/stats call.
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
type Counters = Pick<
|
|
200
|
+
ContainerSample,
|
|
201
|
+
| "cpuUsage"
|
|
202
|
+
| "netRx"
|
|
203
|
+
| "netTx"
|
|
204
|
+
| "blockRead"
|
|
205
|
+
| "blockWrite"
|
|
206
|
+
| "memoryUsage"
|
|
207
|
+
| "memoryLimit"
|
|
208
|
+
| "pids"
|
|
209
|
+
>;
|
|
210
|
+
|
|
211
|
+
// Docker's counters are uint64, which the generated schema parses as bigint.
|
|
212
|
+
// The wire is JSON, which has no bigint, so everything narrows to number here.
|
|
213
|
+
// Lossless below 2^53: at 100% CPU a `cpuUsage` nanosecond counter needs ~104
|
|
214
|
+
// days to reach that, and a container that runs that long without a restart
|
|
215
|
+
// resets nothing — but neither would docker's own uint64 survive a diff across
|
|
216
|
+
// a restart. Bytes counters are further away still.
|
|
217
|
+
const num = (v: bigint | number | null | undefined): number => Number(v ?? 0);
|
|
218
|
+
|
|
219
|
+
function readCounters(stats: ode.ContainerStats): Counters {
|
|
220
|
+
const networks = networksSchema.safeParse(stats.networks);
|
|
221
|
+
let netRx = 0;
|
|
222
|
+
let netTx = 0;
|
|
223
|
+
if (networks.success && networks.data) {
|
|
224
|
+
// Summed across interfaces: an app's container can sit on several networks
|
|
225
|
+
// (its own, management, an untrusted DMZ) and the consumer wants one
|
|
226
|
+
// number for the container.
|
|
227
|
+
for (const iface of Object.values(networks.data)) {
|
|
228
|
+
netRx += num(iface.rx_bytes);
|
|
229
|
+
netTx += num(iface.tx_bytes);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let blockRead = 0;
|
|
234
|
+
let blockWrite = 0;
|
|
235
|
+
for (const entry of stats.blkio_stats?.io_service_bytes_recursive ?? []) {
|
|
236
|
+
// `op` casing varies across docker versions and cgroup drivers ("Read" on
|
|
237
|
+
// cgroup v1, "read" on v2).
|
|
238
|
+
const op = entry?.op?.toLowerCase();
|
|
239
|
+
if (op === "read") blockRead += num(entry?.value);
|
|
240
|
+
if (op === "write") blockWrite += num(entry?.value);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// `usage` counts the page cache, so it reads far above what the process
|
|
244
|
+
// actually holds. Docker's own CLI subtracts inactive file cache before
|
|
245
|
+
// showing MEM USAGE; do the same so the number matches what an operator sees
|
|
246
|
+
// in `docker stats`.
|
|
247
|
+
//
|
|
248
|
+
// Order matters and mirrors Docker's algorithm exactly: prefer
|
|
249
|
+
// `total_inactive_file` (present on cgroup v1) and fall back to
|
|
250
|
+
// `inactive_file` (cgroup v2, which has no `total_` prefix). On a v1 host a
|
|
251
|
+
// container with child cgroups reports both keys with DIFFERENT values, so
|
|
252
|
+
// reading `inactive_file` first would subtract the wrong one and drift from
|
|
253
|
+
// `docker stats`.
|
|
254
|
+
const memStats = stats.memory_stats?.stats;
|
|
255
|
+
const inactiveFile = num(memStats?.total_inactive_file ?? memStats?.inactive_file);
|
|
256
|
+
const memoryUsage = Math.max(0, num(stats.memory_stats?.usage) - inactiveFile);
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
cpuUsage: num(stats.cpu_stats?.cpu_usage?.total_usage),
|
|
260
|
+
netRx,
|
|
261
|
+
netTx,
|
|
262
|
+
blockRead,
|
|
263
|
+
blockWrite,
|
|
264
|
+
memoryUsage,
|
|
265
|
+
memoryLimit: num(stats.memory_stats?.limit),
|
|
266
|
+
pids: num(stats.pids_stats?.current),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// `networks` is typed `unknown` in the generated schema (docker keys it by
|
|
271
|
+
// interface name, so there's no fixed shape to generate). Parse the two fields
|
|
272
|
+
// we sum.
|
|
273
|
+
const networksSchema = z
|
|
274
|
+
.record(
|
|
275
|
+
z.string(),
|
|
276
|
+
z.object({
|
|
277
|
+
rx_bytes: z.coerce.bigint().nullish(),
|
|
278
|
+
tx_bytes: z.coerce.bigint().nullish(),
|
|
279
|
+
}),
|
|
280
|
+
)
|
|
281
|
+
.nullish();
|
|
282
|
+
|
|
283
|
+
async function readRequestLine(conn: Deno.UnixConn): Promise<void> {
|
|
284
|
+
// Closing the conn on deadline is what unblocks a parked read: `conn.read`
|
|
285
|
+
// rejects once the socket is gone, so the loop can't wait past the timeout.
|
|
286
|
+
let timedOut = false;
|
|
287
|
+
const timer = setTimeout(() => {
|
|
288
|
+
timedOut = true;
|
|
289
|
+
try {
|
|
290
|
+
conn.close();
|
|
291
|
+
} catch {}
|
|
292
|
+
}, REQUEST_READ_TIMEOUT_MS);
|
|
293
|
+
try {
|
|
294
|
+
const buf = new Uint8Array(1);
|
|
295
|
+
const collected: number[] = [];
|
|
296
|
+
while (collected.length < 4096) {
|
|
297
|
+
const n = await conn.read(buf);
|
|
298
|
+
if (n === null) break;
|
|
299
|
+
if (n === 0) continue;
|
|
300
|
+
if (buf[0] === 0x0a) break;
|
|
301
|
+
collected.push(buf[0]!);
|
|
302
|
+
}
|
|
303
|
+
const raw = decoder.decode(new Uint8Array(collected)).trim();
|
|
304
|
+
if (!raw) return;
|
|
305
|
+
try {
|
|
306
|
+
sampleRequestSchema.parse(JSON.parse(raw));
|
|
307
|
+
} catch {
|
|
308
|
+
// The request carries nothing this server acts on; a malformed line
|
|
309
|
+
// still gets a sample rather than an error.
|
|
310
|
+
}
|
|
311
|
+
} catch (err) {
|
|
312
|
+
// A read rejecting because our own timer closed the conn is the timeout
|
|
313
|
+
// firing, not a real fault — surface it as one clear message rather than a
|
|
314
|
+
// bare BadResource. Any other read error propagates as-is.
|
|
315
|
+
if (timedOut) throw new Error("stats request read timed out");
|
|
316
|
+
throw err;
|
|
317
|
+
} finally {
|
|
318
|
+
clearTimeout(timer);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export type { DiskSample };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Consumer-side helper for the worker's `home-proxy.sock` broker. A
|
|
2
|
+
// granted app mounts the per-app socket DIRECTORY and calls `callHome` to make
|
|
3
|
+
// an authenticated control-plane request over the socket inside it; the worker
|
|
4
|
+
// relays it across the mesh to Home. App containers are NOT on the mesh, so
|
|
5
|
+
// this is the only channel an app has to reach Home.
|
|
6
|
+
//
|
|
7
|
+
// `Deno` is referenced only inside the function body — never at module
|
|
8
|
+
// load — so this stays QuickJS-safe and can be re-exported from the
|
|
9
|
+
// top-level `@yourrealm/sdk` barrel without dragging Deno into eval.
|
|
10
|
+
// Mirrors `spawn-client`/`logs-client` framing: connect, write one request
|
|
11
|
+
// line, read one response line, return.
|
|
12
|
+
|
|
13
|
+
import { REALM_HOME_PROXY_CONTAINER_PATH } from "../../worker-protocol/home-proxy-paths.ts";
|
|
14
|
+
import {
|
|
15
|
+
homeProxyResponseSchema,
|
|
16
|
+
type HomeProxyMethod,
|
|
17
|
+
type HomeProxyResponse,
|
|
18
|
+
} from "../docker/home-proxy-server/protocol.ts";
|
|
19
|
+
|
|
20
|
+
export interface CallHomeOpts {
|
|
21
|
+
/** Defaults to {@link REALM_HOME_PROXY_CONTAINER_PATH}. Override for tests. */
|
|
22
|
+
socketPath?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Make one control-plane call through the worker's proxy socket. `path` is
|
|
27
|
+
* a sub-path of Home's `/api-access` surface. Resolves
|
|
28
|
+
* with Home's `{ status, body }`; rejects if the broker closes without a
|
|
29
|
+
* response line.
|
|
30
|
+
*/
|
|
31
|
+
export async function callHome(
|
|
32
|
+
method: HomeProxyMethod,
|
|
33
|
+
path: string,
|
|
34
|
+
body?: unknown,
|
|
35
|
+
opts: CallHomeOpts = {},
|
|
36
|
+
): Promise<HomeProxyResponse> {
|
|
37
|
+
const socketPath = opts.socketPath ?? REALM_HOME_PROXY_CONTAINER_PATH;
|
|
38
|
+
const conn = await Deno.connect({ transport: "unix", path: socketPath });
|
|
39
|
+
// `TextEncoder`/`TextDecoder` are constructed INSIDE the function, never
|
|
40
|
+
// at module load: this module is re-exported from the top-level barrel,
|
|
41
|
+
// which loads under QuickJS during realm.ts eval, and QuickJS provides
|
|
42
|
+
// neither global. Per-instance decoder also lets a multi-byte UTF-8
|
|
43
|
+
// sequence straddling two reads survive the boundary.
|
|
44
|
+
const encoder = new TextEncoder();
|
|
45
|
+
const decoder = new TextDecoder("utf-8");
|
|
46
|
+
try {
|
|
47
|
+
// Omit `body` from the line entirely when undefined, so a no-body call
|
|
48
|
+
// writes exactly `{"method":…,"path":…}`.
|
|
49
|
+
const line = JSON.stringify(body === undefined ? { method, path } : { method, path, body });
|
|
50
|
+
await conn.write(encoder.encode(line + "\n"));
|
|
51
|
+
|
|
52
|
+
const buf = new Uint8Array(8192);
|
|
53
|
+
let remainder = "";
|
|
54
|
+
while (true) {
|
|
55
|
+
const n = await conn.read(buf);
|
|
56
|
+
if (n === null) break;
|
|
57
|
+
remainder += decoder.decode(buf.slice(0, n), { stream: true });
|
|
58
|
+
const nl = remainder.indexOf("\n");
|
|
59
|
+
if (nl !== -1) {
|
|
60
|
+
const raw = remainder.slice(0, nl).trim();
|
|
61
|
+
const parsed = homeProxyResponseSchema.parse(JSON.parse(raw));
|
|
62
|
+
return { status: parsed.status, body: parsed.body };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
throw new Error("home proxy closed without a response line");
|
|
66
|
+
} finally {
|
|
67
|
+
try {
|
|
68
|
+
conn.close();
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
}
|