nylorun 0.7.0-beta → 0.9.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 +109 -0
- package/README.md +95 -25
- package/dist/cli.js +17 -2
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +10 -0
- package/dist/project/link.d.ts +13 -7
- package/dist/project/link.js +13 -2
- 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 +71 -1
- package/dist/stack/commands.js +209 -38
- package/dist/stack/compose-file.d.ts +47 -7
- package/dist/stack/compose-file.js +223 -18
- package/dist/stack/env-file.d.ts +50 -14
- package/dist/stack/env-file.js +87 -40
- package/dist/stack/host-files.js +11 -1
- package/dist/stack/images.d.ts +8 -2
- package/dist/stack/images.js +8 -2
- package/dist/stack/index.d.ts +9 -0
- package/dist/stack/index.js +18 -1
- package/dist/stack/keys.d.ts +4 -0
- package/dist/stack/keys.js +72 -0
- package/dist/stack/mcp.d.ts +8 -0
- package/dist/stack/mcp.js +114 -0
- package/dist/stack/operator-keys.d.ts +39 -0
- package/dist/stack/operator-keys.js +146 -0
- package/dist/stack/paths.d.ts +32 -0
- package/dist/stack/paths.js +11 -0
- package/dist/stack/prepare.d.ts +12 -6
- package/dist/stack/prepare.js +24 -6
- package/dist/stack/sandbox.d.ts +4 -0
- package/dist/stack/sandbox.js +106 -0
- package/package.json +5 -5
- package/dist/project/derived-key.d.ts +0 -8
- package/dist/project/derived-key.js +0 -18
|
@@ -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,8 +1,11 @@
|
|
|
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
6
|
import { type FetchLike } from "./studio-login.js";
|
|
5
|
-
|
|
7
|
+
import { type AdminEndpoint } from "./operator-keys.js";
|
|
8
|
+
export declare const STACK_SERVICES: readonly ["postgres", "restate", "s2-lite", "rustfs", "gateway", "runtime", "harness", "studio"];
|
|
6
9
|
export declare const stackUsage: string;
|
|
7
10
|
export interface StackDeps {
|
|
8
11
|
/** Environment snapshot (NYLORUN_HOME, NYLORUN_TENANT, image overrides, NYLORUN_COMPOSE_PROJECT). */
|
|
@@ -79,14 +82,27 @@ export interface StackStatus {
|
|
|
79
82
|
/** Exact origins that may show Studio in a frame (Studio §8.9). */
|
|
80
83
|
embedOrigins?: string[];
|
|
81
84
|
};
|
|
85
|
+
/** Restate's UI: `url` only while published (`nylorun start --restate-ui`). */
|
|
82
86
|
restate: {
|
|
83
87
|
url?: string;
|
|
88
|
+
published: boolean;
|
|
84
89
|
};
|
|
85
90
|
/** The gateway container (the Model Gate), in the combined packing. */
|
|
86
91
|
gateway: {
|
|
87
92
|
state: string;
|
|
88
93
|
healthy: boolean;
|
|
89
94
|
};
|
|
95
|
+
/**
|
|
96
|
+
* Where agent turns, MCP servers and workspaces run (F6.2): `remote`, the harness container,
|
|
97
|
+
* or `in-process`, the runtime container (`NYLORUN_HARNESS` in .env). `connected` counts the
|
|
98
|
+
* harnesses attached to the Tenant, as the Runtime reports them.
|
|
99
|
+
*/
|
|
100
|
+
harness: {
|
|
101
|
+
mode: HarnessMode;
|
|
102
|
+
state: string;
|
|
103
|
+
healthy: boolean;
|
|
104
|
+
connected?: number;
|
|
105
|
+
};
|
|
90
106
|
services: ComposeService[];
|
|
91
107
|
}
|
|
92
108
|
/** What `nylorun status` reports, as data (`nylorun doctor`). */
|
|
@@ -113,6 +129,38 @@ export interface StackEndpoints {
|
|
|
113
129
|
/** This call started the Runtime (it was not running before). */
|
|
114
130
|
started: boolean;
|
|
115
131
|
}
|
|
132
|
+
/** The running Tenant's API, reached with an operator key (`nylorun sandbox`). */
|
|
133
|
+
export interface TenantApi {
|
|
134
|
+
/** The Tenant's name. */
|
|
135
|
+
name: string;
|
|
136
|
+
runtimeUrl: string;
|
|
137
|
+
/**
|
|
138
|
+
* The linked project's key (`.nylorun/credentials.json`) when it reaches the Tenant, else
|
|
139
|
+
* the operator key `cli` the Host root keeps (`cli-credentials.json`).
|
|
140
|
+
*/
|
|
141
|
+
applicationKey: string;
|
|
142
|
+
}
|
|
143
|
+
/** The selected Tenant's Admin API while it runs: `nylorun key` and `runningTenantApi`. */
|
|
144
|
+
export interface RunningAdmin extends AdminEndpoint {
|
|
145
|
+
/** The Tenant's name. */
|
|
146
|
+
name: string;
|
|
147
|
+
runtimeUrl: string;
|
|
148
|
+
paths: StackPaths;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The selected Tenant's Admin API while it runs; exit 3 when it is not running, 7 when it is
|
|
152
|
+
* not open.
|
|
153
|
+
*/
|
|
154
|
+
export declare function runningAdmin(deps: StackDeps, options?: {
|
|
155
|
+
name?: string;
|
|
156
|
+
}): Promise<RunningAdmin>;
|
|
157
|
+
/**
|
|
158
|
+
* The selected Tenant's API while it runs: commands that read or change the Tenant's data never
|
|
159
|
+
* start it. Exit 3 when it is not running, 7 when it is not open.
|
|
160
|
+
*/
|
|
161
|
+
export declare function runningTenantApi(deps: StackDeps, options?: {
|
|
162
|
+
name?: string;
|
|
163
|
+
}): Promise<TenantApi>;
|
|
116
164
|
/**
|
|
117
165
|
* Start the selected Tenant unless it is already running (`nylorun studio`): the
|
|
118
166
|
* `start` code path without its output, its Tenant wait or the Project link. Compose
|
|
@@ -122,6 +170,28 @@ export declare function ensureStack(deps: StackDeps, options: {
|
|
|
122
170
|
studio: boolean;
|
|
123
171
|
name?: string;
|
|
124
172
|
}): Promise<StackEndpoints>;
|
|
173
|
+
/** A started Tenant as `nylorun sandbox` sees it. */
|
|
174
|
+
export interface SelectedTenant {
|
|
175
|
+
name: string;
|
|
176
|
+
paths: StackPaths;
|
|
177
|
+
project: string;
|
|
178
|
+
/** `docker compose` arguments for this Tenant's project and files. */
|
|
179
|
+
compose(...args: string[]): string[];
|
|
180
|
+
}
|
|
181
|
+
/** The Tenant `nylorun sandbox` acts on; it must have been started once. */
|
|
182
|
+
export declare function selectTenant(deps: StackDeps, options?: {
|
|
183
|
+
name?: string;
|
|
184
|
+
}): Promise<SelectedTenant>;
|
|
185
|
+
/**
|
|
186
|
+
* Rewrite the Tenant's Compose files with `sandboxes` set (an object) or removed (null),
|
|
187
|
+
* and bring it up again: Compose recreates the containers whose settings changed.
|
|
188
|
+
*/
|
|
189
|
+
export declare function restartTenant(deps: StackDeps, options: {
|
|
190
|
+
name?: string;
|
|
191
|
+
sandboxes: SandboxStackEnv | null;
|
|
192
|
+
}): Promise<void>;
|
|
193
|
+
/** Ports every Tenant on this machine keeps for sandboxes, but the one under `except`. */
|
|
194
|
+
export declare function reservedSandboxPorts(deps: StackDeps, except: string): Promise<Set<number>>;
|
|
125
195
|
/** Add Studio's `next` path (e.g. `/tenants/<id>`) to a login URL. */
|
|
126
196
|
export declare function withNext(loginUrl: string, next: string | undefined): string;
|
|
127
197
|
/** The Tenant's Studio page, as a login `next` path. */
|