nylorun 0.7.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 +51 -0
- package/README.md +50 -10
- package/dist/cli.js +7 -2
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +10 -0
- 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 +53 -1
- package/dist/stack/commands.js +141 -23
- package/dist/stack/compose-file.d.ts +42 -5
- package/dist/stack/compose-file.js +207 -15
- package/dist/stack/env-file.d.ts +48 -1
- package/dist/stack/env-file.js +81 -4
- 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 +3 -0
- package/dist/stack/index.js +6 -0
- package/dist/stack/paths.d.ts +15 -0
- package/dist/stack/paths.js +7 -0
- package/dist/stack/prepare.d.ts +10 -2
- package/dist/stack/prepare.js +20 -2
- package/dist/stack/sandbox.d.ts +4 -0
- package/dist/stack/sandbox.js +106 -0
- package/package.json +5 -5
|
@@ -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,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
6
|
import { type FetchLike } from "./studio-login.js";
|
|
5
|
-
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"];
|
|
6
8
|
export declare const stackUsage: string;
|
|
7
9
|
export interface StackDeps {
|
|
8
10
|
/** Environment snapshot (NYLORUN_HOME, NYLORUN_TENANT, image overrides, NYLORUN_COMPOSE_PROJECT). */
|
|
@@ -79,14 +81,27 @@ export interface StackStatus {
|
|
|
79
81
|
/** Exact origins that may show Studio in a frame (Studio §8.9). */
|
|
80
82
|
embedOrigins?: string[];
|
|
81
83
|
};
|
|
84
|
+
/** Restate's UI: `url` only while published (`nylorun start --restate-ui`). */
|
|
82
85
|
restate: {
|
|
83
86
|
url?: string;
|
|
87
|
+
published: boolean;
|
|
84
88
|
};
|
|
85
89
|
/** The gateway container (the Model Gate), in the combined packing. */
|
|
86
90
|
gateway: {
|
|
87
91
|
state: string;
|
|
88
92
|
healthy: boolean;
|
|
89
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
|
+
};
|
|
90
105
|
services: ComposeService[];
|
|
91
106
|
}
|
|
92
107
|
/** What `nylorun status` reports, as data (`nylorun doctor`). */
|
|
@@ -113,6 +128,21 @@ export interface StackEndpoints {
|
|
|
113
128
|
/** This call started the Runtime (it was not running before). */
|
|
114
129
|
started: boolean;
|
|
115
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>;
|
|
116
146
|
/**
|
|
117
147
|
* Start the selected Tenant unless it is already running (`nylorun studio`): the
|
|
118
148
|
* `start` code path without its output, its Tenant wait or the Project link. Compose
|
|
@@ -122,6 +152,28 @@ export declare function ensureStack(deps: StackDeps, options: {
|
|
|
122
152
|
studio: boolean;
|
|
123
153
|
name?: string;
|
|
124
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>>;
|
|
125
177
|
/** Add Studio's `next` path (e.g. `/tenants/<id>`) to a login URL. */
|
|
126
178
|
export declare function withNext(loginUrl: string, next: string | undefined): string;
|
|
127
179
|
/** The Tenant's Studio page, as a login `next` path. */
|
package/dist/stack/commands.js
CHANGED
|
@@ -15,13 +15,27 @@ import { stackPaths } from "./paths.js";
|
|
|
15
15
|
import { prepareStack, readStackEnv } from "./prepare.js";
|
|
16
16
|
import { assertTenantName, chooseTenantName, DEFAULT_TENANT, defaultNylorunRoot, listTenants, moveStackRoots, readTenantRecord, removeOldProxy, sanitizeTenantName, tenantRoot, tenantsDir, writeTenantRecord, } from "./stacks.js";
|
|
17
17
|
import { mintStudioLogin, studioOrigin } from "./studio-login.js";
|
|
18
|
-
export const STACK_SERVICES = [
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
export const STACK_SERVICES = [
|
|
19
|
+
"postgres",
|
|
20
|
+
"restate",
|
|
21
|
+
"s2-lite",
|
|
22
|
+
"rustfs",
|
|
23
|
+
"gateway",
|
|
24
|
+
"runtime",
|
|
25
|
+
"harness",
|
|
26
|
+
"studio",
|
|
27
|
+
];
|
|
28
|
+
const CORE_SERVICES = ["postgres", "restate", "s2-lite", "rustfs", "gateway", "runtime", "harness"];
|
|
29
|
+
/** The services `start` waits for: the harness only while it runs turns (`NYLORUN_HARNESS=remote`). */
|
|
30
|
+
function coreServices(harness) {
|
|
31
|
+
return CORE_SERVICES.filter((service) => service !== "harness" || harness === "remote");
|
|
32
|
+
}
|
|
33
|
+
export const stackUsage = ` up|start [--tenant <name>] [--no-link] [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset] [--restate-ui]
|
|
21
34
|
start the project's Tenant, creating it and the Project link (.nylorun/link.json,
|
|
22
35
|
credentials.json) on the first run; print the Runtime and Studio URLs and open Studio
|
|
23
36
|
signed in (in a terminal). --tenant attaches to (or creates) a named Tenant; --no-link
|
|
24
|
-
starts the default Tenant (or --tenant's) without linking the current directory
|
|
37
|
+
starts the default Tenant (or --tenant's) without linking the current directory;
|
|
38
|
+
--restate-ui (or NYLORUN_RESTATE_UI=1) publishes Restate's UI on loopback for debugging
|
|
25
39
|
down|stop [--tenant <name> | --all] stop the Tenant's containers (--all: every Tenant's); keep volumes
|
|
26
40
|
status [--tenant <name>] [--json] the Tenant, its id, services, endpoints and Runtime health
|
|
27
41
|
logs [service] [--tenant <name>] [-f] [--tail <n>]
|
|
@@ -207,8 +221,8 @@ async function waitForHealth(ctx, runtimeUrl, hostId) {
|
|
|
207
221
|
await sleep(ctx.deps.pollMs ?? 500);
|
|
208
222
|
}
|
|
209
223
|
}
|
|
210
|
-
/** `/v1/admin/status`
|
|
211
|
-
async function
|
|
224
|
+
/** `/v1/admin/status` on the operator listener; undefined when it does not answer. */
|
|
225
|
+
async function fetchAdminStatus(deps, adminUrl, adminKey) {
|
|
212
226
|
if (!adminKey)
|
|
213
227
|
return undefined;
|
|
214
228
|
try {
|
|
@@ -223,20 +237,32 @@ async function fetchTenant(deps, adminUrl, adminKey) {
|
|
|
223
237
|
});
|
|
224
238
|
if (!response.ok)
|
|
225
239
|
return undefined;
|
|
226
|
-
const
|
|
240
|
+
const body = (await response.json());
|
|
241
|
+
const tenant = body.tenant;
|
|
227
242
|
if (!tenant || (tenant.state !== "open" && tenant.state !== "unavailable"))
|
|
228
243
|
return undefined;
|
|
244
|
+
const harness = body.aggregate?.harness;
|
|
229
245
|
return {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
246
|
+
tenant: {
|
|
247
|
+
id: typeof tenant.id === "string" ? tenant.id : null,
|
|
248
|
+
name: typeof tenant.name === "string" ? tenant.name : null,
|
|
249
|
+
state: tenant.state,
|
|
250
|
+
...(tenant.cause ? { cause: tenant.cause } : {}),
|
|
251
|
+
},
|
|
252
|
+
...((harness?.mode === "remote" || harness?.mode === "in-process") &&
|
|
253
|
+
typeof harness.connected === "number"
|
|
254
|
+
? { harness: { mode: harness.mode, connected: harness.connected } }
|
|
255
|
+
: {}),
|
|
234
256
|
};
|
|
235
257
|
}
|
|
236
258
|
catch {
|
|
237
259
|
return undefined;
|
|
238
260
|
}
|
|
239
261
|
}
|
|
262
|
+
/** `/v1/admin/status`'s Tenant on the operator listener; undefined when it does not answer. */
|
|
263
|
+
async function fetchTenant(deps, adminUrl, adminKey) {
|
|
264
|
+
return (await fetchAdminStatus(deps, adminUrl, adminKey))?.tenant;
|
|
265
|
+
}
|
|
240
266
|
/** Why the Tenant is unavailable, with the repair the Runtime names. */
|
|
241
267
|
function tenantCauseMessage(ctx, tenant) {
|
|
242
268
|
const cause = tenant.cause;
|
|
@@ -371,6 +397,8 @@ async function bringUp(ctx, options) {
|
|
|
371
397
|
: {}),
|
|
372
398
|
...(options.studioEmbedOrigins ? { studioEmbedOrigins: options.studioEmbedOrigins } : {}),
|
|
373
399
|
...(analytics ? { studioAnalyticsId: STUDIO_ANALYTICS_ID } : {}),
|
|
400
|
+
...(options.sandboxes !== undefined ? { sandboxes: options.sandboxes } : {}),
|
|
401
|
+
...(options.restateUi !== undefined ? { restateUi: options.restateUi } : {}),
|
|
374
402
|
});
|
|
375
403
|
if (analytics && options.studio && telemetry.noticeShown === undefined) {
|
|
376
404
|
deps.err(TELEMETRY_NOTICE);
|
|
@@ -384,15 +412,24 @@ async function bringUp(ctx, options) {
|
|
|
384
412
|
deps.err(`Created Tenant ${ctx.name} under ${ctx.paths.root} (Runtime port ${prepared.env.runtimePort}, Studio port ${prepared.env.studioPort}).`);
|
|
385
413
|
const runtimeUrl = `http://${STACK_CLIENT_HOST}:${prepared.env.runtimePort}`;
|
|
386
414
|
const adminUrl = `http://${STACK_CLIENT_HOST}:${prepared.env.adminPort}`;
|
|
387
|
-
const up = await deps.docker.stream(
|
|
415
|
+
const up = await deps.docker.stream(
|
|
416
|
+
// --remove-orphans: a service this start's file no longer has (the harness after a rollback
|
|
417
|
+
// to NYLORUN_HARNESS=in-process, sandboxes after disable) is removed, not left running.
|
|
418
|
+
composeArgs(ctx, "up", "--detach", "--wait", "--wait-timeout", "300", "--remove-orphans", ...coreServices(prepared.env.harness)));
|
|
388
419
|
if (up !== 0) {
|
|
389
420
|
// A Runtime whose Tenant cannot open fails readiness; its status names the cause.
|
|
390
421
|
const tenant = await fetchTenant(deps, adminUrl, prepared.adminKey);
|
|
391
422
|
if (tenant?.cause)
|
|
392
423
|
throw new CliError(tenantCauseMessage(ctx, tenant), 7);
|
|
393
|
-
throw new CliError(`docker compose up failed (exit ${up}). See "nylorun logs runtime", "nylorun logs gateway" and "nylorun status".`, 7);
|
|
424
|
+
throw new CliError(`docker compose up failed (exit ${up}). See "nylorun logs runtime", "nylorun logs gateway", "nylorun logs harness" and "nylorun status".`, 7);
|
|
394
425
|
}
|
|
395
426
|
await waitForHealth(ctx, runtimeUrl, prepared.host.hostId);
|
|
427
|
+
// Without --wait: a cluster that is down must not fail the start; sessions refuse pods.
|
|
428
|
+
if (prepared.env.sandboxes) {
|
|
429
|
+
const sandboxes = await deps.docker.stream(composeArgs(ctx, "up", "--detach", "sandboxes"));
|
|
430
|
+
if (sandboxes !== 0)
|
|
431
|
+
deps.err(`Warning: the sandboxes service did not start. See "nylorun logs sandboxes".`);
|
|
432
|
+
}
|
|
396
433
|
let studioStarted = false;
|
|
397
434
|
if (options.studio) {
|
|
398
435
|
const studio = await deps.docker.stream(composeArgs(ctx, "up", "--detach", "--wait", "--wait-timeout", "120", "studio"));
|
|
@@ -408,6 +445,9 @@ async function bringUp(ctx, options) {
|
|
|
408
445
|
studioUrl: studioOrigin(prepared.env.studioPort),
|
|
409
446
|
studioStarted,
|
|
410
447
|
adminKey: prepared.adminKey,
|
|
448
|
+
...(prepared.env.restateUi
|
|
449
|
+
? { restateUrl: `http://${STACK_CLIENT_HOST}:${prepared.env.restatePort}` }
|
|
450
|
+
: {}),
|
|
411
451
|
};
|
|
412
452
|
}
|
|
413
453
|
const errorText = (error) => (error instanceof Error ? error.message : String(error));
|
|
@@ -492,7 +532,7 @@ async function linkProject(ctx, started, tenantId) {
|
|
|
492
532
|
deps.err(`Warning: could not seed the Tenant from .env (${error instanceof Error ? error.message : String(error)}). Set the model in Studio or with "npx @nylorun/cli configure".`);
|
|
493
533
|
}
|
|
494
534
|
}
|
|
495
|
-
const START_USAGE = "nylorun start [--tenant <name>] [--no-link] [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset]";
|
|
535
|
+
const START_USAGE = "nylorun start [--tenant <name>] [--no-link] [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset] [--restate-ui]";
|
|
496
536
|
async function start(deps, args) {
|
|
497
537
|
const flags = parseStackFlags(args, {
|
|
498
538
|
booleans: [
|
|
@@ -501,6 +541,7 @@ async function start(deps, args) {
|
|
|
501
541
|
"--no-link",
|
|
502
542
|
"--allow-downgrade",
|
|
503
543
|
"--studio-embed-origin-reset",
|
|
544
|
+
"--restate-ui",
|
|
504
545
|
],
|
|
505
546
|
values: ["--tenant"],
|
|
506
547
|
lists: ["--studio-embed-origin"],
|
|
@@ -518,6 +559,7 @@ async function start(deps, args) {
|
|
|
518
559
|
const started = await bringUp(ctx, {
|
|
519
560
|
studio: !flags.booleans.has("--no-studio"),
|
|
520
561
|
allowDowngrade: flags.booleans.has("--allow-downgrade"),
|
|
562
|
+
restateUi: flags.booleans.has("--restate-ui") || deps.env.NYLORUN_RESTATE_UI?.trim() === "1",
|
|
521
563
|
...(embedOrigins.length || resetEmbed
|
|
522
564
|
? { studioEmbedOrigins: { add: embedOrigins, reset: resetEmbed } }
|
|
523
565
|
: {}),
|
|
@@ -527,6 +569,8 @@ async function start(deps, args) {
|
|
|
527
569
|
await linkProject({ ...ctx, projectDir: ctx.projectDir }, started, tenant.id);
|
|
528
570
|
deps.out(`Tenant ${ctx.name} (${tenant.id})`);
|
|
529
571
|
deps.out(`Runtime ${started.runtimeUrl}`);
|
|
572
|
+
if (started.restateUrl)
|
|
573
|
+
deps.out(`Restate ${started.restateUrl} (UI and admin, unauthenticated; for debugging)`);
|
|
530
574
|
if (started.studioStarted) {
|
|
531
575
|
deps.out(`Studio ${started.studioUrl}`);
|
|
532
576
|
if (opensBrowser(deps, flags)) {
|
|
@@ -607,8 +651,9 @@ async function stackStatus(ctx) {
|
|
|
607
651
|
state: "absent",
|
|
608
652
|
runtime: { healthy: false },
|
|
609
653
|
studio: { state: "absent" },
|
|
610
|
-
restate: {},
|
|
654
|
+
restate: { published: false },
|
|
611
655
|
gateway: { state: "absent", healthy: false },
|
|
656
|
+
harness: { mode: "remote", state: "absent", healthy: false },
|
|
612
657
|
services: [],
|
|
613
658
|
};
|
|
614
659
|
if (!existsSync(ctx.paths.compose) || !existsSync(ctx.paths.env))
|
|
@@ -626,11 +671,14 @@ async function stackStatus(ctx) {
|
|
|
626
671
|
const adminUrl = persisted.adminPort
|
|
627
672
|
? `http://${STACK_CLIENT_HOST}:${persisted.adminPort}`
|
|
628
673
|
: runtimeUrl;
|
|
629
|
-
const
|
|
630
|
-
? await
|
|
674
|
+
const answered = healthy && adminUrl
|
|
675
|
+
? await fetchAdminStatus(ctx.deps, adminUrl, await readAdminKey(ctx.paths))
|
|
631
676
|
: undefined;
|
|
677
|
+
const tenant = answered?.tenant;
|
|
632
678
|
const studio = services.find((s) => s.service === "studio");
|
|
633
679
|
const gateway = services.find((s) => s.service === "gateway");
|
|
680
|
+
const harness = services.find((s) => s.service === "harness");
|
|
681
|
+
const harnessMode = persisted.harness ?? "remote";
|
|
634
682
|
return {
|
|
635
683
|
...base,
|
|
636
684
|
state: services.some((s) => s.state === "running") ? "running" : "stopped",
|
|
@@ -649,13 +697,19 @@ async function stackStatus(ctx) {
|
|
|
649
697
|
? { embedOrigins: persisted.studioFrameAncestors }
|
|
650
698
|
: {}),
|
|
651
699
|
},
|
|
652
|
-
restate: persisted.restatePort
|
|
653
|
-
? { url: `http://${STACK_CLIENT_HOST}:${persisted.restatePort}
|
|
654
|
-
: {},
|
|
700
|
+
restate: persisted.restatePort && persisted.restateUi
|
|
701
|
+
? { url: `http://${STACK_CLIENT_HOST}:${persisted.restatePort}`, published: true }
|
|
702
|
+
: { published: false },
|
|
655
703
|
gateway: {
|
|
656
704
|
state: gateway ? [gateway.state, gateway.health].filter(Boolean).join(", ") : "absent",
|
|
657
705
|
healthy: isUp(services, "gateway"),
|
|
658
706
|
},
|
|
707
|
+
harness: {
|
|
708
|
+
mode: answered?.harness?.mode ?? harnessMode,
|
|
709
|
+
state: harness ? [harness.state, harness.health].filter(Boolean).join(", ") : "absent",
|
|
710
|
+
healthy: isUp(services, "harness"),
|
|
711
|
+
...(answered?.harness ? { connected: answered.harness.connected } : {}),
|
|
712
|
+
},
|
|
659
713
|
services,
|
|
660
714
|
};
|
|
661
715
|
}
|
|
@@ -700,8 +754,15 @@ async function status(deps, args) {
|
|
|
700
754
|
if (result.studio.embedOrigins?.length)
|
|
701
755
|
out(`Embeds ${result.studio.embedOrigins.join(" ")} (may show Studio in a frame)`);
|
|
702
756
|
out(`Gateway ${result.gateway.state} (the Model Gate; model calls fail while it is down)`);
|
|
703
|
-
|
|
704
|
-
|
|
757
|
+
out(result.harness.mode === "remote"
|
|
758
|
+
? `Harness ${result.harness.state}, remote${result.harness.connected === undefined ? "" : `, ${result.harness.connected} connected`} (agent turns, MCP servers and workspaces; "nylorun logs harness")`
|
|
759
|
+
: `Harness in-process (NYLORUN_HARNESS=in-process in .env: turns run in the runtime container)`);
|
|
760
|
+
out(result.restate.url
|
|
761
|
+
? `Restate UI ${result.restate.url} (unauthenticated; published by --restate-ui)`
|
|
762
|
+
: `Restate UI not published (nylorun start --restate-ui)`);
|
|
763
|
+
const sandboxes = result.services.find((s) => s.service === "sandboxes");
|
|
764
|
+
if (sandboxes)
|
|
765
|
+
out(`Sandboxes ${[sandboxes.state, sandboxes.health].filter(Boolean).join(", ")} (see "nylorun sandbox status")`);
|
|
705
766
|
out(`Services ${STACK_SERVICES.map((name) => {
|
|
706
767
|
const service = result.services.find((s) => s.service === name);
|
|
707
768
|
const state = service
|
|
@@ -718,7 +779,7 @@ async function logs(deps, args) {
|
|
|
718
779
|
if (flags.rest.length > 1)
|
|
719
780
|
throw usageError(`Usage: ${usage}`);
|
|
720
781
|
const service = flags.rest[0];
|
|
721
|
-
if (service !== undefined && !STACK_SERVICES.includes(service))
|
|
782
|
+
if (service !== undefined && ![...STACK_SERVICES, "sandboxes"].includes(service))
|
|
722
783
|
throw usageError(`Unknown service ${service}. Usage: ${usage}`);
|
|
723
784
|
const tail = flags.values.get("--tail");
|
|
724
785
|
if (tail !== undefined && !/^\d+$/.test(tail))
|
|
@@ -865,6 +926,9 @@ async function runningStack(ctx, options) {
|
|
|
865
926
|
const services = await composePs(ctx);
|
|
866
927
|
if (!isUp(services, "runtime") || !isUp(services, "gateway"))
|
|
867
928
|
return undefined;
|
|
929
|
+
// An older Tenant without the harness container is brought up again (remote is the default).
|
|
930
|
+
if ((persisted.harness ?? "remote") === "remote" && !isUp(services, "harness"))
|
|
931
|
+
return undefined;
|
|
868
932
|
const studioUp = isUp(services, "studio");
|
|
869
933
|
if (options.studio && !studioUp)
|
|
870
934
|
return undefined;
|
|
@@ -907,6 +971,26 @@ async function ensureSelected(ctx, options) {
|
|
|
907
971
|
started: !runtimeWasUp,
|
|
908
972
|
};
|
|
909
973
|
}
|
|
974
|
+
/**
|
|
975
|
+
* The selected Tenant's API while it runs: commands that read or change the Tenant's data never
|
|
976
|
+
* start it. Exit 3 when it is not running, 7 when it is not open.
|
|
977
|
+
*/
|
|
978
|
+
export async function runningTenantApi(deps, options = {}) {
|
|
979
|
+
const ctx = await selectStack(deps, options);
|
|
980
|
+
requireStackFiles(ctx);
|
|
981
|
+
await dockerPreflight(deps.docker);
|
|
982
|
+
const running = await runningStack(ctx, { studio: false });
|
|
983
|
+
if (!running)
|
|
984
|
+
throw new CliError(`Tenant ${ctx.name} is not running. Run "nylorun start" first.`, 3);
|
|
985
|
+
const tenant = await fetchTenant(deps, running.adminUrl, running.adminKey);
|
|
986
|
+
if (!tenant?.id || tenant.state !== "open")
|
|
987
|
+
throw new CliError(`Tenant ${ctx.name} is not open. See "nylorun status".`, 7);
|
|
988
|
+
return {
|
|
989
|
+
name: ctx.name,
|
|
990
|
+
runtimeUrl: running.runtimeUrl,
|
|
991
|
+
applicationKey: deriveTenantKey(running.adminKey, tenant.id, PROJECT_PRINCIPAL_ID),
|
|
992
|
+
};
|
|
993
|
+
}
|
|
910
994
|
/**
|
|
911
995
|
* Start the selected Tenant unless it is already running (`nylorun studio`): the
|
|
912
996
|
* `start` code path without its output, its Tenant wait or the Project link. Compose
|
|
@@ -915,6 +999,40 @@ async function ensureSelected(ctx, options) {
|
|
|
915
999
|
export async function ensureStack(deps, options) {
|
|
916
1000
|
return await ensureSelected(await selectStack(deps, options.name === undefined ? {} : { name: options.name }), options);
|
|
917
1001
|
}
|
|
1002
|
+
/** The Tenant `nylorun sandbox` acts on; it must have been started once. */
|
|
1003
|
+
export async function selectTenant(deps, options = {}) {
|
|
1004
|
+
await tidyMachine(deps);
|
|
1005
|
+
const ctx = await selectStack(deps, options);
|
|
1006
|
+
requireStackFiles(ctx);
|
|
1007
|
+
return {
|
|
1008
|
+
name: ctx.name,
|
|
1009
|
+
paths: ctx.paths,
|
|
1010
|
+
project: ctx.project,
|
|
1011
|
+
compose: (...args) => composeArgs(ctx, ...args),
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Rewrite the Tenant's Compose files with `sandboxes` set (an object) or removed (null),
|
|
1016
|
+
* and bring it up again: Compose recreates the containers whose settings changed.
|
|
1017
|
+
*/
|
|
1018
|
+
export async function restartTenant(deps, options) {
|
|
1019
|
+
const ctx = await selectStack(deps, options.name === undefined ? {} : { name: options.name });
|
|
1020
|
+
await dockerPreflight(deps.docker);
|
|
1021
|
+
await bringUp(ctx, { studio: false, sandboxes: options.sandboxes });
|
|
1022
|
+
}
|
|
1023
|
+
/** Ports every Tenant on this machine keeps for sandboxes, but the one under `except`. */
|
|
1024
|
+
export async function reservedSandboxPorts(deps, except) {
|
|
1025
|
+
const reserved = await tenantPorts(nylorunRoot(deps), except);
|
|
1026
|
+
for (const tenant of await listTenants(nylorunRoot(deps))) {
|
|
1027
|
+
if (resolve(tenant.root) === except)
|
|
1028
|
+
continue;
|
|
1029
|
+
const sandboxes = (await readStackEnv(stackPaths(tenant.root)))?.sandboxes;
|
|
1030
|
+
for (const port of [sandboxes?.harnessPort, sandboxes?.gatesPort, sandboxes?.egressPort])
|
|
1031
|
+
if (port !== undefined)
|
|
1032
|
+
reserved.add(port);
|
|
1033
|
+
}
|
|
1034
|
+
return reserved;
|
|
1035
|
+
}
|
|
918
1036
|
/** Add Studio's `next` path (e.g. `/tenants/<id>`) to a login URL. */
|
|
919
1037
|
export function withNext(loginUrl, next) {
|
|
920
1038
|
if (!next)
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* network and ports. Container, network and volume names are global on the Docker engine, so
|
|
6
6
|
* every one is `<project>-<role>` (`nylorun-shop-studio`, network `nylorun-shop`, volume
|
|
7
7
|
* `nylorun-shop-postgres`), labelled `dev.nylorun.tenant: <name>`; they are written here, from
|
|
8
|
-
* the project and the Tenant's name. Everything else that varies (ports, the Postgres password
|
|
9
|
-
* UID/GID, the Host root, the images) comes from `docker/.env`.
|
|
8
|
+
* the project and the Tenant's name. Everything else that varies (ports, the Postgres password and the other
|
|
9
|
+
* secrets, UID/GID, the Host root, the images) comes from `docker/.env`.
|
|
10
10
|
*
|
|
11
11
|
* The Tenant's state is its Postgres database, executed through Restate, with its
|
|
12
12
|
* history in s2-lite; the Runtime's /ready checks all three. Postgres initialises the
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
* which alone reads model credentials, and the Tool Gate (F4.1), which alone
|
|
26
26
|
* holds remote MCP connections and their credentials and POSTs every Action
|
|
27
27
|
* delivery. Every model call, remote MCP call and delivery of the loop crosses it
|
|
28
|
-
* (NYLORUN_GATES_URL, with NYLORUN_GATES_TOKEN from `.env
|
|
29
|
-
*
|
|
28
|
+
* (NYLORUN_GATES_URL, with NYLORUN_GATES_TOKEN from `.env`, or a run's token from the
|
|
29
|
+
* harness); MCP stdio servers run in the harness container. The gateway also runs keys (F4.2): the only
|
|
30
30
|
* holder of the vault key, it runs every vault write that touches a secret and signs
|
|
31
31
|
* every token (NYLORUN_KEYS_URL). The gateway mounts only the Host's Tenant directory
|
|
32
32
|
* (`tenant/`) and its keys directory (`keys/`, the vault key), read-only: never
|
|
@@ -35,5 +35,42 @@
|
|
|
35
35
|
* Restate's private key and `.env`. The runtime does not wait for the gateway: while
|
|
36
36
|
* it is down, model and MCP calls fail, deliveries are retried, vault writes and
|
|
37
37
|
* token minting answer 503, and the session takes the next message.
|
|
38
|
+
*
|
|
39
|
+
* The Object store (blueprint D35) is RustFS, single node and single drive, pinned by digest,
|
|
40
|
+
* on the `rustfs` volume; it is not published. Its credential is the access key `nylorun` and
|
|
41
|
+
* the secret key NYLORUN_OBJECT_STORE_SECRET_KEY from `.env`, and only the runtime and the
|
|
42
|
+
* gateway receive it (NYLORUN_OBJECT_STORE_*). The runtime creates the bucket at boot and
|
|
43
|
+
* reaches the store through its `s3` BlobStore.
|
|
44
|
+
*
|
|
45
|
+
* With `sandboxes` (after `nylorun sandbox enable`, F7.2) the `sandboxes` service runs the
|
|
46
|
+
* agent-sandbox driver: the only container holding the cluster credentials
|
|
47
|
+
* (`<Host root>/sandboxes`, read-only), not published, reached by the runtime alone with
|
|
48
|
+
* NYLORUN_SANDBOXES_TOKEN. The runtime gets an empty read-only mount over that directory.
|
|
49
|
+
* Sandbox pods reach the stack only on the Docker host's address (NYLORUN_SANDBOX_HOST_ADDRESS),
|
|
50
|
+
* where three ports are published on NYLORUN_SANDBOX_BIND: the runtime's Harness API listener
|
|
51
|
+
* (NYLORUN_SANDBOX_HARNESS_PORT → 4200: pods join and connect with host tokens), the
|
|
52
|
+
* gateway's gates (NYLORUN_SANDBOX_GATES_PORT → 4100: model and MCP calls with run tokens) and
|
|
53
|
+
* egress-gate (NYLORUN_SANDBOX_EGRESS_PORT → 4200 in the gateway, `--service gates,keys,egress`:
|
|
54
|
+
* CONNECT with the egress token, to the hosts the sandbox spec allows). The two HTTP listeners
|
|
55
|
+
* accept that address as a Host.
|
|
56
|
+
*
|
|
57
|
+
* The harness (F6.2, `harness: "remote"`, the default) runs every agent turn, MCP stdio server
|
|
58
|
+
* and workspace in its own container: the runtime image with `--service harness`, connected to
|
|
59
|
+
* core's Harness API (`ws://runtime:4200/nylorun/harness/v1`) with NYLORUN_HARNESS_TOKEN, its
|
|
60
|
+
* only credential, and calling models and remote MCP servers through the gateway with each
|
|
61
|
+
* run's token. It mounts only the Tenant's `sandboxes/`, `plugin-data/`, `home/` and `tmp/`
|
|
62
|
+
* under `/harness`, and the Host root's `plugins/` read-only at its own path (plugin roots name
|
|
63
|
+
* paths on this machine; the runtime mounts it there too). NYLORUN_HARNESS=in-process in `.env`
|
|
64
|
+
* rolls back to turns in the runtime container, without the harness service.
|
|
65
|
+
*
|
|
66
|
+
* Three networks: `store` (internal: no egress) joins Postgres, s2-lite, Restate and RustFS to
|
|
67
|
+
* the runtime and the gateway; `harness` joins the harness to the runtime and the gateway only;
|
|
68
|
+
* `default` carries egress and the published ports (runtime, gateway, Studio, sandboxes).
|
|
69
|
+
* Restate's admin port (its UI, unauthenticated) is published, and Restate joins `default`,
|
|
70
|
+
* only with `restateUi` (`nylorun start --restate-ui`).
|
|
38
71
|
*/
|
|
39
|
-
export declare function renderComposeFile(project: string, name: string
|
|
72
|
+
export declare function renderComposeFile(project: string, name: string, options?: {
|
|
73
|
+
sandboxes?: true;
|
|
74
|
+
harness?: "remote" | "in-process";
|
|
75
|
+
restateUi?: true;
|
|
76
|
+
}): string;
|