nylorun 0.5.0-beta → 0.6.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 +17 -0
- package/README.md +110 -18
- package/dist/doctor.d.ts +3 -1
- package/dist/doctor.js +19 -2
- package/dist/stack/commands.d.ts +10 -2
- package/dist/stack/commands.js +214 -36
- package/dist/stack/compose-file.d.ts +6 -3
- package/dist/stack/compose-file.js +43 -12
- package/dist/stack/docker.d.ts +10 -0
- package/dist/stack/docker.js +63 -0
- package/dist/stack/env-file.d.ts +5 -0
- package/dist/stack/env-file.js +5 -0
- package/dist/stack/images.d.ts +3 -1
- package/dist/stack/images.js +2 -1
- package/dist/stack/index.d.ts +2 -1
- package/dist/stack/index.js +3 -1
- package/dist/stack/ports.d.ts +2 -0
- package/dist/stack/ports.js +2 -0
- package/dist/stack/prepare.d.ts +2 -0
- package/dist/stack/prepare.js +2 -1
- package/dist/stack/proxy.d.ts +78 -0
- package/dist/stack/proxy.js +224 -0
- package/dist/stack/studio-login.d.ts +4 -3
- package/dist/stack/studio-login.js +5 -4
- package/package.json +3 -3
package/dist/stack/commands.js
CHANGED
|
@@ -8,28 +8,29 @@ import { readProjectCredentials, readProjectLink, writeProjectCredentials, write
|
|
|
8
8
|
import { findProjectRoot } from "../project/root.js";
|
|
9
9
|
import { seedTenant } from "../project/seed.js";
|
|
10
10
|
import { readTelemetry, STUDIO_ANALYTICS_ID, TELEMETRY_NOTICE, telemetryDecision, writeTelemetry, } from "../telemetry.js";
|
|
11
|
-
import { dockerPreflight, parseComposePs, } from "./docker.js";
|
|
11
|
+
import { dockerPreflight, formatBytes, parseComposePs, tenantMemory, } from "./docker.js";
|
|
12
12
|
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 { disconnectProxy, ensureProxySettings, PROXY_PROJECT, proxyDisabled, proxyOrigin, readProxySettings, readProxyStatus, refreshProxyRoutes, startProxy, stopProxy, } from "./proxy.js";
|
|
16
17
|
import { assertTenantName, chooseTenantName, DEFAULT_TENANT, defaultNylorunRoot, listTenants, moveStackRoots, readTenantRecord, sanitizeTenantName, tenantRoot, tenantsDir, writeTenantRecord, } from "./stacks.js";
|
|
17
18
|
import { mintStudioLogin, studioOrigin } from "./studio-login.js";
|
|
18
|
-
export const STACK_SERVICES = ["postgres", "restate", "s2", "gateway", "runtime", "studio"];
|
|
19
|
-
const CORE_SERVICES = ["postgres", "restate", "s2", "gateway", "runtime"];
|
|
19
|
+
export const STACK_SERVICES = ["postgres", "restate", "s2-lite", "gateway", "runtime", "studio"];
|
|
20
|
+
const CORE_SERVICES = ["postgres", "restate", "s2-lite", "gateway", "runtime"];
|
|
20
21
|
export const stackUsage = ` up|start [--tenant <name>] [--no-link] [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset]
|
|
21
22
|
start the project's Tenant, creating it and the Project link (.nylorun/link.json,
|
|
22
23
|
credentials.json) on the first run; print the Runtime and Studio URLs and open Studio
|
|
23
24
|
signed in (in a terminal). --tenant attaches to (or creates) a named Tenant; --no-link
|
|
24
25
|
starts the default Tenant (or --tenant's) without linking the current directory
|
|
25
|
-
down|stop [--tenant <name>]
|
|
26
|
+
down|stop [--tenant <name> | --all] stop the Tenant's containers (--all: every Tenant's, and the Studio proxy); keep volumes
|
|
26
27
|
status [--tenant <name>] [--json] the Tenant, its id, services, endpoints and Runtime health
|
|
27
28
|
logs [service] [--tenant <name>] [-f] [--tail <n>]
|
|
28
29
|
container logs (${STACK_SERVICES.join(", ")})
|
|
29
30
|
studio [--tenant <name>] [--no-open]
|
|
30
31
|
sign a browser in to Studio on the Tenant; --no-open prints the login URL; start the Tenant if it is stopped
|
|
31
32
|
reset [--tenant <name>] [--yes] delete the Tenant's data (volumes, tenant/ and vault key); the next start creates it anew
|
|
32
|
-
ls [--json] the Tenants on this machine
|
|
33
|
+
ls [--json] the Tenants on this machine, with their memory in use
|
|
33
34
|
delete <tenant> --yes remove a Tenant: containers, volumes and its Host root, with the vault key (KEK)`;
|
|
34
35
|
const usageError = (message) => new CliError(message, 2);
|
|
35
36
|
/** The Compose project of Tenant `name`: `nylorun-<name>`, or `NYLORUN_COMPOSE_PROJECT`. */
|
|
@@ -42,6 +43,57 @@ export function stackProject(env, name) {
|
|
|
42
43
|
function nylorunRoot(deps) {
|
|
43
44
|
return deps.nylorunRoot ?? defaultNylorunRoot();
|
|
44
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* The Studio proxy serves the machine's Tenants (`~/.nylorun/tenants`), unless
|
|
48
|
+
* NYLORUN_PROXY_DISABLED=1; a Host root or Compose project of its own (NYLORUN_HOME,
|
|
49
|
+
* NYLORUN_COMPOSE_PROJECT, as the repository's smokes use) is outside it.
|
|
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
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Compose's names for a 0.5 Tenant's volumes (`<project>_postgres`, …) that exist on this
|
|
69
|
+
* engine, and whether this release's `<project>-postgres` does.
|
|
70
|
+
*/
|
|
71
|
+
async function oldVolumes(docker, project) {
|
|
72
|
+
const result = await docker.run(["volume", "ls", "--quiet", "--filter", `name=${project}`]);
|
|
73
|
+
const names = new Set(result.code === 0 ? result.stdout.split(/\s+/) : []);
|
|
74
|
+
return {
|
|
75
|
+
old: ["postgres", "restate", "s2", "workspaces"]
|
|
76
|
+
.map((volume) => `${project}_${volume}`)
|
|
77
|
+
.filter((name) => names.has(name)),
|
|
78
|
+
current: names.has(`${project}-postgres`),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
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
|
+
*/
|
|
85
|
+
async function composeDown(ctx, args) {
|
|
86
|
+
const { docker } = ctx.deps;
|
|
87
|
+
await disconnectProxy(docker, ctx.project);
|
|
88
|
+
const code = await docker.stream(args);
|
|
89
|
+
if (code !== 0)
|
|
90
|
+
throw new CliError(`docker compose down failed (exit ${code}).`, 1);
|
|
91
|
+
const { old } = await oldVolumes(docker, ctx.project);
|
|
92
|
+
if (old.length) {
|
|
93
|
+
await docker.run(["volume", "rm", ...old]);
|
|
94
|
+
await docker.run(["network", "rm", `${ctx.project}_default`]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
45
97
|
async function tenantNames(deps) {
|
|
46
98
|
const names = (await listTenants(nylorunRoot(deps))).map((tenant) => tenant.name);
|
|
47
99
|
return names.length ? ` Tenants on this machine: ${names.join(", ")}.` : "";
|
|
@@ -285,10 +337,10 @@ async function refuseDowngrade(ctx, allowDowngrade) {
|
|
|
285
337
|
}
|
|
286
338
|
throw new CliError(`Refusing to start Runtime ${pinned} on Tenant ${ctx.name}: Runtime ${host.runtimeVersion} last ran it (${ctx.paths.config}), and a Runtime older than the Tenant's database cannot open its Tenant. Update nylorun (npx nylorun@latest start), or run "nylorun start --allow-downgrade" when both Runtimes use the same database schema.`, 5);
|
|
287
339
|
}
|
|
288
|
-
/** Ports
|
|
289
|
-
async function
|
|
290
|
-
const others = (await listTenants(
|
|
291
|
-
.filter((tenant) => resolve(tenant.root) !==
|
|
340
|
+
/** Ports the Tenants on this machine keep in their `.env` (but the one under `except`). */
|
|
341
|
+
async function tenantPorts(base, except) {
|
|
342
|
+
const others = (await listTenants(base))
|
|
343
|
+
.filter((tenant) => resolve(tenant.root) !== except)
|
|
292
344
|
.map((tenant) => stackPaths(tenant.root));
|
|
293
345
|
const reserved = new Set();
|
|
294
346
|
for (const paths of others) {
|
|
@@ -304,14 +356,31 @@ async function reservedPorts(ctx) {
|
|
|
304
356
|
}
|
|
305
357
|
return reserved;
|
|
306
358
|
}
|
|
359
|
+
/** Ports other Tenants and the proxy keep, so a new Tenant avoids them. */
|
|
360
|
+
async function reservedPorts(ctx) {
|
|
361
|
+
const base = nylorunRoot(ctx.deps);
|
|
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
|
+
}
|
|
307
368
|
async function bringUp(ctx, options) {
|
|
308
369
|
const { deps } = ctx;
|
|
309
370
|
await refuseLauncherRuntime(ctx);
|
|
310
371
|
const overridden = runtimeImageOverridden(deps.env);
|
|
311
372
|
if (!overridden)
|
|
312
373
|
await refuseDowngrade(ctx, options.allowDowngrade ?? false);
|
|
313
|
-
const
|
|
374
|
+
const legacy = await oldVolumes(deps.docker, ctx.project);
|
|
375
|
+
if (legacy.old.length && !legacy.current)
|
|
376
|
+
throw new CliError(`Tenant ${ctx.name} was created by nylorun 0.5; its data is in the old volumes ${legacy.old.join(", ")}. Run "nylorun reset --tenant ${ctx.name}" to start it fresh; that also removes the old volumes (or drop them yourself with "docker volume rm ${legacy.old.join(" ")}").`, 3);
|
|
377
|
+
const base = nylorunRoot(deps);
|
|
378
|
+
const telemetry = await readTelemetry(base);
|
|
314
379
|
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;
|
|
315
384
|
const prepared = await prepareStack({
|
|
316
385
|
paths: ctx.paths,
|
|
317
386
|
name: ctx.name,
|
|
@@ -331,6 +400,7 @@ async function bringUp(ctx, options) {
|
|
|
331
400
|
: {}),
|
|
332
401
|
...(options.studioEmbedOrigins ? { studioEmbedOrigins: options.studioEmbedOrigins } : {}),
|
|
333
402
|
...(analytics ? { studioAnalyticsId: STUDIO_ANALYTICS_ID } : {}),
|
|
403
|
+
...(proxy ? { studioPublicOrigin: proxyOrigin(ctx.name, proxy.port) } : {}),
|
|
334
404
|
});
|
|
335
405
|
if (analytics && options.studio && telemetry.noticeShown === undefined) {
|
|
336
406
|
deps.err(TELEMETRY_NOTICE);
|
|
@@ -360,30 +430,59 @@ async function bringUp(ctx, options) {
|
|
|
360
430
|
if (!studioStarted)
|
|
361
431
|
deps.err(`Warning: Studio did not start (image ${prepared.env.studioImage}). The Runtime is up; see "nylorun logs studio".`);
|
|
362
432
|
}
|
|
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
|
+
}
|
|
363
446
|
return {
|
|
364
447
|
runtimeUrl,
|
|
365
448
|
adminUrl,
|
|
366
449
|
hostId: prepared.host.hostId,
|
|
367
450
|
studioPort: prepared.env.studioPort,
|
|
451
|
+
studioUrl,
|
|
368
452
|
studioStarted,
|
|
369
453
|
adminKey: prepared.adminKey,
|
|
370
454
|
};
|
|
371
455
|
}
|
|
372
|
-
|
|
373
|
-
|
|
456
|
+
const errorText = (error) => (error instanceof Error ? error.message : String(error));
|
|
457
|
+
/**
|
|
458
|
+
* A login URL on Studio's URL: through the proxy when it serves the Tenant (once; a proxy that
|
|
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) {
|
|
462
|
+
const { deps } = ctx;
|
|
463
|
+
const origin = studioOrigin(studio.port);
|
|
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
|
+
}
|
|
472
|
+
const deadline = Date.now() + (deps.loginTimeoutMs ?? 15_000);
|
|
374
473
|
let lastError = "";
|
|
375
474
|
for (;;) {
|
|
376
475
|
try {
|
|
377
|
-
return await mintStudioLogin({ fetch:
|
|
476
|
+
return await mintStudioLogin({ fetch: deps.fetch, origin, adminKey });
|
|
378
477
|
}
|
|
379
478
|
catch (error) {
|
|
380
|
-
lastError =
|
|
479
|
+
lastError = errorText(error);
|
|
381
480
|
}
|
|
382
481
|
if (Date.now() > deadline)
|
|
383
482
|
break;
|
|
384
|
-
await sleep(
|
|
483
|
+
await sleep(deps.pollMs ?? 500);
|
|
385
484
|
}
|
|
386
|
-
|
|
485
|
+
deps.err(`Warning: Studio at ${origin} is not reachable for a login (${lastError}).`);
|
|
387
486
|
return undefined;
|
|
388
487
|
}
|
|
389
488
|
/** Printed by `start` when it does not open Studio itself. */
|
|
@@ -483,22 +582,46 @@ async function start(deps, args) {
|
|
|
483
582
|
deps.out(`Tenant ${ctx.name} (${tenant.id})`);
|
|
484
583
|
deps.out(`Runtime ${started.runtimeUrl}`);
|
|
485
584
|
if (started.studioStarted) {
|
|
486
|
-
deps.out(`Studio ${
|
|
585
|
+
deps.out(`Studio ${started.studioUrl}`);
|
|
487
586
|
if (opensBrowser(deps, flags)) {
|
|
488
|
-
const login = await tryStudioLogin(ctx, started.studioPort, started.adminKey);
|
|
587
|
+
const login = await tryStudioLogin(ctx, { port: started.studioPort, url: started.studioUrl }, started.adminKey);
|
|
489
588
|
if (login)
|
|
490
589
|
await openLogin(ctx, withNext(login, tenantStudioPath(tenant.id)));
|
|
491
590
|
}
|
|
492
591
|
else
|
|
493
592
|
deps.err(STUDIO_SIGN_IN_HINT);
|
|
494
593
|
}
|
|
594
|
+
await alsoRunning(ctx);
|
|
495
595
|
return 0;
|
|
496
596
|
}
|
|
597
|
+
/** After `start`: the machine's other running Tenants and their memory, on stderr. */
|
|
598
|
+
async function alsoRunning(ctx) {
|
|
599
|
+
const { deps } = ctx;
|
|
600
|
+
const projects = await composeProjects(deps.docker);
|
|
601
|
+
if (!projects)
|
|
602
|
+
return;
|
|
603
|
+
const others = (await listTenants(nylorunRoot(deps)))
|
|
604
|
+
.map((tenant) => tenant.name)
|
|
605
|
+
.filter((name) => name !== ctx.name && projects.get(`nylorun-${name}`) === "running");
|
|
606
|
+
if (others.length === 0)
|
|
607
|
+
return;
|
|
608
|
+
const memory = await tenantMemory(deps.docker);
|
|
609
|
+
const sizes = others.map((name) => memory?.get(name));
|
|
610
|
+
const total = sizes.every((size) => size !== undefined)
|
|
611
|
+
? sizes.reduce((sum, size) => sum + size, 0)
|
|
612
|
+
: undefined;
|
|
613
|
+
deps.err(`Also running: ${others.join(", ")}${total === undefined ? "" : ` (about ${formatBytes(total)})`}. "nylorun stop --all" stops them all.`);
|
|
614
|
+
}
|
|
497
615
|
async function stop(deps, args) {
|
|
498
|
-
const usage = "nylorun stop [--tenant <name>]";
|
|
499
|
-
const flags = parseStackFlags(args, { values: ["--tenant"] }, usage);
|
|
616
|
+
const usage = "nylorun stop [--tenant <name> | --all]";
|
|
617
|
+
const flags = parseStackFlags(args, { booleans: ["--all"], values: ["--tenant"] }, usage);
|
|
500
618
|
if (flags.rest.length)
|
|
501
619
|
throw usageError(`Usage: ${usage}`);
|
|
620
|
+
if (flags.booleans.has("--all")) {
|
|
621
|
+
if (flags.values.has("--tenant"))
|
|
622
|
+
throw usageError(`--all and --tenant are exclusive. Usage: ${usage}`);
|
|
623
|
+
return await stopAll(deps);
|
|
624
|
+
}
|
|
502
625
|
const ctx = await selectStack(deps, nameOption(flags));
|
|
503
626
|
requireStackFiles(ctx);
|
|
504
627
|
await dockerPreflight(deps.docker);
|
|
@@ -508,6 +631,34 @@ async function stop(deps, args) {
|
|
|
508
631
|
deps.out(`Stopped Tenant ${ctx.name}; its data is kept.`);
|
|
509
632
|
return 0;
|
|
510
633
|
}
|
|
634
|
+
/** `stop --all`: every running Tenant on this machine, then the Studio proxy; volumes are kept. */
|
|
635
|
+
async function stopAll(deps) {
|
|
636
|
+
await dockerPreflight(deps.docker);
|
|
637
|
+
const projects = (await composeProjects(deps.docker)) ?? new Map();
|
|
638
|
+
const stopped = [];
|
|
639
|
+
for (const tenant of await listTenants(nylorunRoot(deps))) {
|
|
640
|
+
const project = `nylorun-${tenant.name}`;
|
|
641
|
+
if (projects.get(project) !== "running")
|
|
642
|
+
continue;
|
|
643
|
+
const paths = stackPaths(tenant.root);
|
|
644
|
+
const code = await deps.docker.stream(existsSync(paths.compose) && existsSync(paths.env)
|
|
645
|
+
? composeArgs({ project, paths }, "stop")
|
|
646
|
+
: ["compose", "--project-name", project, "stop"]);
|
|
647
|
+
if (code !== 0)
|
|
648
|
+
throw new CliError(`docker compose stop failed for Tenant ${tenant.name} (exit ${code}).`, 1);
|
|
649
|
+
stopped.push(tenant.name);
|
|
650
|
+
}
|
|
651
|
+
const proxy = projects.get(PROXY_PROJECT) === "running" && (await stopProxy(deps.docker));
|
|
652
|
+
deps.out(stopped.length
|
|
653
|
+
? `Stopped Tenants ${stopped.join(", ")}${proxy ? " and the Studio proxy" : ""}; their data is kept.`
|
|
654
|
+
: `No Tenant was running${proxy ? "; stopped the Studio proxy" : ""}.`);
|
|
655
|
+
return 0;
|
|
656
|
+
}
|
|
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
|
+
}
|
|
511
662
|
async function stackStatus(ctx) {
|
|
512
663
|
const base = {
|
|
513
664
|
name: ctx.name,
|
|
@@ -552,7 +703,7 @@ async function stackStatus(ctx) {
|
|
|
552
703
|
},
|
|
553
704
|
...(tenant ? { tenant } : {}),
|
|
554
705
|
studio: {
|
|
555
|
-
...(persisted.studioPort ?
|
|
706
|
+
...(persisted.studioPort ? await studioUrls(ctx, persisted.studioPort) : {}),
|
|
556
707
|
state: studio ? [studio.state, studio.health].filter(Boolean).join(", ") : "absent",
|
|
557
708
|
...(persisted.studioFrameAncestors
|
|
558
709
|
? { embedOrigins: persisted.studioFrameAncestors }
|
|
@@ -568,6 +719,10 @@ async function stackStatus(ctx) {
|
|
|
568
719
|
services,
|
|
569
720
|
};
|
|
570
721
|
}
|
|
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
|
+
}
|
|
571
726
|
/** What `nylorun status` reports, as data (`nylorun doctor`). */
|
|
572
727
|
export async function readStackStatus(deps, options = {}) {
|
|
573
728
|
await moveStackRoots(nylorunRoot(deps), deps.err);
|
|
@@ -605,7 +760,8 @@ async function status(deps, args) {
|
|
|
605
760
|
out(`Runtime ${result.runtime.url ?? "?"} ${runtimeDetail}`);
|
|
606
761
|
if (result.runtime.adminUrl)
|
|
607
762
|
out(`Admin API ${result.runtime.adminUrl} (operators only, never proxied)`);
|
|
608
|
-
|
|
763
|
+
const also = result.studio.proxyUrl ? `also ${result.studio.url}; ` : "";
|
|
764
|
+
out(`Studio ${result.studio.proxyUrl ?? result.studio.url ?? "?"} ${result.studio.state} (${also}log in with "nylorun studio")`);
|
|
609
765
|
if (result.studio.embedOrigins?.length)
|
|
610
766
|
out(`Embeds ${result.studio.embedOrigins.join(" ")} (may show Studio in a frame)`);
|
|
611
767
|
out(`Gateway ${result.gateway.state} (the Model Gate; model calls fail while it is down)`);
|
|
@@ -655,9 +811,7 @@ async function reset(deps, args) {
|
|
|
655
811
|
}
|
|
656
812
|
if (existsSync(paths.compose) && existsSync(paths.env)) {
|
|
657
813
|
await dockerPreflight(deps.docker);
|
|
658
|
-
|
|
659
|
-
if (code !== 0)
|
|
660
|
-
throw new CliError(`docker compose down failed (exit ${code}).`, 1);
|
|
814
|
+
await composeDown(ctx, composeArgs(ctx, "down", "--volumes", "--remove-orphans"));
|
|
661
815
|
}
|
|
662
816
|
await rm(paths.tenant, { recursive: true, force: true });
|
|
663
817
|
await mkdir(paths.tenant, { recursive: true, mode: 0o700 });
|
|
@@ -691,18 +845,30 @@ async function ls(deps, args) {
|
|
|
691
845
|
const base = nylorunRoot(deps);
|
|
692
846
|
const projects = await composeProjects(deps.docker);
|
|
693
847
|
const state = (project) => (projects ? (projects.get(project) ?? "stopped") : "unknown");
|
|
848
|
+
const memory = [...(projects?.values() ?? [])].includes("running")
|
|
849
|
+
? await tenantMemory(deps.docker)
|
|
850
|
+
: undefined;
|
|
851
|
+
const proxy = proxyDisabled(deps.env) ? undefined : await readProxySettings(base);
|
|
694
852
|
const tenants = [];
|
|
695
853
|
for (const entry of await listTenants(base)) {
|
|
696
854
|
const persisted = (await readStackEnv(stackPaths(entry.root))) ?? {};
|
|
855
|
+
const tenantState = state(`nylorun-${entry.name}`);
|
|
697
856
|
tenants.push({
|
|
698
857
|
name: entry.name,
|
|
699
858
|
root: entry.root,
|
|
700
859
|
...(entry.record.project ? { project: entry.record.project } : {}),
|
|
701
|
-
state:
|
|
860
|
+
state: tenantState,
|
|
861
|
+
memoryBytes: (tenantState === "running" && memory?.get(entry.name)) || null,
|
|
702
862
|
...(persisted.runtimePort
|
|
703
863
|
? { runtimeUrl: `http://${STACK_CLIENT_HOST}:${persisted.runtimePort}` }
|
|
704
864
|
: {}),
|
|
705
|
-
...(persisted.studioPort
|
|
865
|
+
...(persisted.studioPort
|
|
866
|
+
? {
|
|
867
|
+
studioUrl: proxy
|
|
868
|
+
? proxyOrigin(entry.name, proxy.port)
|
|
869
|
+
: studioOrigin(persisted.studioPort),
|
|
870
|
+
}
|
|
871
|
+
: {}),
|
|
706
872
|
ports: {
|
|
707
873
|
...(persisted.runtimePort ? { runtime: persisted.runtimePort } : {}),
|
|
708
874
|
...(persisted.adminPort ? { admin: persisted.adminPort } : {}),
|
|
@@ -719,10 +885,20 @@ async function ls(deps, args) {
|
|
|
719
885
|
deps.out('No Tenants on this machine. Run "npx nylorun start" in a project (or anywhere, for the default Tenant).');
|
|
720
886
|
return 0;
|
|
721
887
|
}
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
888
|
+
const rows = [
|
|
889
|
+
["TENANT", "STATE", "MEMORY", "RUNTIME", "STUDIO", "PROJECT"],
|
|
890
|
+
...tenants.map((tenant) => [
|
|
891
|
+
tenant.name,
|
|
892
|
+
tenant.state,
|
|
893
|
+
tenant.memoryBytes === null ? "-" : formatBytes(tenant.memoryBytes),
|
|
894
|
+
tenant.runtimeUrl ?? "-",
|
|
895
|
+
tenant.studioUrl ?? "-",
|
|
896
|
+
tenant.project ?? "-",
|
|
897
|
+
]),
|
|
898
|
+
];
|
|
899
|
+
const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)) + 2);
|
|
900
|
+
for (const row of rows)
|
|
901
|
+
deps.out(row.map((cell, column) => (column < row.length - 1 ? cell.padEnd(widths[column]) : cell)).join(""));
|
|
726
902
|
return 0;
|
|
727
903
|
}
|
|
728
904
|
async function deleteStack(deps, args) {
|
|
@@ -740,12 +916,11 @@ async function deleteStack(deps, args) {
|
|
|
740
916
|
throw usageError(`nylorun delete removes Tenant ${name}: its containers, volumes and Host root ${ctx.paths.root}, with its vault key (KEK) and all its data. This cannot be undone; pass --yes to confirm.`);
|
|
741
917
|
await dockerPreflight(deps.docker);
|
|
742
918
|
const files = existsSync(ctx.paths.compose) && existsSync(ctx.paths.env);
|
|
743
|
-
|
|
919
|
+
await composeDown(ctx, files
|
|
744
920
|
? composeArgs(ctx, "down", "--volumes", "--remove-orphans")
|
|
745
921
|
: ["compose", "--project-name", ctx.project, "down", "--volumes", "--remove-orphans"]);
|
|
746
|
-
if (code !== 0)
|
|
747
|
-
throw new CliError(`docker compose down failed (exit ${code}).`, 1);
|
|
748
922
|
await rm(ctx.paths.root, { recursive: true, force: true });
|
|
923
|
+
await refreshProxyRoutes(deps.docker, base, await proxyRoutes(deps));
|
|
749
924
|
deps.out(`Deleted Tenant ${name}: its containers, volumes and Host root (vault key included).`);
|
|
750
925
|
return 0;
|
|
751
926
|
}
|
|
@@ -780,6 +955,7 @@ async function runningStack(ctx, options) {
|
|
|
780
955
|
hostId: host.hostId,
|
|
781
956
|
adminKey,
|
|
782
957
|
studioPort: persisted.studioPort,
|
|
958
|
+
studioUrl: await studioUrl(ctx, persisted.studioPort),
|
|
783
959
|
studioUp,
|
|
784
960
|
started: false,
|
|
785
961
|
};
|
|
@@ -799,6 +975,7 @@ async function ensureSelected(ctx, options) {
|
|
|
799
975
|
hostId: started.hostId,
|
|
800
976
|
adminKey: started.adminKey,
|
|
801
977
|
studioPort: started.studioPort,
|
|
978
|
+
studioUrl: started.studioUrl,
|
|
802
979
|
studioUp: started.studioStarted,
|
|
803
980
|
started: !runtimeWasUp,
|
|
804
981
|
};
|
|
@@ -828,7 +1005,7 @@ export function tenantStudioPath(tenantId) {
|
|
|
828
1005
|
* Undefined (after a warning) when Studio does not answer.
|
|
829
1006
|
*/
|
|
830
1007
|
export async function studioLoginUrl(deps, running, next) {
|
|
831
|
-
const login = await tryStudioLogin({ deps }, running.studioPort, running.adminKey);
|
|
1008
|
+
const login = await tryStudioLogin({ deps }, { port: running.studioPort, url: running.studioUrl }, running.adminKey);
|
|
832
1009
|
return login === undefined ? undefined : withNext(login, next);
|
|
833
1010
|
}
|
|
834
1011
|
async function studio(deps, args, options = {}) {
|
|
@@ -858,7 +1035,8 @@ async function studio(deps, args, options = {}) {
|
|
|
858
1035
|
deps.out(`Studio ${login}`);
|
|
859
1036
|
return 0;
|
|
860
1037
|
}
|
|
861
|
-
|
|
1038
|
+
// The login's origin: the proxy's, or Studio's own port when the proxy did not answer.
|
|
1039
|
+
const origin = new URL(login).origin;
|
|
862
1040
|
deps.out(`Studio ${next ? new URL(next, origin).toString() : origin}`);
|
|
863
1041
|
await openLogin({ deps }, login);
|
|
864
1042
|
return 0;
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* A local Tenant's Compose file (Runtime Architecture §14.3), written to
|
|
3
3
|
* `<Host root>/docker/compose.yaml` by `nylorun start`. Each local Tenant is one installation
|
|
4
4
|
* (tenancy.md §6): its own Compose project (`nylorun-<name>`, the file's `name`), volumes,
|
|
5
|
-
* network and ports.
|
|
6
|
-
*
|
|
5
|
+
* network and ports. Container, network and volume names are global on the Docker engine, so
|
|
6
|
+
* every one is `<project>-<role>` (`nylorun-shop-studio`, network `nylorun-shop`, volume
|
|
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, Studio's public origins) comes from `docker/.env`.
|
|
7
10
|
*
|
|
8
11
|
* The Tenant's state is its Postgres database, executed through Restate, with its
|
|
9
12
|
* history in s2-lite; the Runtime's /ready checks all three. Postgres initialises the
|
|
@@ -33,4 +36,4 @@
|
|
|
33
36
|
* it is down, model and MCP calls fail, deliveries are retried, vault writes and
|
|
34
37
|
* token minting answer 503, and the session takes the next message.
|
|
35
38
|
*/
|
|
36
|
-
export declare function renderComposeFile(project: string): string;
|
|
39
|
+
export declare function renderComposeFile(project: string, name: string): string;
|
|
@@ -3,8 +3,11 @@ import { PINNED_IMAGES } from "./images.js";
|
|
|
3
3
|
* A local Tenant's Compose file (Runtime Architecture §14.3), written to
|
|
4
4
|
* `<Host root>/docker/compose.yaml` by `nylorun start`. Each local Tenant is one installation
|
|
5
5
|
* (tenancy.md §6): its own Compose project (`nylorun-<name>`, the file's `name`), volumes,
|
|
6
|
-
* network and ports.
|
|
7
|
-
*
|
|
6
|
+
* network and ports. Container, network and volume names are global on the Docker engine, so
|
|
7
|
+
* every one is `<project>-<role>` (`nylorun-shop-studio`, network `nylorun-shop`, volume
|
|
8
|
+
* `nylorun-shop-postgres`), labelled `dev.nylorun.tenant: <name>`; they are written here, from
|
|
9
|
+
* the project and the Tenant's name. Everything else that varies (ports, the Postgres password,
|
|
10
|
+
* UID/GID, the Host root, the images, Studio's public origins) comes from `docker/.env`.
|
|
8
11
|
*
|
|
9
12
|
* The Tenant's state is its Postgres database, executed through Restate, with its
|
|
10
13
|
* history in s2-lite; the Runtime's /ready checks all three. Postgres initialises the
|
|
@@ -34,13 +37,18 @@ import { PINNED_IMAGES } from "./images.js";
|
|
|
34
37
|
* it is down, model and MCP calls fail, deliveries are retried, vault writes and
|
|
35
38
|
* token minting answer 503, and the session takes the next message.
|
|
36
39
|
*/
|
|
37
|
-
export function renderComposeFile(project) {
|
|
40
|
+
export function renderComposeFile(project, name) {
|
|
38
41
|
return `# Written by \`nylorun start\`; rewritten on every start. Settings live in .env.
|
|
39
42
|
name: ${project}
|
|
40
43
|
|
|
44
|
+
x-tenant: &tenant
|
|
45
|
+
dev.nylorun.tenant: "${name}"
|
|
46
|
+
|
|
41
47
|
services:
|
|
42
48
|
postgres: # Session Store
|
|
43
49
|
image: ${PINNED_IMAGES.postgres}
|
|
50
|
+
container_name: ${project}-postgres
|
|
51
|
+
labels: *tenant
|
|
44
52
|
# Logical replication feeds the stream relay; a stuck slot is capped at 4 GB of WAL.
|
|
45
53
|
command: ["postgres", "-c", "wal_level=logical", "-c", "max_slot_wal_keep_size=4GB"]
|
|
46
54
|
environment:
|
|
@@ -59,9 +67,14 @@ services:
|
|
|
59
67
|
|
|
60
68
|
restate: # Durable Session Execution
|
|
61
69
|
image: ${PINNED_IMAGES.restate}
|
|
70
|
+
container_name: ${project}-restate
|
|
71
|
+
labels: *tenant
|
|
62
72
|
command: ["--node-name=restate-1"] # stable name, so data is found on restart
|
|
63
73
|
environment:
|
|
64
74
|
RESTATE_WORKER__INVOKER__REQUEST_IDENTITY_PRIVATE_KEY_PEM_FILE: /run/nylorun/restate-identity.pem
|
|
75
|
+
# RocksDB's memory (block cache and memtables) defaults to 2 GiB; a local Tenant needs a
|
|
76
|
+
# fraction of it, and several Tenants share a laptop.
|
|
77
|
+
RESTATE_ROCKSDB_TOTAL_MEMORY_SIZE: 256MiB
|
|
65
78
|
volumes:
|
|
66
79
|
- restate:/restate-data
|
|
67
80
|
- \${NYLORUN_HOST_ROOT:?run nylorun start}/docker/restate-identity.pem:/run/nylorun/restate-identity.pem:ro
|
|
@@ -74,20 +87,24 @@ services:
|
|
|
74
87
|
retries: 30
|
|
75
88
|
restart: unless-stopped
|
|
76
89
|
|
|
77
|
-
s2: # Durable Streams; not published, only the Runtime reaches it
|
|
90
|
+
s2-lite: # Durable Streams; not published, only the Runtime reaches it
|
|
78
91
|
image: ${PINNED_IMAGES.s2}
|
|
92
|
+
container_name: ${project}-s2-lite
|
|
93
|
+
labels: *tenant
|
|
79
94
|
command: ["lite", "--local-root", "/home/nonroot/data"] # local disk; listens on port 80
|
|
80
95
|
# The image runs as uid 65532. Docker fills a new volume with the image's
|
|
81
96
|
# /home/nonroot, owned by that user; a volume at a path the image lacks
|
|
82
97
|
# (e.g. /data) is root-owned and s2-lite cannot write it.
|
|
83
98
|
volumes:
|
|
84
|
-
- s2:/home/nonroot
|
|
99
|
+
- s2-lite:/home/nonroot
|
|
85
100
|
# The s2 image has no shell or HTTP client, so it has no health check;
|
|
86
101
|
# the Runtime's /ready covers it.
|
|
87
102
|
restart: unless-stopped
|
|
88
103
|
|
|
89
104
|
gateway: # the Model and Tool Gates and keys: the vault key, credentials, signing, outbound calls; not published
|
|
90
105
|
image: \${NYLORUN_RUNTIME_IMAGE:?run nylorun start}
|
|
106
|
+
container_name: ${project}-gateway
|
|
107
|
+
labels: *tenant
|
|
91
108
|
command: ["--service", "gates,keys"]
|
|
92
109
|
user: "\${NYLORUN_UID:?run nylorun start}:\${NYLORUN_GID:?run nylorun start}"
|
|
93
110
|
depends_on:
|
|
@@ -116,12 +133,14 @@ services:
|
|
|
116
133
|
|
|
117
134
|
runtime:
|
|
118
135
|
image: \${NYLORUN_RUNTIME_IMAGE:?run nylorun start}
|
|
136
|
+
container_name: ${project}-runtime
|
|
137
|
+
labels: *tenant
|
|
119
138
|
command: ["--service", "core,loop"]
|
|
120
139
|
user: "\${NYLORUN_UID:?run nylorun start}:\${NYLORUN_GID:?run nylorun start}"
|
|
121
140
|
depends_on:
|
|
122
141
|
postgres: { condition: service_healthy }
|
|
123
142
|
restate: { condition: service_healthy }
|
|
124
|
-
s2: { condition: service_started }
|
|
143
|
+
s2-lite: { condition: service_started }
|
|
125
144
|
environment:
|
|
126
145
|
NYLORUN_HOME: /nylorun
|
|
127
146
|
NYLORUN_PACKING: combined
|
|
@@ -149,7 +168,7 @@ services:
|
|
|
149
168
|
NYLORUN_RESTATE_ADMIN_URL: http://restate:9070
|
|
150
169
|
NYLORUN_WORKER_URL: http://runtime:9080 # registered with Restate; not published
|
|
151
170
|
NYLORUN_RESTATE_IDENTITY_KEY: \${NYLORUN_RESTATE_IDENTITY_KEY:?run nylorun start}
|
|
152
|
-
NYLORUN_S2_ENDPOINT: http://s2:80
|
|
171
|
+
NYLORUN_S2_ENDPOINT: http://s2-lite:80
|
|
153
172
|
NYLORUN_S2_TOKEN: ignored # s2-lite has no access tokens yet
|
|
154
173
|
NYLORUN_WORKSPACE_STORE_URL: file:///workspaces
|
|
155
174
|
# Action endpoints on this machine: \`localhost\` in a registered URL means the Docker host.
|
|
@@ -181,6 +200,8 @@ services:
|
|
|
181
200
|
|
|
182
201
|
studio: # dashboard + trusted proxy
|
|
183
202
|
image: \${NYLORUN_STUDIO_IMAGE:?run nylorun start}
|
|
203
|
+
container_name: ${project}-studio
|
|
204
|
+
labels: *tenant
|
|
184
205
|
user: "\${NYLORUN_UID}:\${NYLORUN_GID}" # reads the 0600 admin key file
|
|
185
206
|
depends_on:
|
|
186
207
|
runtime: { condition: service_healthy }
|
|
@@ -188,8 +209,13 @@ services:
|
|
|
188
209
|
NYLORUN_RUNTIME_URL: http://runtime:4001 # the operator listener: Admin and Tenant API
|
|
189
210
|
NYLORUN_ADMIN_KEY_FILE: /run/nylorun/host-credentials.json
|
|
190
211
|
PORT: "3000"
|
|
191
|
-
# Studio's Host check accepts localhost/127.0.0.1 on the published port
|
|
212
|
+
# Studio's Host check accepts localhost/127.0.0.1 on the published port, and the
|
|
213
|
+
# proxy's origin (http://<name>.localhost:<port>), kept in .env.
|
|
192
214
|
NYLORUN_STUDIO_PUBLIC_PORT: \${NYLORUN_STUDIO_PORT}
|
|
215
|
+
NYLORUN_STUDIO_PUBLIC_ORIGINS: \${NYLORUN_STUDIO_PUBLIC_ORIGINS:-}
|
|
216
|
+
# Browsers share cookies across ports of one host: a cookie per Tenant keeps the
|
|
217
|
+
# sessions of two Studios on localhost apart.
|
|
218
|
+
NYLORUN_STUDIO_SESSION_COOKIE: nylorun_studio_${name}
|
|
193
219
|
# Exact origins that may frame Studio (Babai Desktop); kept in .env.
|
|
194
220
|
NYLORUN_STUDIO_FRAME_ANCESTORS: \${NYLORUN_STUDIO_FRAME_ANCESTORS:-}
|
|
195
221
|
# Studio's anonymous usage analytics; empty when telemetry is off.
|
|
@@ -205,10 +231,15 @@ services:
|
|
|
205
231
|
retries: 30
|
|
206
232
|
restart: unless-stopped
|
|
207
233
|
|
|
234
|
+
networks:
|
|
235
|
+
default:
|
|
236
|
+
name: ${project}
|
|
237
|
+
labels: *tenant
|
|
238
|
+
|
|
208
239
|
volumes:
|
|
209
|
-
postgres: {}
|
|
210
|
-
restate: {}
|
|
211
|
-
s2: {}
|
|
212
|
-
workspaces: {}
|
|
240
|
+
postgres: { name: ${project}-postgres, labels: *tenant }
|
|
241
|
+
restate: { name: ${project}-restate, labels: *tenant }
|
|
242
|
+
s2-lite: { name: ${project}-s2-lite, labels: *tenant }
|
|
243
|
+
workspaces: { name: ${project}-workspaces, labels: *tenant }
|
|
213
244
|
`;
|
|
214
245
|
}
|
package/dist/stack/docker.d.ts
CHANGED
|
@@ -36,3 +36,13 @@ export interface ComposeService {
|
|
|
36
36
|
}
|
|
37
37
|
/** Parse `docker compose ps --format json` (a JSON array, or one object per line). */
|
|
38
38
|
export declare function parseComposePs(stdout: string): ComposeService[];
|
|
39
|
+
/** `123.4MiB` (from `docker stats` MemUsage) in bytes; undefined when unreadable. */
|
|
40
|
+
export declare function parseByteSize(text: string): number | undefined;
|
|
41
|
+
/** `812 MB`, `2.1 GB`. */
|
|
42
|
+
export declare function formatBytes(bytes: number): string;
|
|
43
|
+
/**
|
|
44
|
+
* Memory in use per Tenant: the sum over its running containers, found by the label
|
|
45
|
+
* `dev.nylorun.tenant`, from one `docker stats --no-stream`. Undefined when Docker does not
|
|
46
|
+
* answer; a Tenant with no running container is absent.
|
|
47
|
+
*/
|
|
48
|
+
export declare function tenantMemory(docker: DockerRunner): Promise<Map<string, number> | undefined>;
|