nylorun 0.6.0-beta → 0.8.0-beta
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/CHANGELOG.md +61 -0
- package/README.md +77 -73
- package/dist/cli.js +7 -2
- package/dist/doctor.d.ts +2 -4
- package/dist/doctor.js +12 -19
- package/dist/sandbox/cluster-file.d.ts +47 -0
- package/dist/sandbox/cluster-file.js +55 -0
- package/dist/sandbox/commands.d.ts +41 -0
- package/dist/sandbox/commands.js +281 -0
- package/dist/sandbox/kubectl.d.ts +39 -0
- package/dist/sandbox/kubectl.js +70 -0
- package/dist/sandbox/manifests.d.ts +41 -0
- package/dist/sandbox/manifests.js +138 -0
- package/dist/sandbox/probe.d.ts +44 -0
- package/dist/sandbox/probe.js +137 -0
- package/dist/stack/commands.d.ts +56 -9
- package/dist/stack/commands.js +167 -123
- package/dist/stack/compose-file.d.ts +42 -5
- package/dist/stack/compose-file.js +208 -21
- package/dist/stack/env-file.d.ts +48 -6
- package/dist/stack/env-file.js +81 -9
- package/dist/stack/host-files.js +11 -1
- package/dist/stack/images.d.ts +8 -4
- package/dist/stack/images.js +8 -3
- package/dist/stack/index.d.ts +4 -2
- package/dist/stack/index.js +7 -3
- package/dist/stack/paths.d.ts +15 -0
- package/dist/stack/paths.js +7 -0
- package/dist/stack/ports.d.ts +0 -2
- package/dist/stack/ports.js +0 -2
- package/dist/stack/prepare.d.ts +10 -4
- package/dist/stack/prepare.js +20 -3
- package/dist/stack/sandbox.d.ts +4 -0
- package/dist/stack/sandbox.js +106 -0
- package/dist/stack/stacks.d.ts +8 -0
- package/dist/stack/stacks.js +19 -0
- package/dist/stack/studio-login.d.ts +2 -2
- package/dist/stack/studio-login.js +2 -2
- package/package.json +5 -5
- package/dist/stack/proxy.d.ts +0 -78
- package/dist/stack/proxy.js +0 -224
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { CliError } from "../errors.js";
|
|
3
|
+
import { networkPolicy, PROBE_IMAGE, probePod } from "./manifests.js";
|
|
4
|
+
const DOCKER_DESKTOP_HOST = "192.168.65.254";
|
|
5
|
+
/** Docker Desktop and OrbStack forward the host address to this machine's loopback. */
|
|
6
|
+
export function defaultBind(context, hostAddress) {
|
|
7
|
+
if (hostAddress === DOCKER_DESKTOP_HOST || context === "docker-desktop" || context === "orbstack")
|
|
8
|
+
return "127.0.0.1";
|
|
9
|
+
return hostAddress;
|
|
10
|
+
}
|
|
11
|
+
const IPV4 = /^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)$/;
|
|
12
|
+
export function assertIPv4(value, option) {
|
|
13
|
+
if (!IPV4.test(value))
|
|
14
|
+
throw new CliError(`${option} must be an IPv4 address: ${value}`, 2);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
/** The last IPv4 address `nslookup` prints for the name (the first is the DNS server). */
|
|
18
|
+
export function parseNslookup(output) {
|
|
19
|
+
const answer = output.split(/Name:\s*\S+/).slice(1).join("\n");
|
|
20
|
+
const addresses = [...answer.matchAll(/Address(?:es)?:?\s*(?:\d+\s+)?([0-9.]+)\b/g)]
|
|
21
|
+
.map((match) => match[1])
|
|
22
|
+
.filter((address) => IPV4.test(address));
|
|
23
|
+
return addresses.at(-1);
|
|
24
|
+
}
|
|
25
|
+
async function sleep(ms) {
|
|
26
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
27
|
+
}
|
|
28
|
+
/** `wget` from a probe pod; true when the connection succeeds. */
|
|
29
|
+
async function reaches(kube, namespace, pod, address, port) {
|
|
30
|
+
const result = await kube.run(["exec", "-n", namespace, pod, "--", "wget", "-q", "-O", "-", "-T", "3", `http://${address}:${port}/`], { timeoutMs: 20_000 });
|
|
31
|
+
return result.code === 0;
|
|
32
|
+
}
|
|
33
|
+
export async function runProbe(deps, options) {
|
|
34
|
+
const { kube, docker } = deps;
|
|
35
|
+
const suffix = randomBytes(3).toString("hex");
|
|
36
|
+
const namespace = `nylorun-sbx-probe-${suffix}`;
|
|
37
|
+
const listener = namespace;
|
|
38
|
+
const poll = deps.pollMs ?? 1000;
|
|
39
|
+
let listenerStarted = false;
|
|
40
|
+
const fail = (message) => new CliError(`Refusing context ${kube.context}: ${message}`, 6);
|
|
41
|
+
try {
|
|
42
|
+
await kube.apply("Creating the probe namespace", [
|
|
43
|
+
{ apiVersion: "v1", kind: "Namespace", metadata: { name: namespace, labels: { "app.kubernetes.io/managed-by": "nylorun" } } },
|
|
44
|
+
probePod(namespace, "peer", { role: "peer", serve: true }),
|
|
45
|
+
probePod(namespace, "control", { role: "control" }),
|
|
46
|
+
probePod(namespace, "restricted", { role: "restricted" }),
|
|
47
|
+
]);
|
|
48
|
+
await kube.check("Waiting for the probe pods", [
|
|
49
|
+
"wait", "--for=condition=Ready", "pod", "--all", "-n", namespace, "--timeout=180s",
|
|
50
|
+
], { timeoutMs: 200_000 });
|
|
51
|
+
let hostAddress = options.hostAddress;
|
|
52
|
+
if (!hostAddress) {
|
|
53
|
+
const lookup = await kube.run(["exec", "-n", namespace, "control", "--", "nslookup", "host.docker.internal"]);
|
|
54
|
+
// busybox nslookup may exit non-zero after an NXDOMAIN on a search domain: parse anyway.
|
|
55
|
+
hostAddress = parseNslookup(lookup.stdout);
|
|
56
|
+
if (!hostAddress)
|
|
57
|
+
throw fail("pods cannot resolve host.docker.internal. Pass --host-address with the Docker host's address as pods reach it (172.17.0.1 for kind on Linux).");
|
|
58
|
+
}
|
|
59
|
+
const bindAddress = options.bindAddress ?? defaultBind(kube.context, hostAddress);
|
|
60
|
+
const allowed = await deps.ports.pickFree();
|
|
61
|
+
let denied = await deps.ports.pickFree();
|
|
62
|
+
if (denied === allowed)
|
|
63
|
+
denied = await deps.ports.pickFree();
|
|
64
|
+
const started = await docker.run([
|
|
65
|
+
"run", "--detach", "--rm", "--name", listener, "--label", "dev.nylorun.probe=true",
|
|
66
|
+
"--publish", `${bindAddress}:${allowed}:8080`, "--publish", `${bindAddress}:${denied}:8081`,
|
|
67
|
+
PROBE_IMAGE, "sh", "-c",
|
|
68
|
+
"echo ok > /tmp/index.html; httpd -p 8081 -h /tmp; exec httpd -f -p 8080 -h /tmp",
|
|
69
|
+
]);
|
|
70
|
+
if (started.code !== 0)
|
|
71
|
+
throw fail(`could not publish a probe listener on ${bindAddress}: ${started.stderr.trim()}`);
|
|
72
|
+
listenerStarted = true;
|
|
73
|
+
const peer = (await kube.check("Reading the peer pod's address", [
|
|
74
|
+
"get", "pod", "peer", "-n", namespace, "-o", "jsonpath={.status.podIP}",
|
|
75
|
+
])).trim();
|
|
76
|
+
if (!(await reaches(kube, namespace, "control", peer, 8080)))
|
|
77
|
+
throw fail("a pod cannot reach another pod in its namespace (the probe cannot tell a policy from a broken network).");
|
|
78
|
+
if (!(await reaches(kube, namespace, "control", hostAddress, allowed)))
|
|
79
|
+
throw fail(`pods cannot reach ${hostAddress}:${allowed} on the Docker host (published on ${bindAddress}). Pass --host-address and --bind-address for this cluster.`);
|
|
80
|
+
await kube.apply("Applying the probe NetworkPolicy", [
|
|
81
|
+
networkPolicy(namespace, hostAddress, [allowed], { name: "probe", podSelector: { "nylorun.dev/probe": "restricted" } }),
|
|
82
|
+
]);
|
|
83
|
+
const deadline = Date.now() + (deps.policyTimeoutMs ?? 30_000);
|
|
84
|
+
while (await reaches(kube, namespace, "restricted", peer, 8080)) {
|
|
85
|
+
if (Date.now() > deadline)
|
|
86
|
+
throw fail("NetworkPolicy is not enforced: a pod under a deny policy still reached another pod. Sandboxes need a CNI that enforces NetworkPolicy (kindnet with policy support, Calico, Cilium).");
|
|
87
|
+
await sleep(poll);
|
|
88
|
+
}
|
|
89
|
+
if (await reaches(kube, namespace, "restricted", hostAddress, denied))
|
|
90
|
+
throw fail(`NetworkPolicy is not enforced per port: a restricted pod reached ${hostAddress}:${denied}.`);
|
|
91
|
+
if (!(await reaches(kube, namespace, "restricted", hostAddress, allowed)))
|
|
92
|
+
throw fail(`a restricted pod cannot reach the allowed ${hostAddress}:${allowed}: the policy's ipBlock does not match the host route.`);
|
|
93
|
+
const notPulled = options.prePull?.length ? await prePull(deps, namespace, options.prePull) : [];
|
|
94
|
+
return { hostAddress, bindAddress, enforced: true, probedAt: new Date().toISOString(), notPulled };
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
if (listenerStarted)
|
|
98
|
+
await docker.run(["rm", "--force", listener]);
|
|
99
|
+
await kube.run(["delete", "namespace", namespace, "--ignore-not-found", "--wait=false"]);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Pull images on every node with a pod per node and image (a cold pull of a pack-sized image
|
|
104
|
+
* takes about 30 s). Best effort: images that do not pull (a local build, no registry) are
|
|
105
|
+
* returned, for a warning.
|
|
106
|
+
*/
|
|
107
|
+
async function prePull(deps, namespace, images) {
|
|
108
|
+
const { kube } = deps;
|
|
109
|
+
const nodes = JSON.parse(await kube.check("Listing nodes", ["get", "nodes", "-o", "json"]));
|
|
110
|
+
const pods = nodes.items.flatMap((node, n) => images.map((image, i) => ({
|
|
111
|
+
name: `pull-${n}-${i}`,
|
|
112
|
+
image,
|
|
113
|
+
manifest: probePod(namespace, `pull-${n}-${i}`, {
|
|
114
|
+
role: "pull", image, nodeName: node.metadata.name, command: ["true"],
|
|
115
|
+
}),
|
|
116
|
+
})));
|
|
117
|
+
await kube.apply("Pre-pulling images", pods.map((pod) => pod.manifest));
|
|
118
|
+
const failed = new Set();
|
|
119
|
+
const done = new Set();
|
|
120
|
+
const deadline = Date.now() + 300_000;
|
|
121
|
+
while (done.size + failed.size < pods.length && Date.now() < deadline) {
|
|
122
|
+
const list = await kube.getJson(["pods", "-n", namespace, "-l", "nylorun.dev/probe=pull"]);
|
|
123
|
+
for (const item of list?.items ?? []) {
|
|
124
|
+
const pod = pods.find((p) => p.name === item.metadata.name);
|
|
125
|
+
if (!pod || done.has(pod.name) || failed.has(pod.name))
|
|
126
|
+
continue;
|
|
127
|
+
const reason = item.status?.containerStatuses?.[0]?.state?.waiting?.reason ?? "";
|
|
128
|
+
if (/ErrImagePull|ImagePullBackOff|InvalidImageName|ErrImageNeverPull/.test(reason))
|
|
129
|
+
failed.add(pod.name);
|
|
130
|
+
else if (item.status?.phase && item.status.phase !== "Pending")
|
|
131
|
+
done.add(pod.name);
|
|
132
|
+
}
|
|
133
|
+
if (done.size + failed.size < pods.length)
|
|
134
|
+
await sleep(deps.pollMs ?? 2000);
|
|
135
|
+
}
|
|
136
|
+
return images.filter((image) => pods.some((pod) => pod.image === image && !done.has(pod.name)));
|
|
137
|
+
}
|
package/dist/stack/commands.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { HostTenant } from "@nylorun/core/contracts";
|
|
2
2
|
import { type ComposeService, type DockerRunner } from "./docker.js";
|
|
3
|
+
import type { HarnessMode, SandboxStackEnv } from "./env-file.js";
|
|
4
|
+
import { type StackPaths } from "./paths.js";
|
|
3
5
|
import type { PortProbe } from "./ports.js";
|
|
4
|
-
import { type ProxyStatus } from "./proxy.js";
|
|
5
6
|
import { type FetchLike } from "./studio-login.js";
|
|
6
|
-
export declare const STACK_SERVICES: readonly ["postgres", "restate", "s2-lite", "gateway", "runtime", "studio"];
|
|
7
|
+
export declare const STACK_SERVICES: readonly ["postgres", "restate", "s2-lite", "rustfs", "gateway", "runtime", "harness", "studio"];
|
|
7
8
|
export declare const stackUsage: string;
|
|
8
9
|
export interface StackDeps {
|
|
9
10
|
/** Environment snapshot (NYLORUN_HOME, NYLORUN_TENANT, image overrides, NYLORUN_COMPOSE_PROJECT). */
|
|
@@ -74,26 +75,35 @@ export interface StackStatus {
|
|
|
74
75
|
/** The Tenant as the Runtime reports it, while it answers. */
|
|
75
76
|
tenant?: StackTenant;
|
|
76
77
|
studio: {
|
|
77
|
-
/**
|
|
78
|
+
/** `http://localhost:<port>`: also what an embedding app frames (same site as its own localhost). */
|
|
78
79
|
url?: string;
|
|
79
|
-
/** Through the Studio proxy, when it serves the Tenant. */
|
|
80
|
-
proxyUrl?: string;
|
|
81
80
|
state: string;
|
|
82
81
|
/** Exact origins that may show Studio in a frame (Studio §8.9). */
|
|
83
82
|
embedOrigins?: string[];
|
|
84
83
|
};
|
|
84
|
+
/** Restate's UI: `url` only while published (`nylorun start --restate-ui`). */
|
|
85
85
|
restate: {
|
|
86
86
|
url?: string;
|
|
87
|
+
published: boolean;
|
|
87
88
|
};
|
|
88
89
|
/** The gateway container (the Model Gate), in the combined packing. */
|
|
89
90
|
gateway: {
|
|
90
91
|
state: string;
|
|
91
92
|
healthy: boolean;
|
|
92
93
|
};
|
|
94
|
+
/**
|
|
95
|
+
* Where agent turns, MCP servers and workspaces run (F6.2): `remote`, the harness container,
|
|
96
|
+
* or `in-process`, the runtime container (`NYLORUN_HARNESS` in .env). `connected` counts the
|
|
97
|
+
* harnesses attached to the Tenant, as the Runtime reports them.
|
|
98
|
+
*/
|
|
99
|
+
harness: {
|
|
100
|
+
mode: HarnessMode;
|
|
101
|
+
state: string;
|
|
102
|
+
healthy: boolean;
|
|
103
|
+
connected?: number;
|
|
104
|
+
};
|
|
93
105
|
services: ComposeService[];
|
|
94
106
|
}
|
|
95
|
-
/** The Studio proxy, as data (`nylorun doctor`). */
|
|
96
|
-
export declare function readProxyReport(deps: StackDeps): Promise<ProxyStatus>;
|
|
97
107
|
/** What `nylorun status` reports, as data (`nylorun doctor`). */
|
|
98
108
|
export declare function readStackStatus(deps: StackDeps, options?: {
|
|
99
109
|
name?: string;
|
|
@@ -111,13 +121,28 @@ export interface StackEndpoints {
|
|
|
111
121
|
hostId: string;
|
|
112
122
|
adminKey: string;
|
|
113
123
|
studioPort: number;
|
|
114
|
-
/**
|
|
124
|
+
/** `http://localhost:<studio port>` */
|
|
115
125
|
studioUrl: string;
|
|
116
126
|
/** Studio is running and healthy. */
|
|
117
127
|
studioUp: boolean;
|
|
118
128
|
/** This call started the Runtime (it was not running before). */
|
|
119
129
|
started: boolean;
|
|
120
130
|
}
|
|
131
|
+
/** The running Tenant's API, reached as the Project's derived principal (`nylorun sandbox`). */
|
|
132
|
+
export interface TenantApi {
|
|
133
|
+
/** The Tenant's name. */
|
|
134
|
+
name: string;
|
|
135
|
+
runtimeUrl: string;
|
|
136
|
+
/** The `project` derived principal's key, derived from the admin key; never stored. */
|
|
137
|
+
applicationKey: string;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The selected Tenant's API while it runs: commands that read or change the Tenant's data never
|
|
141
|
+
* start it. Exit 3 when it is not running, 7 when it is not open.
|
|
142
|
+
*/
|
|
143
|
+
export declare function runningTenantApi(deps: StackDeps, options?: {
|
|
144
|
+
name?: string;
|
|
145
|
+
}): Promise<TenantApi>;
|
|
121
146
|
/**
|
|
122
147
|
* Start the selected Tenant unless it is already running (`nylorun studio`): the
|
|
123
148
|
* `start` code path without its output, its Tenant wait or the Project link. Compose
|
|
@@ -127,6 +152,28 @@ export declare function ensureStack(deps: StackDeps, options: {
|
|
|
127
152
|
studio: boolean;
|
|
128
153
|
name?: string;
|
|
129
154
|
}): Promise<StackEndpoints>;
|
|
155
|
+
/** A started Tenant as `nylorun sandbox` sees it. */
|
|
156
|
+
export interface SelectedTenant {
|
|
157
|
+
name: string;
|
|
158
|
+
paths: StackPaths;
|
|
159
|
+
project: string;
|
|
160
|
+
/** `docker compose` arguments for this Tenant's project and files. */
|
|
161
|
+
compose(...args: string[]): string[];
|
|
162
|
+
}
|
|
163
|
+
/** The Tenant `nylorun sandbox` acts on; it must have been started once. */
|
|
164
|
+
export declare function selectTenant(deps: StackDeps, options?: {
|
|
165
|
+
name?: string;
|
|
166
|
+
}): Promise<SelectedTenant>;
|
|
167
|
+
/**
|
|
168
|
+
* Rewrite the Tenant's Compose files with `sandboxes` set (an object) or removed (null),
|
|
169
|
+
* and bring it up again: Compose recreates the containers whose settings changed.
|
|
170
|
+
*/
|
|
171
|
+
export declare function restartTenant(deps: StackDeps, options: {
|
|
172
|
+
name?: string;
|
|
173
|
+
sandboxes: SandboxStackEnv | null;
|
|
174
|
+
}): Promise<void>;
|
|
175
|
+
/** Ports every Tenant on this machine keeps for sandboxes, but the one under `except`. */
|
|
176
|
+
export declare function reservedSandboxPorts(deps: StackDeps, except: string): Promise<Set<number>>;
|
|
130
177
|
/** Add Studio's `next` path (e.g. `/tenants/<id>`) to a login URL. */
|
|
131
178
|
export declare function withNext(loginUrl: string, next: string | undefined): string;
|
|
132
179
|
/** The Tenant's Studio page, as a login `next` path. */
|
|
@@ -135,7 +182,7 @@ export declare function tenantStudioPath(tenantId: string): string;
|
|
|
135
182
|
* Mint a fresh Studio login URL on a running Tenant, landing on `next`.
|
|
136
183
|
* Undefined (after a warning) when Studio does not answer.
|
|
137
184
|
*/
|
|
138
|
-
export declare function studioLoginUrl(deps: StackDeps, running: Pick<StackEndpoints, "studioPort" | "
|
|
185
|
+
export declare function studioLoginUrl(deps: StackDeps, running: Pick<StackEndpoints, "studioPort" | "adminKey">, next?: string): Promise<string | undefined>;
|
|
139
186
|
/** `nylorun studio`, landing on `next` when given, else on the Tenant's page. */
|
|
140
187
|
export declare function runStudioCommand(args: readonly string[], deps: StackDeps, options?: {
|
|
141
188
|
next?: string;
|