nylorun 0.4.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 +39 -0
- package/README.md +207 -105
- package/dist/cli.js +24 -22
- package/dist/doctor.d.ts +10 -8
- package/dist/doctor.js +54 -37
- package/dist/home.d.ts +1 -1
- package/dist/home.js +1 -1
- package/dist/project/derived-key.d.ts +2 -2
- package/dist/project/derived-key.js +2 -2
- package/dist/project/link.d.ts +7 -7
- package/dist/project/link.js +3 -3
- package/dist/stack/commands.d.ts +23 -15
- package/dist/stack/commands.js +332 -214
- package/dist/stack/compose-file.d.ts +11 -8
- package/dist/stack/compose-file.js +54 -21
- package/dist/stack/docker.d.ts +10 -0
- package/dist/stack/docker.js +64 -1
- package/dist/stack/env-file.d.ts +12 -2
- package/dist/stack/env-file.js +13 -3
- package/dist/stack/host-files.d.ts +4 -4
- package/dist/stack/host-files.js +3 -3
- package/dist/stack/images.d.ts +4 -2
- package/dist/stack/images.js +3 -2
- package/dist/stack/index.d.ts +4 -3
- package/dist/stack/index.js +10 -4
- package/dist/stack/paths.d.ts +3 -3
- package/dist/stack/paths.js +1 -1
- package/dist/stack/ports.d.ts +4 -2
- package/dist/stack/ports.js +3 -1
- package/dist/stack/prepare.d.ts +7 -3
- package/dist/stack/prepare.js +5 -3
- package/dist/stack/proxy.d.ts +78 -0
- package/dist/stack/proxy.js +224 -0
- package/dist/stack/stacks.d.ts +37 -34
- package/dist/stack/stacks.js +91 -57
- package/dist/stack/studio-login.d.ts +4 -3
- package/dist/stack/studio-login.js +5 -4
- package/dist/stack/versions.d.ts +1 -1
- package/dist/stack/versions.js +1 -1
- package/dist/telemetry.d.ts +31 -0
- package/dist/telemetry.js +79 -0
- package/package.json +5 -5
package/dist/stack/commands.js
CHANGED
|
@@ -1,83 +1,137 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { mkdir, readFile, rm
|
|
3
|
-
import { basename,
|
|
2
|
+
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { basename, resolve } from "node:path";
|
|
4
4
|
import { compareVersions, PROTOCOL_HEADER, PROTOCOL_VERSION, } from "@nylorun/core/compatibility";
|
|
5
5
|
import { CliError } from "../errors.js";
|
|
6
6
|
import { deriveTenantKey, PROJECT_PRINCIPAL_ID } from "../project/derived-key.js";
|
|
7
7
|
import { readProjectCredentials, readProjectLink, writeProjectCredentials, writeProjectLink, } from "../project/link.js";
|
|
8
8
|
import { findProjectRoot } from "../project/root.js";
|
|
9
9
|
import { seedTenant } from "../project/seed.js";
|
|
10
|
-
import {
|
|
10
|
+
import { readTelemetry, STUDIO_ANALYTICS_ID, TELEMETRY_NOTICE, telemetryDecision, writeTelemetry, } from "../telemetry.js";
|
|
11
|
+
import { dockerPreflight, formatBytes, parseComposePs, tenantMemory, } from "./docker.js";
|
|
11
12
|
import { readAdminKey, readHostConfig, STACK_CLIENT_HOST } from "./host-files.js";
|
|
12
13
|
import { runtimeImageOverridden, stackImages } from "./images.js";
|
|
13
14
|
import { stackPaths } from "./paths.js";
|
|
14
15
|
import { prepareStack, readStackEnv } from "./prepare.js";
|
|
15
|
-
import {
|
|
16
|
+
import { disconnectProxy, ensureProxySettings, PROXY_PROJECT, proxyDisabled, proxyOrigin, readProxySettings, readProxyStatus, refreshProxyRoutes, startProxy, stopProxy, } from "./proxy.js";
|
|
17
|
+
import { assertTenantName, chooseTenantName, DEFAULT_TENANT, defaultNylorunRoot, listTenants, moveStackRoots, readTenantRecord, sanitizeTenantName, tenantRoot, tenantsDir, writeTenantRecord, } from "./stacks.js";
|
|
16
18
|
import { mintStudioLogin, studioOrigin } from "./studio-login.js";
|
|
17
|
-
export const STACK_SERVICES = ["postgres", "restate", "s2", "gateway", "runtime", "studio"];
|
|
18
|
-
const CORE_SERVICES = ["postgres", "restate", "s2", "gateway", "runtime"];
|
|
19
|
-
export const stackUsage = ` up|start [--
|
|
20
|
-
start the project's
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
down|stop [--
|
|
25
|
-
status [--
|
|
26
|
-
logs [service] [--
|
|
27
|
-
|
|
28
|
-
studio [--
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
19
|
+
export const STACK_SERVICES = ["postgres", "restate", "s2-lite", "gateway", "runtime", "studio"];
|
|
20
|
+
const CORE_SERVICES = ["postgres", "restate", "s2-lite", "gateway", "runtime"];
|
|
21
|
+
export const stackUsage = ` up|start [--tenant <name>] [--no-link] [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset]
|
|
22
|
+
start the project's Tenant, creating it and the Project link (.nylorun/link.json,
|
|
23
|
+
credentials.json) on the first run; print the Runtime and Studio URLs and open Studio
|
|
24
|
+
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
|
+
down|stop [--tenant <name> | --all] stop the Tenant's containers (--all: every Tenant's, and the Studio proxy); keep volumes
|
|
27
|
+
status [--tenant <name>] [--json] the Tenant, its id, services, endpoints and Runtime health
|
|
28
|
+
logs [service] [--tenant <name>] [-f] [--tail <n>]
|
|
29
|
+
container logs (${STACK_SERVICES.join(", ")})
|
|
30
|
+
studio [--tenant <name>] [--no-open]
|
|
31
|
+
sign a browser in to Studio on the Tenant; --no-open prints the login URL; start the Tenant if it is stopped
|
|
32
|
+
reset [--tenant <name>] [--yes] delete the Tenant's data (volumes, tenant/ and vault key); the next start creates it anew
|
|
33
|
+
ls [--json] the Tenants on this machine, with their memory in use
|
|
34
|
+
delete <tenant> --yes remove a Tenant: containers, volumes and its Host root, with the vault key (KEK)`;
|
|
33
35
|
const usageError = (message) => new CliError(message, 2);
|
|
34
|
-
/** The Compose project of
|
|
36
|
+
/** The Compose project of Tenant `name`: `nylorun-<name>`, or `NYLORUN_COMPOSE_PROJECT`. */
|
|
35
37
|
export function stackProject(env, name) {
|
|
36
|
-
const override = env.
|
|
38
|
+
const override = env.NYLORUN_COMPOSE_PROJECT?.trim();
|
|
37
39
|
if (!override)
|
|
38
40
|
return `nylorun-${name}`;
|
|
39
|
-
return
|
|
41
|
+
return assertTenantName(override, "NYLORUN_COMPOSE_PROJECT");
|
|
40
42
|
}
|
|
41
43
|
function nylorunRoot(deps) {
|
|
42
44
|
return deps.nylorunRoot ?? defaultNylorunRoot();
|
|
43
45
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
+
}
|
|
97
|
+
async function tenantNames(deps) {
|
|
98
|
+
const names = (await listTenants(nylorunRoot(deps))).map((tenant) => tenant.name);
|
|
99
|
+
return names.length ? ` Tenants on this machine: ${names.join(", ")}.` : "";
|
|
47
100
|
}
|
|
48
101
|
/**
|
|
49
|
-
* Which
|
|
50
|
-
* project's link (format
|
|
51
|
-
* directory (`
|
|
52
|
-
*
|
|
102
|
+
* Which Tenant a command acts on: `--tenant`, then `NYLORUN_TENANT`, then the Tenant of the
|
|
103
|
+
* project's link (format 3; `start` replaces an older one); for `start` in a project, then a
|
|
104
|
+
* name from the project directory (`chooseTenantName`); outside a project (or with
|
|
105
|
+
* --no-link), `default`. `NYLORUN_HOME` sets the Host root whatever the name; its name
|
|
106
|
+
* otherwise comes from its `tenant.json`, the project directory or the Host root's own.
|
|
53
107
|
*/
|
|
54
108
|
async function selectStack(deps, options = {}) {
|
|
55
109
|
const { env } = deps;
|
|
56
110
|
const base = nylorunRoot(deps);
|
|
57
111
|
const projectDir = options.noLink ? undefined : findProjectRoot(deps.cwd ?? process.cwd());
|
|
58
112
|
const link = projectDir ? await readProjectLink(projectDir) : undefined;
|
|
59
|
-
const explicit = options.name ?? (env.
|
|
113
|
+
const explicit = options.name ?? (env.NYLORUN_TENANT?.trim() || undefined);
|
|
60
114
|
if (explicit !== undefined)
|
|
61
|
-
|
|
62
|
-
let name = explicit ??
|
|
115
|
+
assertTenantName(explicit, options.name !== undefined ? "--tenant" : "NYLORUN_TENANT");
|
|
116
|
+
let name = explicit ?? link?.tenant;
|
|
63
117
|
let root;
|
|
64
118
|
const home = env.NYLORUN_HOME?.trim();
|
|
65
119
|
if (home) {
|
|
66
120
|
root = resolve(home);
|
|
67
121
|
name ??=
|
|
68
|
-
(await
|
|
69
|
-
|
|
122
|
+
(await readTenantRecord(root))?.name ??
|
|
123
|
+
sanitizeTenantName(basename(projectDir ?? root));
|
|
70
124
|
}
|
|
71
125
|
else {
|
|
72
126
|
if (name === undefined && options.start && projectDir)
|
|
73
|
-
name = await
|
|
127
|
+
name = await chooseTenantName(base, projectDir);
|
|
128
|
+
if (name === undefined && !projectDir)
|
|
129
|
+
name = DEFAULT_TENANT;
|
|
74
130
|
if (name === undefined)
|
|
75
|
-
throw usageError(
|
|
76
|
-
|
|
77
|
-
: `No stack selected: run this in a project "nylorun start" linked, pass --name <stack>, or set NYLORUN_STACK.${await stackNames(deps)}`);
|
|
78
|
-
root = stackRoot(base, name);
|
|
131
|
+
throw usageError(`No Tenant selected: run "nylorun start" in this project, pass --tenant <name>, or set NYLORUN_TENANT.${await tenantNames(deps)}`);
|
|
132
|
+
root = tenantRoot(base, name);
|
|
79
133
|
}
|
|
80
|
-
|
|
134
|
+
assertTenantName(name, "The Tenant name");
|
|
81
135
|
return {
|
|
82
136
|
deps,
|
|
83
137
|
name,
|
|
@@ -101,7 +155,7 @@ function composeArgs(ctx, ...args) {
|
|
|
101
155
|
}
|
|
102
156
|
function requireStackFiles(ctx) {
|
|
103
157
|
if (!existsSync(ctx.paths.compose) || !existsSync(ctx.paths.env))
|
|
104
|
-
throw new CliError(`No
|
|
158
|
+
throw new CliError(`No Tenant ${ctx.name} under ${ctx.paths.root}. Run "nylorun start" first.`, 3);
|
|
105
159
|
}
|
|
106
160
|
export function parseStackFlags(args, allowed, usage) {
|
|
107
161
|
const booleans = new Set();
|
|
@@ -137,9 +191,9 @@ export function parseStackFlags(args, allowed, usage) {
|
|
|
137
191
|
}
|
|
138
192
|
return { rest, booleans, values, lists };
|
|
139
193
|
}
|
|
140
|
-
/** `--
|
|
194
|
+
/** `--tenant` when given. */
|
|
141
195
|
function nameOption(flags) {
|
|
142
|
-
const name = flags.values.get("--
|
|
196
|
+
const name = flags.values.get("--tenant");
|
|
143
197
|
return name === undefined ? {} : { name };
|
|
144
198
|
}
|
|
145
199
|
async function sleep(ms) {
|
|
@@ -203,16 +257,16 @@ async function fetchTenant(deps, adminUrl, adminKey) {
|
|
|
203
257
|
return undefined;
|
|
204
258
|
}
|
|
205
259
|
}
|
|
206
|
-
/** Why the
|
|
260
|
+
/** Why the Tenant is unavailable, with the repair the Runtime names. */
|
|
207
261
|
function tenantCauseMessage(ctx, tenant) {
|
|
208
262
|
const cause = tenant.cause;
|
|
209
263
|
const hint = cause.code === "schema-too-new"
|
|
210
|
-
? ` This nylorun pins Runtime ${ctx.deps.runtimeVersion}, older than the
|
|
264
|
+
? ` This nylorun pins Runtime ${ctx.deps.runtimeVersion}, older than the Tenant's database: update nylorun (npx nylorun@latest start).`
|
|
211
265
|
: "";
|
|
212
|
-
return `
|
|
266
|
+
return `Tenant ${ctx.name} is unavailable (${cause.code}): ${cause.message} ${cause.repair}${hint} See "nylorun logs runtime".`;
|
|
213
267
|
}
|
|
214
268
|
/**
|
|
215
|
-
* Wait until the
|
|
269
|
+
* Wait until the Tenant is open: the Runtime creates it on the first start and opens
|
|
216
270
|
* it on later ones. A Tenant that could not be opened has a cause; report it.
|
|
217
271
|
*/
|
|
218
272
|
async function waitForTenant(ctx, adminUrl, adminKey) {
|
|
@@ -224,7 +278,7 @@ async function waitForTenant(ctx, adminUrl, adminKey) {
|
|
|
224
278
|
if (tenant?.cause)
|
|
225
279
|
throw new CliError(tenantCauseMessage(ctx, tenant), 7);
|
|
226
280
|
if (Date.now() > deadline)
|
|
227
|
-
throw new CliError(`
|
|
281
|
+
throw new CliError(`Tenant ${ctx.name} did not open (${adminUrl}/v1/admin/status: ${tenant ? tenant.state : "no answer"}). See "nylorun status" and "nylorun logs runtime".`, 7);
|
|
228
282
|
await sleep(ctx.deps.pollMs ?? 500);
|
|
229
283
|
}
|
|
230
284
|
}
|
|
@@ -267,7 +321,7 @@ function isOlder(version, than) {
|
|
|
267
321
|
compareVersions(version, than) < 0);
|
|
268
322
|
}
|
|
269
323
|
/**
|
|
270
|
-
* Refuse a Runtime older than the
|
|
324
|
+
* Refuse a Runtime older than the Tenant's database: the Runtime that last ran the Tenant
|
|
271
325
|
* (host.json `runtimeVersion`) migrated it, and an older one cannot open its Tenant
|
|
272
326
|
* (`schema-too-new`). Checked before Compose replaces anything. Skipped when
|
|
273
327
|
* `NYLORUN_RUNTIME_IMAGE` names the image.
|
|
@@ -278,21 +332,16 @@ async function refuseDowngrade(ctx, allowDowngrade) {
|
|
|
278
332
|
if (!isOlder(pinned, host?.runtimeVersion))
|
|
279
333
|
return;
|
|
280
334
|
if (allowDowngrade) {
|
|
281
|
-
ctx.deps.err(`Warning: starting Runtime ${pinned} on
|
|
335
|
+
ctx.deps.err(`Warning: starting Runtime ${pinned} on Tenant ${ctx.name}, which Runtime ${host.runtimeVersion} last ran. If that Runtime migrated the database, the Tenant stays unavailable (schema-too-new).`);
|
|
282
336
|
return;
|
|
283
337
|
}
|
|
284
|
-
throw new CliError(`Refusing to start Runtime ${pinned} on
|
|
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);
|
|
285
339
|
}
|
|
286
|
-
/** Ports
|
|
287
|
-
async function
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
.
|
|
291
|
-
.map((stack) => stackPaths(stack.root));
|
|
292
|
-
// The legacy stack keeps its .env in stack/, not docker/.
|
|
293
|
-
const legacy = legacyStack(base);
|
|
294
|
-
if (legacy && resolve(base) !== ctx.paths.root)
|
|
295
|
-
others.push(legacy);
|
|
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)
|
|
344
|
+
.map((tenant) => stackPaths(tenant.root));
|
|
296
345
|
const reserved = new Set();
|
|
297
346
|
for (const paths of others) {
|
|
298
347
|
const persisted = await readStackEnv(paths);
|
|
@@ -307,12 +356,31 @@ async function reservedPorts(ctx) {
|
|
|
307
356
|
}
|
|
308
357
|
return reserved;
|
|
309
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
|
+
}
|
|
310
368
|
async function bringUp(ctx, options) {
|
|
311
369
|
const { deps } = ctx;
|
|
312
370
|
await refuseLauncherRuntime(ctx);
|
|
313
371
|
const overridden = runtimeImageOverridden(deps.env);
|
|
314
372
|
if (!overridden)
|
|
315
373
|
await refuseDowngrade(ctx, options.allowDowngrade ?? false);
|
|
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);
|
|
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;
|
|
316
384
|
const prepared = await prepareStack({
|
|
317
385
|
paths: ctx.paths,
|
|
318
386
|
name: ctx.name,
|
|
@@ -331,13 +399,19 @@ async function bringUp(ctx, options) {
|
|
|
331
399
|
? { derivedPrincipals: deps.env.NYLORUN_DERIVED_PRINCIPALS }
|
|
332
400
|
: {}),
|
|
333
401
|
...(options.studioEmbedOrigins ? { studioEmbedOrigins: options.studioEmbedOrigins } : {}),
|
|
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) {
|
|
406
|
+
deps.err(TELEMETRY_NOTICE);
|
|
407
|
+
await writeTelemetry(nylorunRoot(deps), { ...telemetry, noticeShown: new Date().toISOString() });
|
|
408
|
+
}
|
|
409
|
+
const record = await readTenantRecord(ctx.paths.root);
|
|
336
410
|
const project = record?.project ?? ctx.projectDir;
|
|
337
411
|
if (record?.name !== ctx.name || record.project !== project)
|
|
338
|
-
await
|
|
412
|
+
await writeTenantRecord(ctx.paths.root, { name: ctx.name, ...(project ? { project } : {}) });
|
|
339
413
|
if (prepared.firstRun)
|
|
340
|
-
deps.err(`Created
|
|
414
|
+
deps.err(`Created Tenant ${ctx.name} under ${ctx.paths.root} (Runtime port ${prepared.env.runtimePort}, Studio port ${prepared.env.studioPort}).`);
|
|
341
415
|
const runtimeUrl = `http://${STACK_CLIENT_HOST}:${prepared.env.runtimePort}`;
|
|
342
416
|
const adminUrl = `http://${STACK_CLIENT_HOST}:${prepared.env.adminPort}`;
|
|
343
417
|
const up = await deps.docker.stream(composeArgs(ctx, "up", "--detach", "--wait", "--wait-timeout", "300", ...CORE_SERVICES));
|
|
@@ -356,30 +430,59 @@ async function bringUp(ctx, options) {
|
|
|
356
430
|
if (!studioStarted)
|
|
357
431
|
deps.err(`Warning: Studio did not start (image ${prepared.env.studioImage}). The Runtime is up; see "nylorun logs studio".`);
|
|
358
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
|
+
}
|
|
359
446
|
return {
|
|
360
447
|
runtimeUrl,
|
|
361
448
|
adminUrl,
|
|
362
449
|
hostId: prepared.host.hostId,
|
|
363
450
|
studioPort: prepared.env.studioPort,
|
|
451
|
+
studioUrl,
|
|
364
452
|
studioStarted,
|
|
365
453
|
adminKey: prepared.adminKey,
|
|
366
454
|
};
|
|
367
455
|
}
|
|
368
|
-
|
|
369
|
-
|
|
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);
|
|
370
473
|
let lastError = "";
|
|
371
474
|
for (;;) {
|
|
372
475
|
try {
|
|
373
|
-
return await mintStudioLogin({ fetch:
|
|
476
|
+
return await mintStudioLogin({ fetch: deps.fetch, origin, adminKey });
|
|
374
477
|
}
|
|
375
478
|
catch (error) {
|
|
376
|
-
lastError =
|
|
479
|
+
lastError = errorText(error);
|
|
377
480
|
}
|
|
378
481
|
if (Date.now() > deadline)
|
|
379
482
|
break;
|
|
380
|
-
await sleep(
|
|
483
|
+
await sleep(deps.pollMs ?? 500);
|
|
381
484
|
}
|
|
382
|
-
|
|
485
|
+
deps.err(`Warning: Studio at ${origin} is not reachable for a login (${lastError}).`);
|
|
383
486
|
return undefined;
|
|
384
487
|
}
|
|
385
488
|
/** Printed by `start` when it does not open Studio itself. */
|
|
@@ -396,36 +499,23 @@ async function openLogin(ctx, login) {
|
|
|
396
499
|
if (!(await ctx.deps.openBrowser(login)))
|
|
397
500
|
ctx.deps.out(`Sign in ${login}`);
|
|
398
501
|
}
|
|
399
|
-
/** The legacy stack is mentioned once per machine (a marker under `stacks/`). */
|
|
400
|
-
async function noteLegacy(deps) {
|
|
401
|
-
const base = nylorunRoot(deps);
|
|
402
|
-
const legacy = legacyStack(base);
|
|
403
|
-
const marker = join(stacksDir(base), ".legacy-noted");
|
|
404
|
-
if (!legacy || existsSync(marker))
|
|
405
|
-
return;
|
|
406
|
-
deps.err(`Note: the single stack of an older release is still on this machine (Compose project nylorun, Host root ${legacy.root}). Stacks are now one per project, and the old one is left as it is: stop it with "npx nylorun legacy stop", or remove it and its Tenants with "npx nylorun legacy delete --yes".`);
|
|
407
|
-
await mkdir(stacksDir(base), { recursive: true, mode: 0o700 });
|
|
408
|
-
await writeFile(marker, "", { mode: 0o600 });
|
|
409
|
-
}
|
|
410
502
|
/**
|
|
411
|
-
* Link the project to the
|
|
503
|
+
* Link the project to the Tenant: `.nylorun/link.json` (format 3) and
|
|
412
504
|
* `.nylorun/credentials.json` with the key of the derived principal `project`. The link is
|
|
413
|
-
* rewritten only when the
|
|
414
|
-
* Tenant from the project's `.env`.
|
|
505
|
+
* rewritten only when it is older, or the Tenant's name, URL, Host or id changed; a new link
|
|
506
|
+
* seeds the Tenant from the project's `.env`.
|
|
415
507
|
*/
|
|
416
508
|
async function linkProject(ctx, started, tenantId) {
|
|
417
509
|
const { deps, projectDir, link } = ctx;
|
|
418
|
-
if (link && link.format < 2)
|
|
419
|
-
deps.err(`This project was linked to a Tenant on ${link.hostUrl}, a stack of an older Runtime. It gets its own stack, ${ctx.name}, and a new link; model credentials come from .env (or Studio), and agents register again (npm run dev).`);
|
|
420
510
|
const applicationKey = deriveTenantKey(started.adminKey, tenantId, PROJECT_PRINCIPAL_ID);
|
|
421
|
-
const fresh = link?.format !==
|
|
422
|
-
link.
|
|
511
|
+
const fresh = link?.format !== 3 ||
|
|
512
|
+
link.tenant !== ctx.name ||
|
|
423
513
|
link.hostUrl !== started.runtimeUrl ||
|
|
424
514
|
link.hostId !== started.hostId ||
|
|
425
515
|
link.tenantId !== tenantId;
|
|
426
516
|
if (fresh)
|
|
427
517
|
await writeProjectLink(projectDir, {
|
|
428
|
-
|
|
518
|
+
tenant: ctx.name,
|
|
429
519
|
hostUrl: started.runtimeUrl,
|
|
430
520
|
hostId: started.hostId,
|
|
431
521
|
tenantId,
|
|
@@ -440,7 +530,7 @@ async function linkProject(ctx, started, tenantId) {
|
|
|
440
530
|
});
|
|
441
531
|
if (!fresh)
|
|
442
532
|
return;
|
|
443
|
-
deps.err(`Linked ${projectDir} to
|
|
533
|
+
deps.err(`Linked ${projectDir} to Tenant ${ctx.name} (.nylorun/link.json, .nylorun/credentials.json).`);
|
|
444
534
|
try {
|
|
445
535
|
const seeded = await seedTenant({
|
|
446
536
|
fetch: deps.fetch,
|
|
@@ -456,7 +546,7 @@ async function linkProject(ctx, started, tenantId) {
|
|
|
456
546
|
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".`);
|
|
457
547
|
}
|
|
458
548
|
}
|
|
459
|
-
const START_USAGE = "nylorun start [--
|
|
549
|
+
const START_USAGE = "nylorun start [--tenant <name>] [--no-link] [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset]";
|
|
460
550
|
async function start(deps, args) {
|
|
461
551
|
const flags = parseStackFlags(args, {
|
|
462
552
|
booleans: [
|
|
@@ -466,7 +556,7 @@ async function start(deps, args) {
|
|
|
466
556
|
"--allow-downgrade",
|
|
467
557
|
"--studio-embed-origin-reset",
|
|
468
558
|
],
|
|
469
|
-
values: ["--
|
|
559
|
+
values: ["--tenant"],
|
|
470
560
|
lists: ["--studio-embed-origin"],
|
|
471
561
|
}, START_USAGE);
|
|
472
562
|
if (flags.rest.length)
|
|
@@ -477,7 +567,6 @@ async function start(deps, args) {
|
|
|
477
567
|
noLink: flags.booleans.has("--no-link"),
|
|
478
568
|
});
|
|
479
569
|
await dockerPreflight(deps.docker);
|
|
480
|
-
await noteLegacy(deps);
|
|
481
570
|
const embedOrigins = flags.lists.get("--studio-embed-origin") ?? [];
|
|
482
571
|
const resetEmbed = flags.booleans.has("--studio-embed-origin-reset");
|
|
483
572
|
const started = await bringUp(ctx, {
|
|
@@ -490,35 +579,86 @@ async function start(deps, args) {
|
|
|
490
579
|
const tenant = await waitForTenant(ctx, started.adminUrl, started.adminKey);
|
|
491
580
|
if (ctx.projectDir)
|
|
492
581
|
await linkProject({ ...ctx, projectDir: ctx.projectDir }, started, tenant.id);
|
|
493
|
-
deps.out(`
|
|
494
|
-
deps.out(`Tenant ${tenant.id}${tenant.name ? ` (${tenant.name})` : ""}`);
|
|
582
|
+
deps.out(`Tenant ${ctx.name} (${tenant.id})`);
|
|
495
583
|
deps.out(`Runtime ${started.runtimeUrl}`);
|
|
496
584
|
if (started.studioStarted) {
|
|
497
|
-
deps.out(`Studio ${
|
|
585
|
+
deps.out(`Studio ${started.studioUrl}`);
|
|
498
586
|
if (opensBrowser(deps, flags)) {
|
|
499
|
-
const login = await tryStudioLogin(ctx, started.studioPort, started.adminKey);
|
|
587
|
+
const login = await tryStudioLogin(ctx, { port: started.studioPort, url: started.studioUrl }, started.adminKey);
|
|
500
588
|
if (login)
|
|
501
589
|
await openLogin(ctx, withNext(login, tenantStudioPath(tenant.id)));
|
|
502
590
|
}
|
|
503
591
|
else
|
|
504
592
|
deps.err(STUDIO_SIGN_IN_HINT);
|
|
505
593
|
}
|
|
594
|
+
await alsoRunning(ctx);
|
|
506
595
|
return 0;
|
|
507
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
|
+
}
|
|
508
615
|
async function stop(deps, args) {
|
|
509
|
-
const usage = "nylorun stop [--
|
|
510
|
-
const flags = parseStackFlags(args, { values: ["--
|
|
616
|
+
const usage = "nylorun stop [--tenant <name> | --all]";
|
|
617
|
+
const flags = parseStackFlags(args, { booleans: ["--all"], values: ["--tenant"] }, usage);
|
|
511
618
|
if (flags.rest.length)
|
|
512
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
|
+
}
|
|
513
625
|
const ctx = await selectStack(deps, nameOption(flags));
|
|
514
626
|
requireStackFiles(ctx);
|
|
515
627
|
await dockerPreflight(deps.docker);
|
|
516
628
|
const code = await deps.docker.stream(composeArgs(ctx, "stop"));
|
|
517
629
|
if (code !== 0)
|
|
518
630
|
throw new CliError(`docker compose stop failed (exit ${code}).`, 1);
|
|
519
|
-
deps.out(`Stopped
|
|
631
|
+
deps.out(`Stopped Tenant ${ctx.name}; its data is kept.`);
|
|
520
632
|
return 0;
|
|
521
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
|
+
}
|
|
522
662
|
async function stackStatus(ctx) {
|
|
523
663
|
const base = {
|
|
524
664
|
name: ctx.name,
|
|
@@ -542,7 +682,7 @@ async function stackStatus(ctx) {
|
|
|
542
682
|
const health = runtimeUrl ? await fetchHealth(ctx.deps, runtimeUrl) : undefined;
|
|
543
683
|
const host = await readHostConfig(ctx.paths);
|
|
544
684
|
const healthy = health?.status === "ok" && (host === undefined || health.hostId === host.hostId);
|
|
545
|
-
// The Admin API answers on the operator port; an older
|
|
685
|
+
// The Admin API answers on the operator port; an older Host root has only the Runtime port.
|
|
546
686
|
const adminUrl = persisted.adminPort
|
|
547
687
|
? `http://${STACK_CLIENT_HOST}:${persisted.adminPort}`
|
|
548
688
|
: runtimeUrl;
|
|
@@ -563,7 +703,7 @@ async function stackStatus(ctx) {
|
|
|
563
703
|
},
|
|
564
704
|
...(tenant ? { tenant } : {}),
|
|
565
705
|
studio: {
|
|
566
|
-
...(persisted.studioPort ?
|
|
706
|
+
...(persisted.studioPort ? await studioUrls(ctx, persisted.studioPort) : {}),
|
|
567
707
|
state: studio ? [studio.state, studio.health].filter(Boolean).join(", ") : "absent",
|
|
568
708
|
...(persisted.studioFrameAncestors
|
|
569
709
|
? { embedOrigins: persisted.studioFrameAncestors }
|
|
@@ -579,8 +719,13 @@ async function stackStatus(ctx) {
|
|
|
579
719
|
services,
|
|
580
720
|
};
|
|
581
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
|
+
}
|
|
582
726
|
/** What `nylorun status` reports, as data (`nylorun doctor`). */
|
|
583
727
|
export async function readStackStatus(deps, options = {}) {
|
|
728
|
+
await moveStackRoots(nylorunRoot(deps), deps.err);
|
|
584
729
|
return await stackStatus(await selectStack(deps, options));
|
|
585
730
|
}
|
|
586
731
|
function describeTenant(tenant) {
|
|
@@ -592,8 +737,8 @@ function describeTenant(tenant) {
|
|
|
592
737
|
: `${who} unavailable (opening)`;
|
|
593
738
|
}
|
|
594
739
|
async function status(deps, args) {
|
|
595
|
-
const usage = "nylorun status [--
|
|
596
|
-
const flags = parseStackFlags(args, { booleans: ["--json"], values: ["--
|
|
740
|
+
const usage = "nylorun status [--tenant <name>] [--json]";
|
|
741
|
+
const flags = parseStackFlags(args, { booleans: ["--json"], values: ["--tenant"] }, usage);
|
|
597
742
|
if (flags.rest.length)
|
|
598
743
|
throw usageError(`Usage: ${usage}`);
|
|
599
744
|
const result = await stackStatus(await selectStack(deps, nameOption(flags)));
|
|
@@ -602,20 +747,21 @@ async function status(deps, args) {
|
|
|
602
747
|
out(JSON.stringify(result, null, 2));
|
|
603
748
|
}
|
|
604
749
|
else if (result.state === "absent") {
|
|
605
|
-
out(`
|
|
750
|
+
out(`Tenant ${result.name} absent (nothing under ${result.home}; run "nylorun start")`);
|
|
606
751
|
}
|
|
607
752
|
else {
|
|
608
|
-
out(`
|
|
753
|
+
out(`Tenant ${result.name} ${result.state} (Compose project ${result.project})`);
|
|
609
754
|
out(`Host root ${result.home} (admin key in host-credentials.json, mode 0600)`);
|
|
610
755
|
if (result.tenant)
|
|
611
|
-
out(`Tenant
|
|
756
|
+
out(`Tenant id ${describeTenant(result.tenant)}`);
|
|
612
757
|
const runtimeDetail = result.runtime.healthy
|
|
613
758
|
? `healthy, ${result.runtime.version ?? "?"}, ${result.runtime.hostId ?? "?"}`
|
|
614
759
|
: "not answering";
|
|
615
760
|
out(`Runtime ${result.runtime.url ?? "?"} ${runtimeDetail}`);
|
|
616
761
|
if (result.runtime.adminUrl)
|
|
617
762
|
out(`Admin API ${result.runtime.adminUrl} (operators only, never proxied)`);
|
|
618
|
-
|
|
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")`);
|
|
619
765
|
if (result.studio.embedOrigins?.length)
|
|
620
766
|
out(`Embeds ${result.studio.embedOrigins.join(" ")} (may show Studio in a frame)`);
|
|
621
767
|
out(`Gateway ${result.gateway.state} (the Model Gate; model calls fail while it is down)`);
|
|
@@ -632,8 +778,8 @@ async function status(deps, args) {
|
|
|
632
778
|
return result.runtime.healthy ? 0 : 3;
|
|
633
779
|
}
|
|
634
780
|
async function logs(deps, args) {
|
|
635
|
-
const usage = `nylorun logs [${STACK_SERVICES.join("|")}] [--
|
|
636
|
-
const flags = parseStackFlags(args, { booleans: ["--follow"], values: ["--tail", "--
|
|
781
|
+
const usage = `nylorun logs [${STACK_SERVICES.join("|")}] [--tenant <name>] [-f] [--tail <n>]`;
|
|
782
|
+
const flags = parseStackFlags(args, { booleans: ["--follow"], values: ["--tail", "--tenant"], aliases: { "-f": "--follow" } }, usage);
|
|
637
783
|
if (flags.rest.length > 1)
|
|
638
784
|
throw usageError(`Usage: ${usage}`);
|
|
639
785
|
const service = flags.rest[0];
|
|
@@ -648,16 +794,16 @@ async function logs(deps, args) {
|
|
|
648
794
|
return await deps.docker.stream(composeArgs(ctx, "logs", ...(flags.booleans.has("--follow") ? ["--follow"] : []), ...(tail !== undefined ? ["--tail", tail] : []), ...(service ? [service] : [])));
|
|
649
795
|
}
|
|
650
796
|
async function reset(deps, args) {
|
|
651
|
-
const usage = "nylorun reset [--
|
|
652
|
-
const flags = parseStackFlags(args, { booleans: ["--yes"], values: ["--
|
|
797
|
+
const usage = "nylorun reset [--tenant <name>] [--yes]";
|
|
798
|
+
const flags = parseStackFlags(args, { booleans: ["--yes"], values: ["--tenant"], aliases: { "-y": "--yes" } }, usage);
|
|
653
799
|
if (flags.rest.length)
|
|
654
800
|
throw usageError(`Usage: ${usage}`);
|
|
655
801
|
const ctx = await selectStack(deps, nameOption(flags));
|
|
656
802
|
const { paths } = ctx;
|
|
657
803
|
if (!flags.booleans.has("--yes")) {
|
|
658
|
-
const question = `Delete
|
|
804
|
+
const question = `Delete Tenant ${ctx.name}'s volumes (Compose project ${ctx.project}), its Tenant directory ${paths.tenant} and its vault key ${paths.vaultKey}? Its data is lost, and the next start creates the Tenant anew, with a new id. [y/N] `;
|
|
659
805
|
if (!deps.confirm)
|
|
660
|
-
throw usageError(`nylorun reset deletes
|
|
806
|
+
throw usageError(`nylorun reset deletes all of Tenant ${ctx.name}'s data; pass --yes to confirm when not in a terminal.`);
|
|
661
807
|
if (!(await deps.confirm(question))) {
|
|
662
808
|
deps.err("Reset cancelled.");
|
|
663
809
|
return 1;
|
|
@@ -665,15 +811,13 @@ async function reset(deps, args) {
|
|
|
665
811
|
}
|
|
666
812
|
if (existsSync(paths.compose) && existsSync(paths.env)) {
|
|
667
813
|
await dockerPreflight(deps.docker);
|
|
668
|
-
|
|
669
|
-
if (code !== 0)
|
|
670
|
-
throw new CliError(`docker compose down failed (exit ${code}).`, 1);
|
|
814
|
+
await composeDown(ctx, composeArgs(ctx, "down", "--volumes", "--remove-orphans"));
|
|
671
815
|
}
|
|
672
816
|
await rm(paths.tenant, { recursive: true, force: true });
|
|
673
817
|
await mkdir(paths.tenant, { recursive: true, mode: 0o700 });
|
|
674
818
|
// The vault key goes with the Tenant's data; the next start writes a new one.
|
|
675
819
|
await rm(paths.keys, { recursive: true, force: true });
|
|
676
|
-
deps.out(`Reset
|
|
820
|
+
deps.out(`Reset Tenant ${ctx.name}: its volumes, Tenant directory and vault key deleted. Run "nylorun start" to create it anew (it relinks the project).`);
|
|
677
821
|
return 0;
|
|
678
822
|
}
|
|
679
823
|
/** Compose projects on this Docker engine and whether any container runs; undefined without Docker. */
|
|
@@ -701,18 +845,30 @@ async function ls(deps, args) {
|
|
|
701
845
|
const base = nylorunRoot(deps);
|
|
702
846
|
const projects = await composeProjects(deps.docker);
|
|
703
847
|
const state = (project) => (projects ? (projects.get(project) ?? "stopped") : "unknown");
|
|
704
|
-
const
|
|
705
|
-
|
|
848
|
+
const memory = [...(projects?.values() ?? [])].includes("running")
|
|
849
|
+
? await tenantMemory(deps.docker)
|
|
850
|
+
: undefined;
|
|
851
|
+
const proxy = proxyDisabled(deps.env) ? undefined : await readProxySettings(base);
|
|
852
|
+
const tenants = [];
|
|
853
|
+
for (const entry of await listTenants(base)) {
|
|
706
854
|
const persisted = (await readStackEnv(stackPaths(entry.root))) ?? {};
|
|
707
|
-
|
|
855
|
+
const tenantState = state(`nylorun-${entry.name}`);
|
|
856
|
+
tenants.push({
|
|
708
857
|
name: entry.name,
|
|
709
858
|
root: entry.root,
|
|
710
|
-
...(entry.record
|
|
711
|
-
state:
|
|
859
|
+
...(entry.record.project ? { project: entry.record.project } : {}),
|
|
860
|
+
state: tenantState,
|
|
861
|
+
memoryBytes: (tenantState === "running" && memory?.get(entry.name)) || null,
|
|
712
862
|
...(persisted.runtimePort
|
|
713
863
|
? { runtimeUrl: `http://${STACK_CLIENT_HOST}:${persisted.runtimePort}` }
|
|
714
864
|
: {}),
|
|
715
|
-
...(persisted.studioPort
|
|
865
|
+
...(persisted.studioPort
|
|
866
|
+
? {
|
|
867
|
+
studioUrl: proxy
|
|
868
|
+
? proxyOrigin(entry.name, proxy.port)
|
|
869
|
+
: studioOrigin(persisted.studioPort),
|
|
870
|
+
}
|
|
871
|
+
: {}),
|
|
716
872
|
ports: {
|
|
717
873
|
...(persisted.runtimePort ? { runtime: persisted.runtimePort } : {}),
|
|
718
874
|
...(persisted.adminPort ? { admin: persisted.adminPort } : {}),
|
|
@@ -721,96 +877,54 @@ async function ls(deps, args) {
|
|
|
721
877
|
},
|
|
722
878
|
});
|
|
723
879
|
}
|
|
724
|
-
const legacy = legacyStack(base);
|
|
725
|
-
const legacyView = legacy
|
|
726
|
-
? { root: legacy.root, project: legacy.project, state: state(legacy.project) }
|
|
727
|
-
: undefined;
|
|
728
880
|
if (flags.booleans.has("--json")) {
|
|
729
|
-
deps.out(JSON.stringify({
|
|
881
|
+
deps.out(JSON.stringify({ tenants }, null, 2));
|
|
730
882
|
return 0;
|
|
731
883
|
}
|
|
732
|
-
if (
|
|
733
|
-
deps.out(
|
|
734
|
-
|
|
735
|
-
const width = Math.max(4, ...stacks.map((stack) => stack.name.length)) + 2;
|
|
736
|
-
deps.out(`${"NAME".padEnd(width)}${"STATE".padEnd(9)}${"RUNTIME".padEnd(24)}${"STUDIO".padEnd(24)}PROJECT`);
|
|
737
|
-
for (const stack of stacks)
|
|
738
|
-
deps.out(`${stack.name.padEnd(width)}${stack.state.padEnd(9)}${(stack.runtimeUrl ?? "-").padEnd(24)}${(stack.studioUrl ?? "-").padEnd(24)}${stack.project ?? "-"}`);
|
|
884
|
+
if (tenants.length === 0) {
|
|
885
|
+
deps.out('No Tenants on this machine. Run "npx nylorun start" in a project (or anywhere, for the default Tenant).');
|
|
886
|
+
return 0;
|
|
739
887
|
}
|
|
740
|
-
|
|
741
|
-
|
|
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(""));
|
|
742
902
|
return 0;
|
|
743
903
|
}
|
|
744
904
|
async function deleteStack(deps, args) {
|
|
745
|
-
const usage = "nylorun delete <
|
|
905
|
+
const usage = "nylorun delete <tenant> --yes";
|
|
746
906
|
const flags = parseStackFlags(args, { booleans: ["--yes"], aliases: { "-y": "--yes" } }, usage);
|
|
747
907
|
const [name, ...extra] = flags.rest;
|
|
748
908
|
if (name === undefined || extra.length)
|
|
749
909
|
throw usageError(`Usage: ${usage}`);
|
|
750
|
-
|
|
910
|
+
assertTenantName(name, "The Tenant name");
|
|
751
911
|
const base = nylorunRoot(deps);
|
|
752
|
-
const ctx = { deps, project: stackProject(deps.env, name), paths: stackPaths(
|
|
912
|
+
const ctx = { deps, project: stackProject(deps.env, name), paths: stackPaths(tenantRoot(base, name)) };
|
|
753
913
|
if (!existsSync(ctx.paths.root))
|
|
754
|
-
throw new CliError(`No
|
|
914
|
+
throw new CliError(`No Tenant ${name} under ${tenantsDir(base)}. See "nylorun ls".`, 3);
|
|
755
915
|
if (!flags.booleans.has("--yes"))
|
|
756
|
-
throw usageError(`nylorun delete removes
|
|
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.`);
|
|
757
917
|
await dockerPreflight(deps.docker);
|
|
758
918
|
const files = existsSync(ctx.paths.compose) && existsSync(ctx.paths.env);
|
|
759
|
-
|
|
919
|
+
await composeDown(ctx, files
|
|
760
920
|
? composeArgs(ctx, "down", "--volumes", "--remove-orphans")
|
|
761
921
|
: ["compose", "--project-name", ctx.project, "down", "--volumes", "--remove-orphans"]);
|
|
762
|
-
if (code !== 0)
|
|
763
|
-
throw new CliError(`docker compose down failed (exit ${code}).`, 1);
|
|
764
922
|
await rm(ctx.paths.root, { recursive: true, force: true });
|
|
765
|
-
deps.
|
|
766
|
-
|
|
767
|
-
}
|
|
768
|
-
function legacyCompose(legacy, ...args) {
|
|
769
|
-
return [
|
|
770
|
-
"compose",
|
|
771
|
-
"--project-name",
|
|
772
|
-
legacy.project,
|
|
773
|
-
"--file",
|
|
774
|
-
legacy.compose,
|
|
775
|
-
"--env-file",
|
|
776
|
-
legacy.env,
|
|
777
|
-
...args,
|
|
778
|
-
];
|
|
779
|
-
}
|
|
780
|
-
async function legacy(deps, args) {
|
|
781
|
-
const usage = "nylorun legacy stop|delete [--yes]";
|
|
782
|
-
const flags = parseStackFlags(args, { booleans: ["--yes"], aliases: { "-y": "--yes" } }, usage);
|
|
783
|
-
const [action, ...extra] = flags.rest;
|
|
784
|
-
if ((action !== "stop" && action !== "delete") || extra.length)
|
|
785
|
-
throw usageError(`Usage: ${usage}`);
|
|
786
|
-
if (action === "stop" && flags.booleans.has("--yes"))
|
|
787
|
-
throw usageError(`Usage: ${usage}`);
|
|
788
|
-
const base = nylorunRoot(deps);
|
|
789
|
-
const stack = legacyStack(base);
|
|
790
|
-
if (!stack) {
|
|
791
|
-
deps.out(`No legacy stack under ${base} (no stack/compose.yaml).`);
|
|
792
|
-
return 0;
|
|
793
|
-
}
|
|
794
|
-
if (action === "stop") {
|
|
795
|
-
await dockerPreflight(deps.docker);
|
|
796
|
-
const code = await deps.docker.stream(legacyCompose(stack, "stop"));
|
|
797
|
-
if (code !== 0)
|
|
798
|
-
throw new CliError(`docker compose stop failed (exit ${code}).`, 1);
|
|
799
|
-
deps.out("Stopped the legacy stack (Compose project nylorun); its volumes are kept.");
|
|
800
|
-
return 0;
|
|
801
|
-
}
|
|
802
|
-
if (!flags.booleans.has("--yes"))
|
|
803
|
-
throw usageError(`nylorun legacy delete removes the legacy stack (Compose project nylorun): its containers, volumes, every Tenant on it with their vault keys, and its files under ${base} (${LEGACY_ENTRIES.join(", ")}). ${stacksDir(base)} is kept. Pass --yes to confirm.`);
|
|
804
|
-
await dockerPreflight(deps.docker);
|
|
805
|
-
const code = await deps.docker.stream(legacyCompose(stack, "down", "--volumes", "--remove-orphans"));
|
|
806
|
-
if (code !== 0)
|
|
807
|
-
throw new CliError(`docker compose down failed (exit ${code}).`, 1);
|
|
808
|
-
for (const entry of LEGACY_ENTRIES)
|
|
809
|
-
await rm(join(base, entry), { recursive: true, force: true });
|
|
810
|
-
deps.out(`Deleted the legacy stack: its containers, volumes and files under ${base}; ${stacksDir(base)} is kept.`);
|
|
923
|
+
await refreshProxyRoutes(deps.docker, base, await proxyRoutes(deps));
|
|
924
|
+
deps.out(`Deleted Tenant ${name}: its containers, volumes and Host root (vault key included).`);
|
|
811
925
|
return 0;
|
|
812
926
|
}
|
|
813
|
-
/** The
|
|
927
|
+
/** The Tenant when the Runtime (and Studio, if wanted) already answer; otherwise undefined. */
|
|
814
928
|
async function runningStack(ctx, options) {
|
|
815
929
|
if (!existsSync(ctx.paths.compose) || !existsSync(ctx.paths.env))
|
|
816
930
|
return undefined;
|
|
@@ -841,6 +955,7 @@ async function runningStack(ctx, options) {
|
|
|
841
955
|
hostId: host.hostId,
|
|
842
956
|
adminKey,
|
|
843
957
|
studioPort: persisted.studioPort,
|
|
958
|
+
studioUrl: await studioUrl(ctx, persisted.studioPort),
|
|
844
959
|
studioUp,
|
|
845
960
|
started: false,
|
|
846
961
|
};
|
|
@@ -860,12 +975,13 @@ async function ensureSelected(ctx, options) {
|
|
|
860
975
|
hostId: started.hostId,
|
|
861
976
|
adminKey: started.adminKey,
|
|
862
977
|
studioPort: started.studioPort,
|
|
978
|
+
studioUrl: started.studioUrl,
|
|
863
979
|
studioUp: started.studioStarted,
|
|
864
980
|
started: !runtimeWasUp,
|
|
865
981
|
};
|
|
866
982
|
}
|
|
867
983
|
/**
|
|
868
|
-
* Start the selected
|
|
984
|
+
* Start the selected Tenant unless it is already running (`nylorun studio`): the
|
|
869
985
|
* `start` code path without its output, its Tenant wait or the Project link. Compose
|
|
870
986
|
* progress still streams, since the first run pulls images.
|
|
871
987
|
*/
|
|
@@ -880,52 +996,54 @@ export function withNext(loginUrl, next) {
|
|
|
880
996
|
url.searchParams.set("next", next);
|
|
881
997
|
return url.toString();
|
|
882
998
|
}
|
|
883
|
-
/** The Studio page
|
|
999
|
+
/** The Tenant's Studio page, as a login `next` path. */
|
|
884
1000
|
export function tenantStudioPath(tenantId) {
|
|
885
1001
|
return `/tenants/${encodeURIComponent(tenantId)}`;
|
|
886
1002
|
}
|
|
887
1003
|
/**
|
|
888
|
-
* Mint a fresh Studio login URL on a running
|
|
1004
|
+
* Mint a fresh Studio login URL on a running Tenant, landing on `next`.
|
|
889
1005
|
* Undefined (after a warning) when Studio does not answer.
|
|
890
1006
|
*/
|
|
891
|
-
export async function studioLoginUrl(deps,
|
|
892
|
-
const login = await tryStudioLogin({ deps },
|
|
1007
|
+
export async function studioLoginUrl(deps, running, next) {
|
|
1008
|
+
const login = await tryStudioLogin({ deps }, { port: running.studioPort, url: running.studioUrl }, running.adminKey);
|
|
893
1009
|
return login === undefined ? undefined : withNext(login, next);
|
|
894
1010
|
}
|
|
895
1011
|
async function studio(deps, args, options = {}) {
|
|
896
|
-
const usage = "nylorun studio [--
|
|
897
|
-
const flags = parseStackFlags(args, { booleans: ["--no-open"], values: ["--
|
|
1012
|
+
const usage = "nylorun studio [--tenant <name>] [--no-open]";
|
|
1013
|
+
const flags = parseStackFlags(args, { booleans: ["--no-open"], values: ["--tenant"] }, usage);
|
|
898
1014
|
if (flags.rest.length)
|
|
899
1015
|
throw usageError(`Usage: ${usage}`);
|
|
900
1016
|
const ctx = await selectStack(deps, nameOption(flags));
|
|
901
|
-
const
|
|
902
|
-
if (
|
|
903
|
-
deps.out(`Runtime ${
|
|
904
|
-
if (!
|
|
1017
|
+
const running = await ensureSelected(ctx, { studio: true });
|
|
1018
|
+
if (running.started)
|
|
1019
|
+
deps.out(`Runtime ${running.runtimeUrl}`);
|
|
1020
|
+
if (!running.studioUp)
|
|
905
1021
|
throw new CliError(`Studio did not start. See "nylorun logs studio".`, 7);
|
|
906
|
-
// Studio serves the
|
|
1022
|
+
// Studio serves the one Tenant; the link's Tenant id stands in while it is opening.
|
|
907
1023
|
let next = options.next;
|
|
908
1024
|
if (next === undefined) {
|
|
909
|
-
const tenant = await fetchTenant(deps,
|
|
910
|
-
const linked = ctx.link?.
|
|
1025
|
+
const tenant = await fetchTenant(deps, running.adminUrl, running.adminKey);
|
|
1026
|
+
const linked = ctx.link?.tenant === ctx.name ? ctx.link.tenantId : undefined;
|
|
911
1027
|
const tenantId = tenant?.id ?? linked;
|
|
912
1028
|
if (tenantId)
|
|
913
1029
|
next = tenantStudioPath(tenantId);
|
|
914
1030
|
}
|
|
915
|
-
const login = await studioLoginUrl(deps,
|
|
1031
|
+
const login = await studioLoginUrl(deps, running, next);
|
|
916
1032
|
if (!login)
|
|
917
1033
|
return 1;
|
|
918
1034
|
if (flags.booleans.has("--no-open")) {
|
|
919
1035
|
deps.out(`Studio ${login}`);
|
|
920
1036
|
return 0;
|
|
921
1037
|
}
|
|
922
|
-
|
|
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;
|
|
923
1040
|
deps.out(`Studio ${next ? new URL(next, origin).toString() : origin}`);
|
|
924
1041
|
await openLogin({ deps }, login);
|
|
925
1042
|
return 0;
|
|
926
1043
|
}
|
|
927
|
-
/** `nylorun studio`, landing on `next` when given, else on the
|
|
1044
|
+
/** `nylorun studio`, landing on `next` when given, else on the Tenant's page. */
|
|
928
1045
|
export async function runStudioCommand(args, deps, options = {}) {
|
|
1046
|
+
await moveStackRoots(nylorunRoot(deps), deps.err);
|
|
929
1047
|
return await studio(deps, args, options);
|
|
930
1048
|
}
|
|
931
1049
|
const COMMANDS = {
|
|
@@ -940,15 +1058,15 @@ const COMMANDS = {
|
|
|
940
1058
|
studio: (deps, args) => studio(deps, args),
|
|
941
1059
|
ls,
|
|
942
1060
|
delete: deleteStack,
|
|
943
|
-
legacy,
|
|
944
1061
|
};
|
|
945
1062
|
export function isStackCommand(name) {
|
|
946
1063
|
return name !== undefined && Object.hasOwn(COMMANDS, name);
|
|
947
1064
|
}
|
|
948
|
-
/** Run one
|
|
1065
|
+
/** Run one Tenant command (`up`/`start`, `down`/`stop`, `status`, `logs`, `reset`, `studio`, `ls`, `delete`). */
|
|
949
1066
|
export async function runStackCommand(name, args, deps) {
|
|
950
1067
|
const command = COMMANDS[name];
|
|
951
1068
|
if (!command)
|
|
952
|
-
throw usageError(`Unknown
|
|
1069
|
+
throw usageError(`Unknown command ${name}.\n${stackUsage}`);
|
|
1070
|
+
await moveStackRoots(nylorunRoot(deps), deps.err);
|
|
953
1071
|
return await command(deps, args);
|
|
954
1072
|
}
|