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.
@@ -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>] stop the Tenant's containers; keep volumes
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 other Tenants keep in their `.env`, so a new Tenant avoids them. */
289
- async function reservedPorts(ctx) {
290
- const others = (await listTenants(nylorunRoot(ctx.deps)))
291
- .filter((tenant) => resolve(tenant.root) !== ctx.paths.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 telemetry = await readTelemetry(nylorunRoot(deps));
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
- async function tryStudioLogin(ctx, studioPort, adminKey) {
373
- const deadline = Date.now() + (ctx.deps.loginTimeoutMs ?? 15_000);
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: ctx.deps.fetch, studioPort, adminKey });
476
+ return await mintStudioLogin({ fetch: deps.fetch, origin, adminKey });
378
477
  }
379
478
  catch (error) {
380
- lastError = error instanceof Error ? error.message : String(error);
479
+ lastError = errorText(error);
381
480
  }
382
481
  if (Date.now() > deadline)
383
482
  break;
384
- await sleep(ctx.deps.pollMs ?? 500);
483
+ await sleep(deps.pollMs ?? 500);
385
484
  }
386
- ctx.deps.err(`Warning: Studio at ${studioOrigin(studioPort)} is not reachable for a login (${lastError}).`);
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 ${studioOrigin(started.studioPort)}`);
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 ? { url: studioOrigin(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
- out(`Studio ${result.studio.url ?? "?"} ${result.studio.state} (log in with "nylorun studio")`);
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
- const code = await deps.docker.stream(composeArgs(ctx, "down", "--volumes", "--remove-orphans"));
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: state(`nylorun-${entry.name}`),
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 ? { studioUrl: studioOrigin(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 width = Math.max(6, ...tenants.map((tenant) => tenant.name.length)) + 2;
723
- deps.out(`${"TENANT".padEnd(width)}${"STATE".padEnd(9)}${"RUNTIME".padEnd(24)}${"STUDIO".padEnd(24)}PROJECT`);
724
- for (const tenant of tenants)
725
- deps.out(`${tenant.name.padEnd(width)}${tenant.state.padEnd(9)}${(tenant.runtimeUrl ?? "-").padEnd(24)}${(tenant.studioUrl ?? "-").padEnd(24)}${tenant.project ?? "-"}`);
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
- const code = await deps.docker.stream(files
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
- const origin = studioOrigin(running.studioPort);
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. Everything else that varies (ports, the Postgres password, UID/GID, the
6
- * Host root, the images, the Tenant's name) comes from `docker/.env`.
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. Everything else that varies (ports, the Postgres password, UID/GID, the
7
- * Host root, the images, the Tenant's name) comes from `docker/.env`.
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
  }
@@ -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>;