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
package/dist/stack/commands.js
CHANGED
|
@@ -13,17 +13,30 @@ import { readAdminKey, readHostConfig, STACK_CLIENT_HOST } from "./host-files.js
|
|
|
13
13
|
import { runtimeImageOverridden, stackImages } from "./images.js";
|
|
14
14
|
import { stackPaths } from "./paths.js";
|
|
15
15
|
import { prepareStack, readStackEnv } from "./prepare.js";
|
|
16
|
-
import {
|
|
17
|
-
import { assertTenantName, chooseTenantName, DEFAULT_TENANT, defaultNylorunRoot, listTenants, moveStackRoots, readTenantRecord, sanitizeTenantName, tenantRoot, tenantsDir, writeTenantRecord, } from "./stacks.js";
|
|
16
|
+
import { assertTenantName, chooseTenantName, DEFAULT_TENANT, defaultNylorunRoot, listTenants, moveStackRoots, readTenantRecord, removeOldProxy, sanitizeTenantName, tenantRoot, tenantsDir, writeTenantRecord, } from "./stacks.js";
|
|
18
17
|
import { mintStudioLogin, studioOrigin } from "./studio-login.js";
|
|
19
|
-
export const STACK_SERVICES = [
|
|
20
|
-
|
|
21
|
-
|
|
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]
|
|
22
34
|
start the project's Tenant, creating it and the Project link (.nylorun/link.json,
|
|
23
35
|
credentials.json) on the first run; print the Runtime and Studio URLs and open Studio
|
|
24
36
|
signed in (in a terminal). --tenant attaches to (or creates) a named Tenant; --no-link
|
|
25
|
-
starts the default Tenant (or --tenant's) without linking the current directory
|
|
26
|
-
|
|
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
|
|
39
|
+
down|stop [--tenant <name> | --all] stop the Tenant's containers (--all: every Tenant's); keep volumes
|
|
27
40
|
status [--tenant <name>] [--json] the Tenant, its id, services, endpoints and Runtime health
|
|
28
41
|
logs [service] [--tenant <name>] [-f] [--tail <n>]
|
|
29
42
|
container logs (${STACK_SERVICES.join(", ")})
|
|
@@ -43,26 +56,11 @@ export function stackProject(env, name) {
|
|
|
43
56
|
function nylorunRoot(deps) {
|
|
44
57
|
return deps.nylorunRoot ?? defaultNylorunRoot();
|
|
45
58
|
}
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
function proxied(ctx) {
|
|
52
|
-
const { env } = ctx.deps;
|
|
53
|
-
return !proxyDisabled(env) && !env.NYLORUN_HOME?.trim() && !env.NYLORUN_COMPOSE_PROJECT?.trim();
|
|
54
|
-
}
|
|
55
|
-
/** Studio's URL for browsers: through the proxy when it serves the Tenant, else its own port. */
|
|
56
|
-
async function studioUrl(ctx, studioPort) {
|
|
57
|
-
const settings = proxied(ctx) ? await readProxySettings(nylorunRoot(ctx.deps)) : undefined;
|
|
58
|
-
return settings ? proxyOrigin(ctx.name, settings.port) : studioOrigin(studioPort);
|
|
59
|
-
}
|
|
60
|
-
/** The proxy's routes: every Tenant on this machine. */
|
|
61
|
-
async function proxyRoutes(deps) {
|
|
62
|
-
return (await listTenants(nylorunRoot(deps))).map((tenant) => ({
|
|
63
|
-
name: tenant.name,
|
|
64
|
-
project: stackProject(deps.env, tenant.name),
|
|
65
|
-
}));
|
|
59
|
+
/** Before every Tenant command: move 0.4's Host roots, remove 0.6's Studio proxy. */
|
|
60
|
+
async function tidyMachine(deps) {
|
|
61
|
+
const base = nylorunRoot(deps);
|
|
62
|
+
await moveStackRoots(base, deps.err);
|
|
63
|
+
await removeOldProxy(deps.docker, base, deps.err);
|
|
66
64
|
}
|
|
67
65
|
/**
|
|
68
66
|
* Compose's names for a 0.5 Tenant's volumes (`<project>_postgres`, …) that exist on this
|
|
@@ -78,13 +76,9 @@ async function oldVolumes(docker, project) {
|
|
|
78
76
|
current: names.has(`${project}-postgres`),
|
|
79
77
|
};
|
|
80
78
|
}
|
|
81
|
-
/**
|
|
82
|
-
* Before `docker compose down` (`reset`, `delete`): detach the proxy from the Tenant's network
|
|
83
|
-
* so Compose can remove it. After it: drop a 0.5 Tenant's volumes and network.
|
|
84
|
-
*/
|
|
79
|
+
/** `docker compose down` (`reset`, `delete`), then drop a 0.5 Tenant's volumes and network. */
|
|
85
80
|
async function composeDown(ctx, args) {
|
|
86
81
|
const { docker } = ctx.deps;
|
|
87
|
-
await disconnectProxy(docker, ctx.project);
|
|
88
82
|
const code = await docker.stream(args);
|
|
89
83
|
if (code !== 0)
|
|
90
84
|
throw new CliError(`docker compose down failed (exit ${code}).`, 1);
|
|
@@ -227,8 +221,8 @@ async function waitForHealth(ctx, runtimeUrl, hostId) {
|
|
|
227
221
|
await sleep(ctx.deps.pollMs ?? 500);
|
|
228
222
|
}
|
|
229
223
|
}
|
|
230
|
-
/** `/v1/admin/status`
|
|
231
|
-
async function
|
|
224
|
+
/** `/v1/admin/status` on the operator listener; undefined when it does not answer. */
|
|
225
|
+
async function fetchAdminStatus(deps, adminUrl, adminKey) {
|
|
232
226
|
if (!adminKey)
|
|
233
227
|
return undefined;
|
|
234
228
|
try {
|
|
@@ -243,20 +237,32 @@ async function fetchTenant(deps, adminUrl, adminKey) {
|
|
|
243
237
|
});
|
|
244
238
|
if (!response.ok)
|
|
245
239
|
return undefined;
|
|
246
|
-
const
|
|
240
|
+
const body = (await response.json());
|
|
241
|
+
const tenant = body.tenant;
|
|
247
242
|
if (!tenant || (tenant.state !== "open" && tenant.state !== "unavailable"))
|
|
248
243
|
return undefined;
|
|
244
|
+
const harness = body.aggregate?.harness;
|
|
249
245
|
return {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
+
: {}),
|
|
254
256
|
};
|
|
255
257
|
}
|
|
256
258
|
catch {
|
|
257
259
|
return undefined;
|
|
258
260
|
}
|
|
259
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
|
+
}
|
|
260
266
|
/** Why the Tenant is unavailable, with the repair the Runtime names. */
|
|
261
267
|
function tenantCauseMessage(ctx, tenant) {
|
|
262
268
|
const cause = tenant.cause;
|
|
@@ -356,14 +362,9 @@ async function tenantPorts(base, except) {
|
|
|
356
362
|
}
|
|
357
363
|
return reserved;
|
|
358
364
|
}
|
|
359
|
-
/** Ports other Tenants
|
|
365
|
+
/** Ports other Tenants keep, so a new Tenant avoids them. */
|
|
360
366
|
async function reservedPorts(ctx) {
|
|
361
|
-
|
|
362
|
-
const reserved = await tenantPorts(base, ctx.paths.root);
|
|
363
|
-
const proxy = await readProxySettings(base);
|
|
364
|
-
if (proxy)
|
|
365
|
-
reserved.add(proxy.port);
|
|
366
|
-
return reserved;
|
|
367
|
+
return await tenantPorts(nylorunRoot(ctx.deps), ctx.paths.root);
|
|
367
368
|
}
|
|
368
369
|
async function bringUp(ctx, options) {
|
|
369
370
|
const { deps } = ctx;
|
|
@@ -377,10 +378,6 @@ async function bringUp(ctx, options) {
|
|
|
377
378
|
const base = nylorunRoot(deps);
|
|
378
379
|
const telemetry = await readTelemetry(base);
|
|
379
380
|
const analytics = telemetryDecision(deps.env, telemetry).enabled;
|
|
380
|
-
// The proxy's port avoids every Tenant's; a new Tenant's ports then avoid the proxy's.
|
|
381
|
-
const proxy = proxied(ctx)
|
|
382
|
-
? await ensureProxySettings(base, deps.ports, await tenantPorts(base))
|
|
383
|
-
: undefined;
|
|
384
381
|
const prepared = await prepareStack({
|
|
385
382
|
paths: ctx.paths,
|
|
386
383
|
name: ctx.name,
|
|
@@ -400,7 +397,8 @@ async function bringUp(ctx, options) {
|
|
|
400
397
|
: {}),
|
|
401
398
|
...(options.studioEmbedOrigins ? { studioEmbedOrigins: options.studioEmbedOrigins } : {}),
|
|
402
399
|
...(analytics ? { studioAnalyticsId: STUDIO_ANALYTICS_ID } : {}),
|
|
403
|
-
...(
|
|
400
|
+
...(options.sandboxes !== undefined ? { sandboxes: options.sandboxes } : {}),
|
|
401
|
+
...(options.restateUi !== undefined ? { restateUi: options.restateUi } : {}),
|
|
404
402
|
});
|
|
405
403
|
if (analytics && options.studio && telemetry.noticeShown === undefined) {
|
|
406
404
|
deps.err(TELEMETRY_NOTICE);
|
|
@@ -414,15 +412,24 @@ async function bringUp(ctx, options) {
|
|
|
414
412
|
deps.err(`Created Tenant ${ctx.name} under ${ctx.paths.root} (Runtime port ${prepared.env.runtimePort}, Studio port ${prepared.env.studioPort}).`);
|
|
415
413
|
const runtimeUrl = `http://${STACK_CLIENT_HOST}:${prepared.env.runtimePort}`;
|
|
416
414
|
const adminUrl = `http://${STACK_CLIENT_HOST}:${prepared.env.adminPort}`;
|
|
417
|
-
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)));
|
|
418
419
|
if (up !== 0) {
|
|
419
420
|
// A Runtime whose Tenant cannot open fails readiness; its status names the cause.
|
|
420
421
|
const tenant = await fetchTenant(deps, adminUrl, prepared.adminKey);
|
|
421
422
|
if (tenant?.cause)
|
|
422
423
|
throw new CliError(tenantCauseMessage(ctx, tenant), 7);
|
|
423
|
-
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);
|
|
424
425
|
}
|
|
425
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
|
+
}
|
|
426
433
|
let studioStarted = false;
|
|
427
434
|
if (options.studio) {
|
|
428
435
|
const studio = await deps.docker.stream(composeArgs(ctx, "up", "--detach", "--wait", "--wait-timeout", "120", "studio"));
|
|
@@ -430,45 +437,24 @@ async function bringUp(ctx, options) {
|
|
|
430
437
|
if (!studioStarted)
|
|
431
438
|
deps.err(`Warning: Studio did not start (image ${prepared.env.studioImage}). The Runtime is up; see "nylorun logs studio".`);
|
|
432
439
|
}
|
|
433
|
-
let studioUrl = studioOrigin(prepared.env.studioPort);
|
|
434
|
-
if (studioStarted && proxy) {
|
|
435
|
-
const up = await startProxy({
|
|
436
|
-
docker: deps.docker,
|
|
437
|
-
base,
|
|
438
|
-
settings: proxy,
|
|
439
|
-
routes: await proxyRoutes(deps),
|
|
440
|
-
err: deps.err,
|
|
441
|
-
...(deps.pollMs !== undefined ? { pollMs: deps.pollMs } : {}),
|
|
442
|
-
});
|
|
443
|
-
if (up)
|
|
444
|
-
studioUrl = proxyOrigin(ctx.name, proxy.port);
|
|
445
|
-
}
|
|
446
440
|
return {
|
|
447
441
|
runtimeUrl,
|
|
448
442
|
adminUrl,
|
|
449
443
|
hostId: prepared.host.hostId,
|
|
450
444
|
studioPort: prepared.env.studioPort,
|
|
451
|
-
studioUrl,
|
|
445
|
+
studioUrl: studioOrigin(prepared.env.studioPort),
|
|
452
446
|
studioStarted,
|
|
453
447
|
adminKey: prepared.adminKey,
|
|
448
|
+
...(prepared.env.restateUi
|
|
449
|
+
? { restateUrl: `http://${STACK_CLIENT_HOST}:${prepared.env.restatePort}` }
|
|
450
|
+
: {}),
|
|
454
451
|
};
|
|
455
452
|
}
|
|
456
453
|
const errorText = (error) => (error instanceof Error ? error.message : String(error));
|
|
457
|
-
/**
|
|
458
|
-
|
|
459
|
-
* does not answer falls back, saying so), else on Studio's own port, retried while it starts.
|
|
460
|
-
*/
|
|
461
|
-
async function tryStudioLogin(ctx, studio, adminKey) {
|
|
454
|
+
/** A login URL on Studio's own port, retried while it starts. */
|
|
455
|
+
async function tryStudioLogin(ctx, studioPort, adminKey) {
|
|
462
456
|
const { deps } = ctx;
|
|
463
|
-
const origin = studioOrigin(
|
|
464
|
-
if (studio.url !== origin) {
|
|
465
|
-
try {
|
|
466
|
-
return await mintStudioLogin({ fetch: deps.fetch, origin: studio.url, adminKey });
|
|
467
|
-
}
|
|
468
|
-
catch (error) {
|
|
469
|
-
deps.err(`Studio did not answer through the proxy at ${studio.url} (${errorText(error)}); signing in at ${origin} instead.`);
|
|
470
|
-
}
|
|
471
|
-
}
|
|
457
|
+
const origin = studioOrigin(studioPort);
|
|
472
458
|
const deadline = Date.now() + (deps.loginTimeoutMs ?? 15_000);
|
|
473
459
|
let lastError = "";
|
|
474
460
|
for (;;) {
|
|
@@ -546,7 +532,7 @@ async function linkProject(ctx, started, tenantId) {
|
|
|
546
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".`);
|
|
547
533
|
}
|
|
548
534
|
}
|
|
549
|
-
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]";
|
|
550
536
|
async function start(deps, args) {
|
|
551
537
|
const flags = parseStackFlags(args, {
|
|
552
538
|
booleans: [
|
|
@@ -555,6 +541,7 @@ async function start(deps, args) {
|
|
|
555
541
|
"--no-link",
|
|
556
542
|
"--allow-downgrade",
|
|
557
543
|
"--studio-embed-origin-reset",
|
|
544
|
+
"--restate-ui",
|
|
558
545
|
],
|
|
559
546
|
values: ["--tenant"],
|
|
560
547
|
lists: ["--studio-embed-origin"],
|
|
@@ -572,6 +559,7 @@ async function start(deps, args) {
|
|
|
572
559
|
const started = await bringUp(ctx, {
|
|
573
560
|
studio: !flags.booleans.has("--no-studio"),
|
|
574
561
|
allowDowngrade: flags.booleans.has("--allow-downgrade"),
|
|
562
|
+
restateUi: flags.booleans.has("--restate-ui") || deps.env.NYLORUN_RESTATE_UI?.trim() === "1",
|
|
575
563
|
...(embedOrigins.length || resetEmbed
|
|
576
564
|
? { studioEmbedOrigins: { add: embedOrigins, reset: resetEmbed } }
|
|
577
565
|
: {}),
|
|
@@ -581,10 +569,12 @@ async function start(deps, args) {
|
|
|
581
569
|
await linkProject({ ...ctx, projectDir: ctx.projectDir }, started, tenant.id);
|
|
582
570
|
deps.out(`Tenant ${ctx.name} (${tenant.id})`);
|
|
583
571
|
deps.out(`Runtime ${started.runtimeUrl}`);
|
|
572
|
+
if (started.restateUrl)
|
|
573
|
+
deps.out(`Restate ${started.restateUrl} (UI and admin, unauthenticated; for debugging)`);
|
|
584
574
|
if (started.studioStarted) {
|
|
585
575
|
deps.out(`Studio ${started.studioUrl}`);
|
|
586
576
|
if (opensBrowser(deps, flags)) {
|
|
587
|
-
const login = await tryStudioLogin(ctx,
|
|
577
|
+
const login = await tryStudioLogin(ctx, started.studioPort, started.adminKey);
|
|
588
578
|
if (login)
|
|
589
579
|
await openLogin(ctx, withNext(login, tenantStudioPath(tenant.id)));
|
|
590
580
|
}
|
|
@@ -631,7 +621,7 @@ async function stop(deps, args) {
|
|
|
631
621
|
deps.out(`Stopped Tenant ${ctx.name}; its data is kept.`);
|
|
632
622
|
return 0;
|
|
633
623
|
}
|
|
634
|
-
/** `stop --all`: every running Tenant on this machine
|
|
624
|
+
/** `stop --all`: every running Tenant on this machine; volumes are kept. */
|
|
635
625
|
async function stopAll(deps) {
|
|
636
626
|
await dockerPreflight(deps.docker);
|
|
637
627
|
const projects = (await composeProjects(deps.docker)) ?? new Map();
|
|
@@ -648,17 +638,11 @@ async function stopAll(deps) {
|
|
|
648
638
|
throw new CliError(`docker compose stop failed for Tenant ${tenant.name} (exit ${code}).`, 1);
|
|
649
639
|
stopped.push(tenant.name);
|
|
650
640
|
}
|
|
651
|
-
const proxy = projects.get(PROXY_PROJECT) === "running" && (await stopProxy(deps.docker));
|
|
652
641
|
deps.out(stopped.length
|
|
653
|
-
? `Stopped Tenants ${stopped.join(", ")}
|
|
654
|
-
:
|
|
642
|
+
? `Stopped Tenants ${stopped.join(", ")}; their data is kept.`
|
|
643
|
+
: "No Tenant was running.");
|
|
655
644
|
return 0;
|
|
656
645
|
}
|
|
657
|
-
async function studioUrls(ctx, port) {
|
|
658
|
-
const url = await studioUrl(ctx, port);
|
|
659
|
-
const direct = studioOrigin(port);
|
|
660
|
-
return url === direct ? { url } : { url: direct, proxyUrl: url };
|
|
661
|
-
}
|
|
662
646
|
async function stackStatus(ctx) {
|
|
663
647
|
const base = {
|
|
664
648
|
name: ctx.name,
|
|
@@ -667,8 +651,9 @@ async function stackStatus(ctx) {
|
|
|
667
651
|
state: "absent",
|
|
668
652
|
runtime: { healthy: false },
|
|
669
653
|
studio: { state: "absent" },
|
|
670
|
-
restate: {},
|
|
654
|
+
restate: { published: false },
|
|
671
655
|
gateway: { state: "absent", healthy: false },
|
|
656
|
+
harness: { mode: "remote", state: "absent", healthy: false },
|
|
672
657
|
services: [],
|
|
673
658
|
};
|
|
674
659
|
if (!existsSync(ctx.paths.compose) || !existsSync(ctx.paths.env))
|
|
@@ -686,11 +671,14 @@ async function stackStatus(ctx) {
|
|
|
686
671
|
const adminUrl = persisted.adminPort
|
|
687
672
|
? `http://${STACK_CLIENT_HOST}:${persisted.adminPort}`
|
|
688
673
|
: runtimeUrl;
|
|
689
|
-
const
|
|
690
|
-
? await
|
|
674
|
+
const answered = healthy && adminUrl
|
|
675
|
+
? await fetchAdminStatus(ctx.deps, adminUrl, await readAdminKey(ctx.paths))
|
|
691
676
|
: undefined;
|
|
677
|
+
const tenant = answered?.tenant;
|
|
692
678
|
const studio = services.find((s) => s.service === "studio");
|
|
693
679
|
const gateway = services.find((s) => s.service === "gateway");
|
|
680
|
+
const harness = services.find((s) => s.service === "harness");
|
|
681
|
+
const harnessMode = persisted.harness ?? "remote";
|
|
694
682
|
return {
|
|
695
683
|
...base,
|
|
696
684
|
state: services.some((s) => s.state === "running") ? "running" : "stopped",
|
|
@@ -703,29 +691,31 @@ async function stackStatus(ctx) {
|
|
|
703
691
|
},
|
|
704
692
|
...(tenant ? { tenant } : {}),
|
|
705
693
|
studio: {
|
|
706
|
-
...(persisted.studioPort ?
|
|
694
|
+
...(persisted.studioPort ? { url: studioOrigin(persisted.studioPort) } : {}),
|
|
707
695
|
state: studio ? [studio.state, studio.health].filter(Boolean).join(", ") : "absent",
|
|
708
696
|
...(persisted.studioFrameAncestors
|
|
709
697
|
? { embedOrigins: persisted.studioFrameAncestors }
|
|
710
698
|
: {}),
|
|
711
699
|
},
|
|
712
|
-
restate: persisted.restatePort
|
|
713
|
-
? { url: `http://${STACK_CLIENT_HOST}:${persisted.restatePort}
|
|
714
|
-
: {},
|
|
700
|
+
restate: persisted.restatePort && persisted.restateUi
|
|
701
|
+
? { url: `http://${STACK_CLIENT_HOST}:${persisted.restatePort}`, published: true }
|
|
702
|
+
: { published: false },
|
|
715
703
|
gateway: {
|
|
716
704
|
state: gateway ? [gateway.state, gateway.health].filter(Boolean).join(", ") : "absent",
|
|
717
705
|
healthy: isUp(services, "gateway"),
|
|
718
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
|
+
},
|
|
719
713
|
services,
|
|
720
714
|
};
|
|
721
715
|
}
|
|
722
|
-
/** The Studio proxy, as data (`nylorun doctor`). */
|
|
723
|
-
export async function readProxyReport(deps) {
|
|
724
|
-
return await readProxyStatus(deps.docker, nylorunRoot(deps), deps.env);
|
|
725
|
-
}
|
|
726
716
|
/** What `nylorun status` reports, as data (`nylorun doctor`). */
|
|
727
717
|
export async function readStackStatus(deps, options = {}) {
|
|
728
|
-
await
|
|
718
|
+
await tidyMachine(deps);
|
|
729
719
|
return await stackStatus(await selectStack(deps, options));
|
|
730
720
|
}
|
|
731
721
|
function describeTenant(tenant) {
|
|
@@ -760,13 +750,19 @@ async function status(deps, args) {
|
|
|
760
750
|
out(`Runtime ${result.runtime.url ?? "?"} ${runtimeDetail}`);
|
|
761
751
|
if (result.runtime.adminUrl)
|
|
762
752
|
out(`Admin API ${result.runtime.adminUrl} (operators only, never proxied)`);
|
|
763
|
-
|
|
764
|
-
out(`Studio ${result.studio.proxyUrl ?? result.studio.url ?? "?"} ${result.studio.state} (${also}log in with "nylorun studio")`);
|
|
753
|
+
out(`Studio ${result.studio.url ?? "?"} ${result.studio.state} (log in with "nylorun studio")`);
|
|
765
754
|
if (result.studio.embedOrigins?.length)
|
|
766
755
|
out(`Embeds ${result.studio.embedOrigins.join(" ")} (may show Studio in a frame)`);
|
|
767
756
|
out(`Gateway ${result.gateway.state} (the Model Gate; model calls fail while it is down)`);
|
|
768
|
-
|
|
769
|
-
|
|
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")`);
|
|
770
766
|
out(`Services ${STACK_SERVICES.map((name) => {
|
|
771
767
|
const service = result.services.find((s) => s.service === name);
|
|
772
768
|
const state = service
|
|
@@ -783,7 +779,7 @@ async function logs(deps, args) {
|
|
|
783
779
|
if (flags.rest.length > 1)
|
|
784
780
|
throw usageError(`Usage: ${usage}`);
|
|
785
781
|
const service = flags.rest[0];
|
|
786
|
-
if (service !== undefined && !STACK_SERVICES.includes(service))
|
|
782
|
+
if (service !== undefined && ![...STACK_SERVICES, "sandboxes"].includes(service))
|
|
787
783
|
throw usageError(`Unknown service ${service}. Usage: ${usage}`);
|
|
788
784
|
const tail = flags.values.get("--tail");
|
|
789
785
|
if (tail !== undefined && !/^\d+$/.test(tail))
|
|
@@ -848,7 +844,6 @@ async function ls(deps, args) {
|
|
|
848
844
|
const memory = [...(projects?.values() ?? [])].includes("running")
|
|
849
845
|
? await tenantMemory(deps.docker)
|
|
850
846
|
: undefined;
|
|
851
|
-
const proxy = proxyDisabled(deps.env) ? undefined : await readProxySettings(base);
|
|
852
847
|
const tenants = [];
|
|
853
848
|
for (const entry of await listTenants(base)) {
|
|
854
849
|
const persisted = (await readStackEnv(stackPaths(entry.root))) ?? {};
|
|
@@ -862,13 +857,7 @@ async function ls(deps, args) {
|
|
|
862
857
|
...(persisted.runtimePort
|
|
863
858
|
? { runtimeUrl: `http://${STACK_CLIENT_HOST}:${persisted.runtimePort}` }
|
|
864
859
|
: {}),
|
|
865
|
-
...(persisted.studioPort
|
|
866
|
-
? {
|
|
867
|
-
studioUrl: proxy
|
|
868
|
-
? proxyOrigin(entry.name, proxy.port)
|
|
869
|
-
: studioOrigin(persisted.studioPort),
|
|
870
|
-
}
|
|
871
|
-
: {}),
|
|
860
|
+
...(persisted.studioPort ? { studioUrl: studioOrigin(persisted.studioPort) } : {}),
|
|
872
861
|
ports: {
|
|
873
862
|
...(persisted.runtimePort ? { runtime: persisted.runtimePort } : {}),
|
|
874
863
|
...(persisted.adminPort ? { admin: persisted.adminPort } : {}),
|
|
@@ -920,7 +909,6 @@ async function deleteStack(deps, args) {
|
|
|
920
909
|
? composeArgs(ctx, "down", "--volumes", "--remove-orphans")
|
|
921
910
|
: ["compose", "--project-name", ctx.project, "down", "--volumes", "--remove-orphans"]);
|
|
922
911
|
await rm(ctx.paths.root, { recursive: true, force: true });
|
|
923
|
-
await refreshProxyRoutes(deps.docker, base, await proxyRoutes(deps));
|
|
924
912
|
deps.out(`Deleted Tenant ${name}: its containers, volumes and Host root (vault key included).`);
|
|
925
913
|
return 0;
|
|
926
914
|
}
|
|
@@ -938,6 +926,9 @@ async function runningStack(ctx, options) {
|
|
|
938
926
|
const services = await composePs(ctx);
|
|
939
927
|
if (!isUp(services, "runtime") || !isUp(services, "gateway"))
|
|
940
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;
|
|
941
932
|
const studioUp = isUp(services, "studio");
|
|
942
933
|
if (options.studio && !studioUp)
|
|
943
934
|
return undefined;
|
|
@@ -955,7 +946,7 @@ async function runningStack(ctx, options) {
|
|
|
955
946
|
hostId: host.hostId,
|
|
956
947
|
adminKey,
|
|
957
948
|
studioPort: persisted.studioPort,
|
|
958
|
-
studioUrl:
|
|
949
|
+
studioUrl: studioOrigin(persisted.studioPort),
|
|
959
950
|
studioUp,
|
|
960
951
|
started: false,
|
|
961
952
|
};
|
|
@@ -980,6 +971,26 @@ async function ensureSelected(ctx, options) {
|
|
|
980
971
|
started: !runtimeWasUp,
|
|
981
972
|
};
|
|
982
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
|
+
}
|
|
983
994
|
/**
|
|
984
995
|
* Start the selected Tenant unless it is already running (`nylorun studio`): the
|
|
985
996
|
* `start` code path without its output, its Tenant wait or the Project link. Compose
|
|
@@ -988,6 +999,40 @@ async function ensureSelected(ctx, options) {
|
|
|
988
999
|
export async function ensureStack(deps, options) {
|
|
989
1000
|
return await ensureSelected(await selectStack(deps, options.name === undefined ? {} : { name: options.name }), options);
|
|
990
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
|
+
}
|
|
991
1036
|
/** Add Studio's `next` path (e.g. `/tenants/<id>`) to a login URL. */
|
|
992
1037
|
export function withNext(loginUrl, next) {
|
|
993
1038
|
if (!next)
|
|
@@ -1005,7 +1050,7 @@ export function tenantStudioPath(tenantId) {
|
|
|
1005
1050
|
* Undefined (after a warning) when Studio does not answer.
|
|
1006
1051
|
*/
|
|
1007
1052
|
export async function studioLoginUrl(deps, running, next) {
|
|
1008
|
-
const login = await tryStudioLogin({ deps },
|
|
1053
|
+
const login = await tryStudioLogin({ deps }, running.studioPort, running.adminKey);
|
|
1009
1054
|
return login === undefined ? undefined : withNext(login, next);
|
|
1010
1055
|
}
|
|
1011
1056
|
async function studio(deps, args, options = {}) {
|
|
@@ -1035,7 +1080,6 @@ async function studio(deps, args, options = {}) {
|
|
|
1035
1080
|
deps.out(`Studio ${login}`);
|
|
1036
1081
|
return 0;
|
|
1037
1082
|
}
|
|
1038
|
-
// The login's origin: the proxy's, or Studio's own port when the proxy did not answer.
|
|
1039
1083
|
const origin = new URL(login).origin;
|
|
1040
1084
|
deps.out(`Studio ${next ? new URL(next, origin).toString() : origin}`);
|
|
1041
1085
|
await openLogin({ deps }, login);
|
|
@@ -1043,7 +1087,7 @@ async function studio(deps, args, options = {}) {
|
|
|
1043
1087
|
}
|
|
1044
1088
|
/** `nylorun studio`, landing on `next` when given, else on the Tenant's page. */
|
|
1045
1089
|
export async function runStudioCommand(args, deps, options = {}) {
|
|
1046
|
-
await
|
|
1090
|
+
await tidyMachine(deps);
|
|
1047
1091
|
return await studio(deps, args, options);
|
|
1048
1092
|
}
|
|
1049
1093
|
const COMMANDS = {
|
|
@@ -1067,6 +1111,6 @@ export async function runStackCommand(name, args, deps) {
|
|
|
1067
1111
|
const command = COMMANDS[name];
|
|
1068
1112
|
if (!command)
|
|
1069
1113
|
throw usageError(`Unknown command ${name}.\n${stackUsage}`);
|
|
1070
|
-
await
|
|
1114
|
+
await tidyMachine(deps);
|
|
1071
1115
|
return await command(deps, args);
|
|
1072
1116
|
}
|
|
@@ -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
|
|
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;
|