nylorun 0.3.0-beta → 0.5.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 +64 -0
- package/README.md +154 -68
- package/dist/cli.js +29 -25
- package/dist/doctor.d.ts +7 -5
- package/dist/doctor.js +58 -29
- package/dist/home.d.ts +1 -1
- package/dist/home.js +1 -1
- package/dist/project/derived-key.d.ts +8 -0
- package/dist/project/derived-key.js +18 -0
- package/dist/project/link.d.ts +32 -4
- package/dist/project/link.js +83 -14
- package/dist/project/seed.d.ts +27 -0
- package/dist/project/seed.js +111 -0
- package/dist/stack/commands.d.ts +42 -19
- package/dist/stack/commands.js +465 -152
- package/dist/stack/compose-file.d.ts +30 -11
- package/dist/stack/compose-file.js +88 -18
- package/dist/stack/docker.js +1 -1
- package/dist/stack/env-file.d.ts +24 -3
- package/dist/stack/env-file.js +49 -4
- package/dist/stack/host-files.d.ts +12 -3
- package/dist/stack/host-files.js +26 -3
- package/dist/stack/images.d.ts +1 -1
- package/dist/stack/images.js +1 -1
- package/dist/stack/index.d.ts +3 -3
- package/dist/stack/index.js +12 -3
- package/dist/stack/paths.d.ts +12 -4
- package/dist/stack/paths.js +9 -6
- package/dist/stack/ports.d.ts +2 -2
- package/dist/stack/ports.js +1 -1
- package/dist/stack/prepare.d.ts +13 -4
- package/dist/stack/prepare.js +25 -13
- package/dist/stack/restate-identity.d.ts +1 -1
- package/dist/stack/restate-identity.js +1 -1
- package/dist/stack/stacks.d.ts +50 -0
- package/dist/stack/stacks.js +150 -0
- 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,38 +1,92 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { readFile, rm
|
|
2
|
+
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { basename, resolve } from "node:path";
|
|
3
4
|
import { compareVersions, PROTOCOL_HEADER, PROTOCOL_VERSION, } from "@nylorun/core/compatibility";
|
|
4
5
|
import { CliError } from "../errors.js";
|
|
5
|
-
import {
|
|
6
|
+
import { deriveTenantKey, PROJECT_PRINCIPAL_ID } from "../project/derived-key.js";
|
|
7
|
+
import { readProjectCredentials, readProjectLink, writeProjectCredentials, writeProjectLink, } from "../project/link.js";
|
|
8
|
+
import { findProjectRoot } from "../project/root.js";
|
|
9
|
+
import { seedTenant } from "../project/seed.js";
|
|
10
|
+
import { readTelemetry, STUDIO_ANALYTICS_ID, TELEMETRY_NOTICE, telemetryDecision, writeTelemetry, } from "../telemetry.js";
|
|
6
11
|
import { dockerPreflight, parseComposePs, } from "./docker.js";
|
|
7
12
|
import { readAdminKey, readHostConfig, STACK_CLIENT_HOST } from "./host-files.js";
|
|
8
13
|
import { runtimeImageOverridden, stackImages } from "./images.js";
|
|
9
14
|
import { stackPaths } from "./paths.js";
|
|
10
15
|
import { prepareStack, readStackEnv } from "./prepare.js";
|
|
16
|
+
import { assertTenantName, chooseTenantName, DEFAULT_TENANT, defaultNylorunRoot, listTenants, moveStackRoots, readTenantRecord, sanitizeTenantName, tenantRoot, tenantsDir, writeTenantRecord, } from "./stacks.js";
|
|
11
17
|
import { mintStudioLogin, studioOrigin } from "./studio-login.js";
|
|
12
|
-
export const STACK_SERVICES = ["postgres", "restate", "s2", "runtime", "studio"];
|
|
13
|
-
const CORE_SERVICES = ["postgres", "restate", "s2", "runtime"];
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
down|stop
|
|
20
|
-
status [--
|
|
21
|
-
logs [service] [-f] [--tail <n>]
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
export const STACK_SERVICES = ["postgres", "restate", "s2", "gateway", "runtime", "studio"];
|
|
19
|
+
const CORE_SERVICES = ["postgres", "restate", "s2", "gateway", "runtime"];
|
|
20
|
+
export const stackUsage = ` up|start [--tenant <name>] [--no-link] [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset]
|
|
21
|
+
start the project's Tenant, creating it and the Project link (.nylorun/link.json,
|
|
22
|
+
credentials.json) on the first run; print the Runtime and Studio URLs and open Studio
|
|
23
|
+
signed in (in a terminal). --tenant attaches to (or creates) a named Tenant; --no-link
|
|
24
|
+
starts the default Tenant (or --tenant's) without linking the current directory
|
|
25
|
+
down|stop [--tenant <name>] stop the Tenant's containers; keep volumes
|
|
26
|
+
status [--tenant <name>] [--json] the Tenant, its id, services, endpoints and Runtime health
|
|
27
|
+
logs [service] [--tenant <name>] [-f] [--tail <n>]
|
|
28
|
+
container logs (${STACK_SERVICES.join(", ")})
|
|
29
|
+
studio [--tenant <name>] [--no-open]
|
|
30
|
+
sign a browser in to Studio on the Tenant; --no-open prints the login URL; start the Tenant if it is stopped
|
|
31
|
+
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
|
+
delete <tenant> --yes remove a Tenant: containers, volumes and its Host root, with the vault key (KEK)`;
|
|
24
34
|
const usageError = (message) => new CliError(message, 2);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
/** The Compose project of Tenant `name`: `nylorun-<name>`, or `NYLORUN_COMPOSE_PROJECT`. */
|
|
36
|
+
export function stackProject(env, name) {
|
|
37
|
+
const override = env.NYLORUN_COMPOSE_PROJECT?.trim();
|
|
38
|
+
if (!override)
|
|
39
|
+
return `nylorun-${name}`;
|
|
40
|
+
return assertTenantName(override, "NYLORUN_COMPOSE_PROJECT");
|
|
30
41
|
}
|
|
31
|
-
function
|
|
42
|
+
function nylorunRoot(deps) {
|
|
43
|
+
return deps.nylorunRoot ?? defaultNylorunRoot();
|
|
44
|
+
}
|
|
45
|
+
async function tenantNames(deps) {
|
|
46
|
+
const names = (await listTenants(nylorunRoot(deps))).map((tenant) => tenant.name);
|
|
47
|
+
return names.length ? ` Tenants on this machine: ${names.join(", ")}.` : "";
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Which Tenant a command acts on: `--tenant`, then `NYLORUN_TENANT`, then the Tenant of the
|
|
51
|
+
* project's link (format 3; `start` replaces an older one); for `start` in a project, then a
|
|
52
|
+
* name from the project directory (`chooseTenantName`); outside a project (or with
|
|
53
|
+
* --no-link), `default`. `NYLORUN_HOME` sets the Host root whatever the name; its name
|
|
54
|
+
* otherwise comes from its `tenant.json`, the project directory or the Host root's own.
|
|
55
|
+
*/
|
|
56
|
+
async function selectStack(deps, options = {}) {
|
|
57
|
+
const { env } = deps;
|
|
58
|
+
const base = nylorunRoot(deps);
|
|
59
|
+
const projectDir = options.noLink ? undefined : findProjectRoot(deps.cwd ?? process.cwd());
|
|
60
|
+
const link = projectDir ? await readProjectLink(projectDir) : undefined;
|
|
61
|
+
const explicit = options.name ?? (env.NYLORUN_TENANT?.trim() || undefined);
|
|
62
|
+
if (explicit !== undefined)
|
|
63
|
+
assertTenantName(explicit, options.name !== undefined ? "--tenant" : "NYLORUN_TENANT");
|
|
64
|
+
let name = explicit ?? link?.tenant;
|
|
65
|
+
let root;
|
|
66
|
+
const home = env.NYLORUN_HOME?.trim();
|
|
67
|
+
if (home) {
|
|
68
|
+
root = resolve(home);
|
|
69
|
+
name ??=
|
|
70
|
+
(await readTenantRecord(root))?.name ??
|
|
71
|
+
sanitizeTenantName(basename(projectDir ?? root));
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
if (name === undefined && options.start && projectDir)
|
|
75
|
+
name = await chooseTenantName(base, projectDir);
|
|
76
|
+
if (name === undefined && !projectDir)
|
|
77
|
+
name = DEFAULT_TENANT;
|
|
78
|
+
if (name === undefined)
|
|
79
|
+
throw usageError(`No Tenant selected: run "nylorun start" in this project, pass --tenant <name>, or set NYLORUN_TENANT.${await tenantNames(deps)}`);
|
|
80
|
+
root = tenantRoot(base, name);
|
|
81
|
+
}
|
|
82
|
+
assertTenantName(name, "The Tenant name");
|
|
32
83
|
return {
|
|
33
84
|
deps,
|
|
34
|
-
|
|
35
|
-
|
|
85
|
+
name,
|
|
86
|
+
paths: stackPaths(root),
|
|
87
|
+
project: stackProject(env, name),
|
|
88
|
+
...(projectDir ? { projectDir } : {}),
|
|
89
|
+
...(link ? { link } : {}),
|
|
36
90
|
};
|
|
37
91
|
}
|
|
38
92
|
function composeArgs(ctx, ...args) {
|
|
@@ -49,7 +103,7 @@ function composeArgs(ctx, ...args) {
|
|
|
49
103
|
}
|
|
50
104
|
function requireStackFiles(ctx) {
|
|
51
105
|
if (!existsSync(ctx.paths.compose) || !existsSync(ctx.paths.env))
|
|
52
|
-
throw new CliError(`No
|
|
106
|
+
throw new CliError(`No Tenant ${ctx.name} under ${ctx.paths.root}. Run "nylorun start" first.`, 3);
|
|
53
107
|
}
|
|
54
108
|
export function parseStackFlags(args, allowed, usage) {
|
|
55
109
|
const booleans = new Set();
|
|
@@ -85,6 +139,11 @@ export function parseStackFlags(args, allowed, usage) {
|
|
|
85
139
|
}
|
|
86
140
|
return { rest, booleans, values, lists };
|
|
87
141
|
}
|
|
142
|
+
/** `--tenant` when given. */
|
|
143
|
+
function nameOption(flags) {
|
|
144
|
+
const name = flags.values.get("--tenant");
|
|
145
|
+
return name === undefined ? {} : { name };
|
|
146
|
+
}
|
|
88
147
|
async function sleep(ms) {
|
|
89
148
|
await new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
90
149
|
}
|
|
@@ -116,6 +175,61 @@ async function waitForHealth(ctx, runtimeUrl, hostId) {
|
|
|
116
175
|
await sleep(ctx.deps.pollMs ?? 500);
|
|
117
176
|
}
|
|
118
177
|
}
|
|
178
|
+
/** `/v1/admin/status`'s Tenant on the operator listener; undefined when it does not answer. */
|
|
179
|
+
async function fetchTenant(deps, adminUrl, adminKey) {
|
|
180
|
+
if (!adminKey)
|
|
181
|
+
return undefined;
|
|
182
|
+
try {
|
|
183
|
+
const response = await deps.fetch(`${adminUrl}/v1/admin/status`, {
|
|
184
|
+
headers: {
|
|
185
|
+
authorization: `Bearer ${adminKey}`,
|
|
186
|
+
[PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
|
|
187
|
+
accept: "application/json",
|
|
188
|
+
},
|
|
189
|
+
signal: AbortSignal.timeout(3000),
|
|
190
|
+
redirect: "error",
|
|
191
|
+
});
|
|
192
|
+
if (!response.ok)
|
|
193
|
+
return undefined;
|
|
194
|
+
const tenant = (await response.json()).tenant;
|
|
195
|
+
if (!tenant || (tenant.state !== "open" && tenant.state !== "unavailable"))
|
|
196
|
+
return undefined;
|
|
197
|
+
return {
|
|
198
|
+
id: typeof tenant.id === "string" ? tenant.id : null,
|
|
199
|
+
name: typeof tenant.name === "string" ? tenant.name : null,
|
|
200
|
+
state: tenant.state,
|
|
201
|
+
...(tenant.cause ? { cause: tenant.cause } : {}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** Why the Tenant is unavailable, with the repair the Runtime names. */
|
|
209
|
+
function tenantCauseMessage(ctx, tenant) {
|
|
210
|
+
const cause = tenant.cause;
|
|
211
|
+
const hint = cause.code === "schema-too-new"
|
|
212
|
+
? ` This nylorun pins Runtime ${ctx.deps.runtimeVersion}, older than the Tenant's database: update nylorun (npx nylorun@latest start).`
|
|
213
|
+
: "";
|
|
214
|
+
return `Tenant ${ctx.name} is unavailable (${cause.code}): ${cause.message} ${cause.repair}${hint} See "nylorun logs runtime".`;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Wait until the Tenant is open: the Runtime creates it on the first start and opens
|
|
218
|
+
* it on later ones. A Tenant that could not be opened has a cause; report it.
|
|
219
|
+
*/
|
|
220
|
+
async function waitForTenant(ctx, adminUrl, adminKey) {
|
|
221
|
+
const deadline = Date.now() + (ctx.deps.healthTimeoutMs ?? 60_000);
|
|
222
|
+
for (;;) {
|
|
223
|
+
const tenant = await fetchTenant(ctx.deps, adminUrl, adminKey);
|
|
224
|
+
if (tenant?.state === "open" && tenant.id)
|
|
225
|
+
return { ...tenant, id: tenant.id };
|
|
226
|
+
if (tenant?.cause)
|
|
227
|
+
throw new CliError(tenantCauseMessage(ctx, tenant), 7);
|
|
228
|
+
if (Date.now() > deadline)
|
|
229
|
+
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);
|
|
230
|
+
await sleep(ctx.deps.pollMs ?? 500);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
119
233
|
/**
|
|
120
234
|
* A Runtime started by the removed launcher (`nylorun runtime up`, the
|
|
121
235
|
* `nylorun-runtime` bin) shares host.json; refuse to fight it.
|
|
@@ -154,37 +268,41 @@ function isOlder(version, than) {
|
|
|
154
268
|
SEMVER.test(than) &&
|
|
155
269
|
compareVersions(version, than) < 0);
|
|
156
270
|
}
|
|
157
|
-
/** The version of this Host's Runtime when it answers on the stack's port. */
|
|
158
|
-
async function runningRuntimeVersion(ctx, hostId) {
|
|
159
|
-
const persisted = await readStackEnv(ctx.paths);
|
|
160
|
-
if (!persisted?.runtimePort || typeof hostId !== "string")
|
|
161
|
-
return undefined;
|
|
162
|
-
const health = await fetchHealth(ctx.deps, `http://${STACK_CLIENT_HOST}:${persisted.runtimePort}`);
|
|
163
|
-
return health?.status === "ok" && health.hostId === hostId ? health.version : undefined;
|
|
164
|
-
}
|
|
165
271
|
/**
|
|
166
|
-
* Refuse
|
|
167
|
-
* `runtimeVersion`)
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* the image.
|
|
272
|
+
* Refuse a Runtime older than the Tenant's database: the Runtime that last ran the Tenant
|
|
273
|
+
* (host.json `runtimeVersion`) migrated it, and an older one cannot open its Tenant
|
|
274
|
+
* (`schema-too-new`). Checked before Compose replaces anything. Skipped when
|
|
275
|
+
* `NYLORUN_RUNTIME_IMAGE` names the image.
|
|
171
276
|
*/
|
|
172
277
|
async function refuseDowngrade(ctx, allowDowngrade) {
|
|
173
278
|
const pinned = ctx.deps.runtimeVersion;
|
|
174
279
|
const host = await readHostConfig(ctx.paths);
|
|
175
|
-
|
|
176
|
-
if (isOlder(pinned, host?.runtimeVersion))
|
|
177
|
-
newer.push(`the Host last ran Runtime ${host.runtimeVersion} (${ctx.paths.config})`);
|
|
178
|
-
const running = await runningRuntimeVersion(ctx, host?.hostId);
|
|
179
|
-
if (isOlder(pinned, running) && running !== host?.runtimeVersion)
|
|
180
|
-
newer.push(`Runtime ${running} is running`);
|
|
181
|
-
if (newer.length === 0)
|
|
280
|
+
if (!isOlder(pinned, host?.runtimeVersion))
|
|
182
281
|
return;
|
|
183
282
|
if (allowDowngrade) {
|
|
184
|
-
ctx.deps.err(`Warning:
|
|
283
|
+
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).`);
|
|
185
284
|
return;
|
|
186
285
|
}
|
|
187
|
-
throw new CliError(`Refusing to
|
|
286
|
+
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
|
+
}
|
|
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)
|
|
292
|
+
.map((tenant) => stackPaths(tenant.root));
|
|
293
|
+
const reserved = new Set();
|
|
294
|
+
for (const paths of others) {
|
|
295
|
+
const persisted = await readStackEnv(paths);
|
|
296
|
+
for (const port of [
|
|
297
|
+
persisted?.runtimePort,
|
|
298
|
+
persisted?.adminPort,
|
|
299
|
+
persisted?.studioPort,
|
|
300
|
+
persisted?.restatePort,
|
|
301
|
+
])
|
|
302
|
+
if (port !== undefined)
|
|
303
|
+
reserved.add(port);
|
|
304
|
+
}
|
|
305
|
+
return reserved;
|
|
188
306
|
}
|
|
189
307
|
async function bringUp(ctx, options) {
|
|
190
308
|
const { deps } = ctx;
|
|
@@ -192,8 +310,12 @@ async function bringUp(ctx, options) {
|
|
|
192
310
|
const overridden = runtimeImageOverridden(deps.env);
|
|
193
311
|
if (!overridden)
|
|
194
312
|
await refuseDowngrade(ctx, options.allowDowngrade ?? false);
|
|
313
|
+
const telemetry = await readTelemetry(nylorunRoot(deps));
|
|
314
|
+
const analytics = telemetryDecision(deps.env, telemetry).enabled;
|
|
195
315
|
const prepared = await prepareStack({
|
|
196
316
|
paths: ctx.paths,
|
|
317
|
+
name: ctx.name,
|
|
318
|
+
project: ctx.project,
|
|
197
319
|
images: stackImages(deps.env, {
|
|
198
320
|
runtime: deps.runtimeVersion,
|
|
199
321
|
studio: deps.studioVersion,
|
|
@@ -203,14 +325,33 @@ async function bringUp(ctx, options) {
|
|
|
203
325
|
// An overriding image's version is unknown: keep the recorded one.
|
|
204
326
|
runtimeVersion: overridden ? undefined : deps.runtimeVersion,
|
|
205
327
|
ports: deps.ports,
|
|
328
|
+
reserved: await reservedPorts(ctx),
|
|
329
|
+
...(deps.env.NYLORUN_DERIVED_PRINCIPALS?.trim()
|
|
330
|
+
? { derivedPrincipals: deps.env.NYLORUN_DERIVED_PRINCIPALS }
|
|
331
|
+
: {}),
|
|
206
332
|
...(options.studioEmbedOrigins ? { studioEmbedOrigins: options.studioEmbedOrigins } : {}),
|
|
333
|
+
...(analytics ? { studioAnalyticsId: STUDIO_ANALYTICS_ID } : {}),
|
|
207
334
|
});
|
|
335
|
+
if (analytics && options.studio && telemetry.noticeShown === undefined) {
|
|
336
|
+
deps.err(TELEMETRY_NOTICE);
|
|
337
|
+
await writeTelemetry(nylorunRoot(deps), { ...telemetry, noticeShown: new Date().toISOString() });
|
|
338
|
+
}
|
|
339
|
+
const record = await readTenantRecord(ctx.paths.root);
|
|
340
|
+
const project = record?.project ?? ctx.projectDir;
|
|
341
|
+
if (record?.name !== ctx.name || record.project !== project)
|
|
342
|
+
await writeTenantRecord(ctx.paths.root, { name: ctx.name, ...(project ? { project } : {}) });
|
|
208
343
|
if (prepared.firstRun)
|
|
209
|
-
deps.err(`
|
|
210
|
-
const up = await deps.docker.stream(composeArgs(ctx, "up", "--detach", "--wait", "--wait-timeout", "300", ...CORE_SERVICES));
|
|
211
|
-
if (up !== 0)
|
|
212
|
-
throw new CliError(`docker compose up failed (exit ${up}). See "nylorun logs runtime" and "nylorun status".`, 7);
|
|
344
|
+
deps.err(`Created Tenant ${ctx.name} under ${ctx.paths.root} (Runtime port ${prepared.env.runtimePort}, Studio port ${prepared.env.studioPort}).`);
|
|
213
345
|
const runtimeUrl = `http://${STACK_CLIENT_HOST}:${prepared.env.runtimePort}`;
|
|
346
|
+
const adminUrl = `http://${STACK_CLIENT_HOST}:${prepared.env.adminPort}`;
|
|
347
|
+
const up = await deps.docker.stream(composeArgs(ctx, "up", "--detach", "--wait", "--wait-timeout", "300", ...CORE_SERVICES));
|
|
348
|
+
if (up !== 0) {
|
|
349
|
+
// A Runtime whose Tenant cannot open fails readiness; its status names the cause.
|
|
350
|
+
const tenant = await fetchTenant(deps, adminUrl, prepared.adminKey);
|
|
351
|
+
if (tenant?.cause)
|
|
352
|
+
throw new CliError(tenantCauseMessage(ctx, tenant), 7);
|
|
353
|
+
throw new CliError(`docker compose up failed (exit ${up}). See "nylorun logs runtime", "nylorun logs gateway" and "nylorun status".`, 7);
|
|
354
|
+
}
|
|
214
355
|
await waitForHealth(ctx, runtimeUrl, prepared.host.hostId);
|
|
215
356
|
let studioStarted = false;
|
|
216
357
|
if (options.studio) {
|
|
@@ -221,6 +362,7 @@ async function bringUp(ctx, options) {
|
|
|
221
362
|
}
|
|
222
363
|
return {
|
|
223
364
|
runtimeUrl,
|
|
365
|
+
adminUrl,
|
|
224
366
|
hostId: prepared.host.hostId,
|
|
225
367
|
studioPort: prepared.env.studioPort,
|
|
226
368
|
studioStarted,
|
|
@@ -258,17 +400,74 @@ async function openLogin(ctx, login) {
|
|
|
258
400
|
if (!(await ctx.deps.openBrowser(login)))
|
|
259
401
|
ctx.deps.out(`Sign in ${login}`);
|
|
260
402
|
}
|
|
261
|
-
/**
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
403
|
+
/**
|
|
404
|
+
* Link the project to the Tenant: `.nylorun/link.json` (format 3) and
|
|
405
|
+
* `.nylorun/credentials.json` with the key of the derived principal `project`. The link is
|
|
406
|
+
* rewritten only when it is older, or the Tenant's name, URL, Host or id changed; a new link
|
|
407
|
+
* seeds the Tenant from the project's `.env`.
|
|
408
|
+
*/
|
|
409
|
+
async function linkProject(ctx, started, tenantId) {
|
|
410
|
+
const { deps, projectDir, link } = ctx;
|
|
411
|
+
const applicationKey = deriveTenantKey(started.adminKey, tenantId, PROJECT_PRINCIPAL_ID);
|
|
412
|
+
const fresh = link?.format !== 3 ||
|
|
413
|
+
link.tenant !== ctx.name ||
|
|
414
|
+
link.hostUrl !== started.runtimeUrl ||
|
|
415
|
+
link.hostId !== started.hostId ||
|
|
416
|
+
link.tenantId !== tenantId;
|
|
417
|
+
if (fresh)
|
|
418
|
+
await writeProjectLink(projectDir, {
|
|
419
|
+
tenant: ctx.name,
|
|
420
|
+
hostUrl: started.runtimeUrl,
|
|
421
|
+
hostId: started.hostId,
|
|
422
|
+
tenantId,
|
|
423
|
+
});
|
|
424
|
+
const credentials = await readProjectCredentials(projectDir);
|
|
425
|
+
if (fresh ||
|
|
426
|
+
credentials?.applicationKey !== applicationKey ||
|
|
427
|
+
credentials.principalId !== PROJECT_PRINCIPAL_ID)
|
|
428
|
+
await writeProjectCredentials(projectDir, {
|
|
429
|
+
applicationKey,
|
|
430
|
+
principalId: PROJECT_PRINCIPAL_ID,
|
|
431
|
+
});
|
|
432
|
+
if (!fresh)
|
|
433
|
+
return;
|
|
434
|
+
deps.err(`Linked ${projectDir} to Tenant ${ctx.name} (.nylorun/link.json, .nylorun/credentials.json).`);
|
|
435
|
+
try {
|
|
436
|
+
const seeded = await seedTenant({
|
|
437
|
+
fetch: deps.fetch,
|
|
438
|
+
runtimeUrl: started.runtimeUrl,
|
|
439
|
+
applicationKey,
|
|
440
|
+
projectRoot: projectDir,
|
|
441
|
+
});
|
|
442
|
+
const set = [...seeded.applied, ...(seeded.model ? [`model ${seeded.model}`] : [])];
|
|
443
|
+
if (set.length)
|
|
444
|
+
deps.err(`Seeded the Tenant from .env: ${set.join(", ")}.`);
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
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".`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const START_USAGE = "nylorun start [--tenant <name>] [--no-link] [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset]";
|
|
451
|
+
async function start(deps, args) {
|
|
265
452
|
const flags = parseStackFlags(args, {
|
|
266
|
-
booleans: [
|
|
453
|
+
booleans: [
|
|
454
|
+
"--no-studio",
|
|
455
|
+
"--no-open",
|
|
456
|
+
"--no-link",
|
|
457
|
+
"--allow-downgrade",
|
|
458
|
+
"--studio-embed-origin-reset",
|
|
459
|
+
],
|
|
460
|
+
values: ["--tenant"],
|
|
267
461
|
lists: ["--studio-embed-origin"],
|
|
268
462
|
}, START_USAGE);
|
|
269
463
|
if (flags.rest.length)
|
|
270
464
|
throw usageError(`Usage: ${START_USAGE}`);
|
|
271
|
-
await
|
|
465
|
+
const ctx = await selectStack(deps, {
|
|
466
|
+
...nameOption(flags),
|
|
467
|
+
start: true,
|
|
468
|
+
noLink: flags.booleans.has("--no-link"),
|
|
469
|
+
});
|
|
470
|
+
await dockerPreflight(deps.docker);
|
|
272
471
|
const embedOrigins = flags.lists.get("--studio-embed-origin") ?? [];
|
|
273
472
|
const resetEmbed = flags.booleans.has("--studio-embed-origin-reset");
|
|
274
473
|
const started = await bringUp(ctx, {
|
|
@@ -278,62 +477,47 @@ async function start(ctx, args) {
|
|
|
278
477
|
? { studioEmbedOrigins: { add: embedOrigins, reset: resetEmbed } }
|
|
279
478
|
: {}),
|
|
280
479
|
});
|
|
281
|
-
ctx.
|
|
480
|
+
const tenant = await waitForTenant(ctx, started.adminUrl, started.adminKey);
|
|
481
|
+
if (ctx.projectDir)
|
|
482
|
+
await linkProject({ ...ctx, projectDir: ctx.projectDir }, started, tenant.id);
|
|
483
|
+
deps.out(`Tenant ${ctx.name} (${tenant.id})`);
|
|
484
|
+
deps.out(`Runtime ${started.runtimeUrl}`);
|
|
282
485
|
if (started.studioStarted) {
|
|
283
|
-
|
|
284
|
-
if (opensBrowser(
|
|
486
|
+
deps.out(`Studio ${studioOrigin(started.studioPort)}`);
|
|
487
|
+
if (opensBrowser(deps, flags)) {
|
|
285
488
|
const login = await tryStudioLogin(ctx, started.studioPort, started.adminKey);
|
|
286
489
|
if (login)
|
|
287
|
-
await openLogin(ctx, login);
|
|
490
|
+
await openLogin(ctx, withNext(login, tenantStudioPath(tenant.id)));
|
|
288
491
|
}
|
|
289
492
|
else
|
|
290
|
-
|
|
493
|
+
deps.err(STUDIO_SIGN_IN_HINT);
|
|
291
494
|
}
|
|
292
|
-
if ((await adminTenantCount(ctx.deps, started.runtimeUrl, started.adminKey)) === 0)
|
|
293
|
-
ctx.deps.err(TENANT_HINT);
|
|
294
495
|
return 0;
|
|
295
496
|
}
|
|
296
|
-
async function stop(
|
|
297
|
-
|
|
298
|
-
|
|
497
|
+
async function stop(deps, args) {
|
|
498
|
+
const usage = "nylorun stop [--tenant <name>]";
|
|
499
|
+
const flags = parseStackFlags(args, { values: ["--tenant"] }, usage);
|
|
500
|
+
if (flags.rest.length)
|
|
501
|
+
throw usageError(`Usage: ${usage}`);
|
|
502
|
+
const ctx = await selectStack(deps, nameOption(flags));
|
|
299
503
|
requireStackFiles(ctx);
|
|
300
|
-
await dockerPreflight(
|
|
301
|
-
const code = await
|
|
504
|
+
await dockerPreflight(deps.docker);
|
|
505
|
+
const code = await deps.docker.stream(composeArgs(ctx, "stop"));
|
|
302
506
|
if (code !== 0)
|
|
303
507
|
throw new CliError(`docker compose stop failed (exit ${code}).`, 1);
|
|
304
|
-
|
|
508
|
+
deps.out(`Stopped Tenant ${ctx.name}; its data is kept.`);
|
|
305
509
|
return 0;
|
|
306
510
|
}
|
|
307
|
-
async function adminTenantCount(deps, runtimeUrl, adminKey) {
|
|
308
|
-
if (!adminKey)
|
|
309
|
-
return undefined;
|
|
310
|
-
try {
|
|
311
|
-
const response = await deps.fetch(`${runtimeUrl}/v1/admin/status`, {
|
|
312
|
-
headers: {
|
|
313
|
-
authorization: `Bearer ${adminKey}`,
|
|
314
|
-
[PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
|
|
315
|
-
accept: "application/json",
|
|
316
|
-
},
|
|
317
|
-
signal: AbortSignal.timeout(3000),
|
|
318
|
-
redirect: "error",
|
|
319
|
-
});
|
|
320
|
-
if (!response.ok)
|
|
321
|
-
return undefined;
|
|
322
|
-
const body = (await response.json());
|
|
323
|
-
return Array.isArray(body.tenants) ? body.tenants.length : undefined;
|
|
324
|
-
}
|
|
325
|
-
catch {
|
|
326
|
-
return undefined;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
511
|
async function stackStatus(ctx) {
|
|
330
512
|
const base = {
|
|
513
|
+
name: ctx.name,
|
|
331
514
|
project: ctx.project,
|
|
332
515
|
home: ctx.paths.root,
|
|
333
516
|
state: "absent",
|
|
334
517
|
runtime: { healthy: false },
|
|
335
518
|
studio: { state: "absent" },
|
|
336
519
|
restate: {},
|
|
520
|
+
gateway: { state: "absent", healthy: false },
|
|
337
521
|
services: [],
|
|
338
522
|
};
|
|
339
523
|
if (!existsSync(ctx.paths.compose) || !existsSync(ctx.paths.env))
|
|
@@ -347,14 +531,15 @@ async function stackStatus(ctx) {
|
|
|
347
531
|
const health = runtimeUrl ? await fetchHealth(ctx.deps, runtimeUrl) : undefined;
|
|
348
532
|
const host = await readHostConfig(ctx.paths);
|
|
349
533
|
const healthy = health?.status === "ok" && (host === undefined || health.hostId === host.hostId);
|
|
350
|
-
// The Admin API answers on the operator port; an older
|
|
534
|
+
// The Admin API answers on the operator port; an older Host root has only the Runtime port.
|
|
351
535
|
const adminUrl = persisted.adminPort
|
|
352
536
|
? `http://${STACK_CLIENT_HOST}:${persisted.adminPort}`
|
|
353
537
|
: runtimeUrl;
|
|
354
|
-
const
|
|
355
|
-
? await
|
|
538
|
+
const tenant = healthy && adminUrl
|
|
539
|
+
? await fetchTenant(ctx.deps, adminUrl, await readAdminKey(ctx.paths))
|
|
356
540
|
: undefined;
|
|
357
541
|
const studio = services.find((s) => s.service === "studio");
|
|
542
|
+
const gateway = services.find((s) => s.service === "gateway");
|
|
358
543
|
return {
|
|
359
544
|
...base,
|
|
360
545
|
state: services.some((s) => s.state === "running") ? "running" : "stopped",
|
|
@@ -364,8 +549,8 @@ async function stackStatus(ctx) {
|
|
|
364
549
|
healthy,
|
|
365
550
|
...(health?.version ? { version: health.version } : {}),
|
|
366
551
|
...(health?.hostId ? { hostId: health.hostId } : {}),
|
|
367
|
-
...(tenants !== undefined ? { tenants } : {}),
|
|
368
552
|
},
|
|
553
|
+
...(tenant ? { tenant } : {}),
|
|
369
554
|
studio: {
|
|
370
555
|
...(persisted.studioPort ? { url: studioOrigin(persisted.studioPort) } : {}),
|
|
371
556
|
state: studio ? [studio.state, studio.health].filter(Boolean).join(", ") : "absent",
|
|
@@ -376,29 +561,46 @@ async function stackStatus(ctx) {
|
|
|
376
561
|
restate: persisted.restatePort
|
|
377
562
|
? { url: `http://${STACK_CLIENT_HOST}:${persisted.restatePort}` }
|
|
378
563
|
: {},
|
|
564
|
+
gateway: {
|
|
565
|
+
state: gateway ? [gateway.state, gateway.health].filter(Boolean).join(", ") : "absent",
|
|
566
|
+
healthy: isUp(services, "gateway"),
|
|
567
|
+
},
|
|
379
568
|
services,
|
|
380
569
|
};
|
|
381
570
|
}
|
|
382
571
|
/** What `nylorun status` reports, as data (`nylorun doctor`). */
|
|
383
|
-
export async function readStackStatus(deps) {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
572
|
+
export async function readStackStatus(deps, options = {}) {
|
|
573
|
+
await moveStackRoots(nylorunRoot(deps), deps.err);
|
|
574
|
+
return await stackStatus(await selectStack(deps, options));
|
|
575
|
+
}
|
|
576
|
+
function describeTenant(tenant) {
|
|
577
|
+
const who = `${tenant.id ?? "(no id yet)"}${tenant.name ? ` (${tenant.name})` : ""}`;
|
|
578
|
+
if (tenant.state === "open")
|
|
579
|
+
return `${who} open`;
|
|
580
|
+
return tenant.cause
|
|
581
|
+
? `${who} unavailable: ${tenant.cause.code}: ${tenant.cause.message} ${tenant.cause.repair}`
|
|
582
|
+
: `${who} unavailable (opening)`;
|
|
583
|
+
}
|
|
584
|
+
async function status(deps, args) {
|
|
585
|
+
const usage = "nylorun status [--tenant <name>] [--json]";
|
|
586
|
+
const flags = parseStackFlags(args, { booleans: ["--json"], values: ["--tenant"] }, usage);
|
|
388
587
|
if (flags.rest.length)
|
|
389
|
-
throw usageError(
|
|
390
|
-
const result = await stackStatus(
|
|
391
|
-
const { out } =
|
|
588
|
+
throw usageError(`Usage: ${usage}`);
|
|
589
|
+
const result = await stackStatus(await selectStack(deps, nameOption(flags)));
|
|
590
|
+
const { out } = deps;
|
|
392
591
|
if (flags.booleans.has("--json")) {
|
|
393
592
|
out(JSON.stringify(result, null, 2));
|
|
394
593
|
}
|
|
395
594
|
else if (result.state === "absent") {
|
|
396
|
-
out(`
|
|
595
|
+
out(`Tenant ${result.name} absent (nothing under ${result.home}; run "nylorun start")`);
|
|
397
596
|
}
|
|
398
597
|
else {
|
|
399
|
-
out(`
|
|
598
|
+
out(`Tenant ${result.name} ${result.state} (Compose project ${result.project})`);
|
|
599
|
+
out(`Host root ${result.home} (admin key in host-credentials.json, mode 0600)`);
|
|
600
|
+
if (result.tenant)
|
|
601
|
+
out(`Tenant id ${describeTenant(result.tenant)}`);
|
|
400
602
|
const runtimeDetail = result.runtime.healthy
|
|
401
|
-
? `healthy, ${result.runtime.version ?? "?"}, ${result.runtime.hostId ?? "?"}
|
|
603
|
+
? `healthy, ${result.runtime.version ?? "?"}, ${result.runtime.hostId ?? "?"}`
|
|
402
604
|
: "not answering";
|
|
403
605
|
out(`Runtime ${result.runtime.url ?? "?"} ${runtimeDetail}`);
|
|
404
606
|
if (result.runtime.adminUrl)
|
|
@@ -406,6 +608,7 @@ async function status(ctx, args) {
|
|
|
406
608
|
out(`Studio ${result.studio.url ?? "?"} ${result.studio.state} (log in with "nylorun studio")`);
|
|
407
609
|
if (result.studio.embedOrigins?.length)
|
|
408
610
|
out(`Embeds ${result.studio.embedOrigins.join(" ")} (may show Studio in a frame)`);
|
|
611
|
+
out(`Gateway ${result.gateway.state} (the Model Gate; model calls fail while it is down)`);
|
|
409
612
|
if (result.restate.url)
|
|
410
613
|
out(`Restate UI ${result.restate.url}`);
|
|
411
614
|
out(`Services ${STACK_SERVICES.map((name) => {
|
|
@@ -415,13 +618,12 @@ async function status(ctx, args) {
|
|
|
415
618
|
: "absent";
|
|
416
619
|
return `${name} ${state}`;
|
|
417
620
|
}).join(", ")}`);
|
|
418
|
-
out(`Host root ${result.home} (admin key in host-credentials.json, mode 0600)`);
|
|
419
621
|
}
|
|
420
622
|
return result.runtime.healthy ? 0 : 3;
|
|
421
623
|
}
|
|
422
|
-
async function logs(
|
|
423
|
-
const usage = `nylorun logs [${STACK_SERVICES.join("|")}] [-f] [--tail <n>]`;
|
|
424
|
-
const flags = parseStackFlags(args, { booleans: ["--follow"], values: ["--tail"], aliases: { "-f": "--follow" } }, usage);
|
|
624
|
+
async function logs(deps, args) {
|
|
625
|
+
const usage = `nylorun logs [${STACK_SERVICES.join("|")}] [--tenant <name>] [-f] [--tail <n>]`;
|
|
626
|
+
const flags = parseStackFlags(args, { booleans: ["--follow"], values: ["--tail", "--tenant"], aliases: { "-f": "--follow" } }, usage);
|
|
425
627
|
if (flags.rest.length > 1)
|
|
426
628
|
throw usageError(`Usage: ${usage}`);
|
|
427
629
|
const service = flags.rest[0];
|
|
@@ -430,19 +632,22 @@ async function logs(ctx, args) {
|
|
|
430
632
|
const tail = flags.values.get("--tail");
|
|
431
633
|
if (tail !== undefined && !/^\d+$/.test(tail))
|
|
432
634
|
throw usageError(`Invalid --tail: ${tail}`);
|
|
635
|
+
const ctx = await selectStack(deps, nameOption(flags));
|
|
433
636
|
requireStackFiles(ctx);
|
|
434
|
-
await dockerPreflight(
|
|
435
|
-
return await
|
|
637
|
+
await dockerPreflight(deps.docker);
|
|
638
|
+
return await deps.docker.stream(composeArgs(ctx, "logs", ...(flags.booleans.has("--follow") ? ["--follow"] : []), ...(tail !== undefined ? ["--tail", tail] : []), ...(service ? [service] : [])));
|
|
436
639
|
}
|
|
437
|
-
async function reset(
|
|
438
|
-
const
|
|
640
|
+
async function reset(deps, args) {
|
|
641
|
+
const usage = "nylorun reset [--tenant <name>] [--yes]";
|
|
642
|
+
const flags = parseStackFlags(args, { booleans: ["--yes"], values: ["--tenant"], aliases: { "-y": "--yes" } }, usage);
|
|
439
643
|
if (flags.rest.length)
|
|
440
|
-
throw usageError(
|
|
441
|
-
const
|
|
644
|
+
throw usageError(`Usage: ${usage}`);
|
|
645
|
+
const ctx = await selectStack(deps, nameOption(flags));
|
|
646
|
+
const { paths } = ctx;
|
|
442
647
|
if (!flags.booleans.has("--yes")) {
|
|
443
|
-
const question = `Delete
|
|
648
|
+
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] `;
|
|
444
649
|
if (!deps.confirm)
|
|
445
|
-
throw usageError(
|
|
650
|
+
throw usageError(`nylorun reset deletes all of Tenant ${ctx.name}'s data; pass --yes to confirm when not in a terminal.`);
|
|
446
651
|
if (!(await deps.confirm(question))) {
|
|
447
652
|
deps.err("Reset cancelled.");
|
|
448
653
|
return 1;
|
|
@@ -454,12 +659,97 @@ async function reset(ctx, args) {
|
|
|
454
659
|
if (code !== 0)
|
|
455
660
|
throw new CliError(`docker compose down failed (exit ${code}).`, 1);
|
|
456
661
|
}
|
|
457
|
-
await rm(paths.
|
|
458
|
-
await mkdir(paths.
|
|
459
|
-
|
|
662
|
+
await rm(paths.tenant, { recursive: true, force: true });
|
|
663
|
+
await mkdir(paths.tenant, { recursive: true, mode: 0o700 });
|
|
664
|
+
// The vault key goes with the Tenant's data; the next start writes a new one.
|
|
665
|
+
await rm(paths.keys, { recursive: true, force: true });
|
|
666
|
+
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).`);
|
|
460
667
|
return 0;
|
|
461
668
|
}
|
|
462
|
-
/**
|
|
669
|
+
/** Compose projects on this Docker engine and whether any container runs; undefined without Docker. */
|
|
670
|
+
async function composeProjects(docker) {
|
|
671
|
+
const result = await docker.run(["compose", "ls", "--all", "--format", "json"]);
|
|
672
|
+
if (result.code !== 0)
|
|
673
|
+
return undefined;
|
|
674
|
+
try {
|
|
675
|
+
const text = result.stdout.trim();
|
|
676
|
+
const rows = (text === "" ? [] : JSON.parse(text));
|
|
677
|
+
return new Map(rows.map((row) => [
|
|
678
|
+
String(row.Name ?? ""),
|
|
679
|
+
/running/.test(String(row.Status ?? "")) ? "running" : "stopped",
|
|
680
|
+
]));
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
return undefined;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
async function ls(deps, args) {
|
|
687
|
+
const usage = "nylorun ls [--json]";
|
|
688
|
+
const flags = parseStackFlags(args, { booleans: ["--json"] }, usage);
|
|
689
|
+
if (flags.rest.length)
|
|
690
|
+
throw usageError(`Usage: ${usage}`);
|
|
691
|
+
const base = nylorunRoot(deps);
|
|
692
|
+
const projects = await composeProjects(deps.docker);
|
|
693
|
+
const state = (project) => (projects ? (projects.get(project) ?? "stopped") : "unknown");
|
|
694
|
+
const tenants = [];
|
|
695
|
+
for (const entry of await listTenants(base)) {
|
|
696
|
+
const persisted = (await readStackEnv(stackPaths(entry.root))) ?? {};
|
|
697
|
+
tenants.push({
|
|
698
|
+
name: entry.name,
|
|
699
|
+
root: entry.root,
|
|
700
|
+
...(entry.record.project ? { project: entry.record.project } : {}),
|
|
701
|
+
state: state(`nylorun-${entry.name}`),
|
|
702
|
+
...(persisted.runtimePort
|
|
703
|
+
? { runtimeUrl: `http://${STACK_CLIENT_HOST}:${persisted.runtimePort}` }
|
|
704
|
+
: {}),
|
|
705
|
+
...(persisted.studioPort ? { studioUrl: studioOrigin(persisted.studioPort) } : {}),
|
|
706
|
+
ports: {
|
|
707
|
+
...(persisted.runtimePort ? { runtime: persisted.runtimePort } : {}),
|
|
708
|
+
...(persisted.adminPort ? { admin: persisted.adminPort } : {}),
|
|
709
|
+
...(persisted.studioPort ? { studio: persisted.studioPort } : {}),
|
|
710
|
+
...(persisted.restatePort ? { restate: persisted.restatePort } : {}),
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
if (flags.booleans.has("--json")) {
|
|
715
|
+
deps.out(JSON.stringify({ tenants }, null, 2));
|
|
716
|
+
return 0;
|
|
717
|
+
}
|
|
718
|
+
if (tenants.length === 0) {
|
|
719
|
+
deps.out('No Tenants on this machine. Run "npx nylorun start" in a project (or anywhere, for the default Tenant).');
|
|
720
|
+
return 0;
|
|
721
|
+
}
|
|
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 ?? "-"}`);
|
|
726
|
+
return 0;
|
|
727
|
+
}
|
|
728
|
+
async function deleteStack(deps, args) {
|
|
729
|
+
const usage = "nylorun delete <tenant> --yes";
|
|
730
|
+
const flags = parseStackFlags(args, { booleans: ["--yes"], aliases: { "-y": "--yes" } }, usage);
|
|
731
|
+
const [name, ...extra] = flags.rest;
|
|
732
|
+
if (name === undefined || extra.length)
|
|
733
|
+
throw usageError(`Usage: ${usage}`);
|
|
734
|
+
assertTenantName(name, "The Tenant name");
|
|
735
|
+
const base = nylorunRoot(deps);
|
|
736
|
+
const ctx = { deps, project: stackProject(deps.env, name), paths: stackPaths(tenantRoot(base, name)) };
|
|
737
|
+
if (!existsSync(ctx.paths.root))
|
|
738
|
+
throw new CliError(`No Tenant ${name} under ${tenantsDir(base)}. See "nylorun ls".`, 3);
|
|
739
|
+
if (!flags.booleans.has("--yes"))
|
|
740
|
+
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
|
+
await dockerPreflight(deps.docker);
|
|
742
|
+
const files = existsSync(ctx.paths.compose) && existsSync(ctx.paths.env);
|
|
743
|
+
const code = await deps.docker.stream(files
|
|
744
|
+
? composeArgs(ctx, "down", "--volumes", "--remove-orphans")
|
|
745
|
+
: ["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
|
+
await rm(ctx.paths.root, { recursive: true, force: true });
|
|
749
|
+
deps.out(`Deleted Tenant ${name}: its containers, volumes and Host root (vault key included).`);
|
|
750
|
+
return 0;
|
|
751
|
+
}
|
|
752
|
+
/** The Tenant when the Runtime (and Studio, if wanted) already answer; otherwise undefined. */
|
|
463
753
|
async function runningStack(ctx, options) {
|
|
464
754
|
if (!existsSync(ctx.paths.compose) || !existsSync(ctx.paths.env))
|
|
465
755
|
return undefined;
|
|
@@ -471,7 +761,7 @@ async function runningStack(ctx, options) {
|
|
|
471
761
|
if (typeof host?.hostId !== "string")
|
|
472
762
|
return undefined;
|
|
473
763
|
const services = await composePs(ctx);
|
|
474
|
-
if (!isUp(services, "runtime"))
|
|
764
|
+
if (!isUp(services, "runtime") || !isUp(services, "gateway"))
|
|
475
765
|
return undefined;
|
|
476
766
|
const studioUp = isUp(services, "studio");
|
|
477
767
|
if (options.studio && !studioUp)
|
|
@@ -481,8 +771,12 @@ async function runningStack(ctx, options) {
|
|
|
481
771
|
if (health?.status !== "ok" || health.hostId !== host.hostId)
|
|
482
772
|
return undefined;
|
|
483
773
|
return {
|
|
774
|
+
name: ctx.name,
|
|
484
775
|
home: ctx.paths.root,
|
|
485
776
|
runtimeUrl,
|
|
777
|
+
adminUrl: persisted.adminPort
|
|
778
|
+
? `http://${STACK_CLIENT_HOST}:${persisted.adminPort}`
|
|
779
|
+
: runtimeUrl,
|
|
486
780
|
hostId: host.hostId,
|
|
487
781
|
adminKey,
|
|
488
782
|
studioPort: persisted.studioPort,
|
|
@@ -490,22 +784,18 @@ async function runningStack(ctx, options) {
|
|
|
490
784
|
started: false,
|
|
491
785
|
};
|
|
492
786
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
* `start` code path without its own output. Compose progress
|
|
496
|
-
* still streams, since the first run pulls images.
|
|
497
|
-
*/
|
|
498
|
-
export async function ensureStack(deps, options) {
|
|
499
|
-
const ctx = context(deps);
|
|
500
|
-
await dockerPreflight(deps.docker);
|
|
787
|
+
async function ensureSelected(ctx, options) {
|
|
788
|
+
await dockerPreflight(ctx.deps.docker);
|
|
501
789
|
const running = await runningStack(ctx, options);
|
|
502
790
|
if (running)
|
|
503
791
|
return running;
|
|
504
792
|
const runtimeWasUp = isUp(existsSync(ctx.paths.compose) ? await composePs(ctx) : [], "runtime");
|
|
505
793
|
const started = await bringUp(ctx, options);
|
|
506
794
|
return {
|
|
795
|
+
name: ctx.name,
|
|
507
796
|
home: ctx.paths.root,
|
|
508
797
|
runtimeUrl: started.runtimeUrl,
|
|
798
|
+
adminUrl: started.adminUrl,
|
|
509
799
|
hostId: started.hostId,
|
|
510
800
|
adminKey: started.adminKey,
|
|
511
801
|
studioPort: started.studioPort,
|
|
@@ -513,6 +803,14 @@ export async function ensureStack(deps, options) {
|
|
|
513
803
|
started: !runtimeWasUp,
|
|
514
804
|
};
|
|
515
805
|
}
|
|
806
|
+
/**
|
|
807
|
+
* Start the selected Tenant unless it is already running (`nylorun studio`): the
|
|
808
|
+
* `start` code path without its output, its Tenant wait or the Project link. Compose
|
|
809
|
+
* progress still streams, since the first run pulls images.
|
|
810
|
+
*/
|
|
811
|
+
export async function ensureStack(deps, options) {
|
|
812
|
+
return await ensureSelected(await selectStack(deps, options.name === undefined ? {} : { name: options.name }), options);
|
|
813
|
+
}
|
|
516
814
|
/** Add Studio's `next` path (e.g. `/tenants/<id>`) to a login URL. */
|
|
517
815
|
export function withNext(loginUrl, next) {
|
|
518
816
|
if (!next)
|
|
@@ -521,42 +819,54 @@ export function withNext(loginUrl, next) {
|
|
|
521
819
|
url.searchParams.set("next", next);
|
|
522
820
|
return url.toString();
|
|
523
821
|
}
|
|
524
|
-
/** The Studio page
|
|
822
|
+
/** The Tenant's Studio page, as a login `next` path. */
|
|
525
823
|
export function tenantStudioPath(tenantId) {
|
|
526
824
|
return `/tenants/${encodeURIComponent(tenantId)}`;
|
|
527
825
|
}
|
|
528
826
|
/**
|
|
529
|
-
* Mint a fresh Studio login URL on a running
|
|
827
|
+
* Mint a fresh Studio login URL on a running Tenant, landing on `next`.
|
|
530
828
|
* Undefined (after a warning) when Studio does not answer.
|
|
531
829
|
*/
|
|
532
|
-
export async function studioLoginUrl(deps,
|
|
533
|
-
const login = await tryStudioLogin(
|
|
830
|
+
export async function studioLoginUrl(deps, running, next) {
|
|
831
|
+
const login = await tryStudioLogin({ deps }, running.studioPort, running.adminKey);
|
|
534
832
|
return login === undefined ? undefined : withNext(login, next);
|
|
535
833
|
}
|
|
536
|
-
async function studio(
|
|
537
|
-
const
|
|
834
|
+
async function studio(deps, args, options = {}) {
|
|
835
|
+
const usage = "nylorun studio [--tenant <name>] [--no-open]";
|
|
836
|
+
const flags = parseStackFlags(args, { booleans: ["--no-open"], values: ["--tenant"] }, usage);
|
|
538
837
|
if (flags.rest.length)
|
|
539
|
-
throw usageError(
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
838
|
+
throw usageError(`Usage: ${usage}`);
|
|
839
|
+
const ctx = await selectStack(deps, nameOption(flags));
|
|
840
|
+
const running = await ensureSelected(ctx, { studio: true });
|
|
841
|
+
if (running.started)
|
|
842
|
+
deps.out(`Runtime ${running.runtimeUrl}`);
|
|
843
|
+
if (!running.studioUp)
|
|
544
844
|
throw new CliError(`Studio did not start. See "nylorun logs studio".`, 7);
|
|
545
|
-
|
|
845
|
+
// Studio serves the one Tenant; the link's Tenant id stands in while it is opening.
|
|
846
|
+
let next = options.next;
|
|
847
|
+
if (next === undefined) {
|
|
848
|
+
const tenant = await fetchTenant(deps, running.adminUrl, running.adminKey);
|
|
849
|
+
const linked = ctx.link?.tenant === ctx.name ? ctx.link.tenantId : undefined;
|
|
850
|
+
const tenantId = tenant?.id ?? linked;
|
|
851
|
+
if (tenantId)
|
|
852
|
+
next = tenantStudioPath(tenantId);
|
|
853
|
+
}
|
|
854
|
+
const login = await studioLoginUrl(deps, running, next);
|
|
546
855
|
if (!login)
|
|
547
856
|
return 1;
|
|
548
857
|
if (flags.booleans.has("--no-open")) {
|
|
549
|
-
|
|
858
|
+
deps.out(`Studio ${login}`);
|
|
550
859
|
return 0;
|
|
551
860
|
}
|
|
552
|
-
const origin = studioOrigin(
|
|
553
|
-
|
|
554
|
-
await openLogin(
|
|
861
|
+
const origin = studioOrigin(running.studioPort);
|
|
862
|
+
deps.out(`Studio ${next ? new URL(next, origin).toString() : origin}`);
|
|
863
|
+
await openLogin({ deps }, login);
|
|
555
864
|
return 0;
|
|
556
865
|
}
|
|
557
|
-
/** `nylorun studio`, landing on `next`
|
|
866
|
+
/** `nylorun studio`, landing on `next` when given, else on the Tenant's page. */
|
|
558
867
|
export async function runStudioCommand(args, deps, options = {}) {
|
|
559
|
-
|
|
868
|
+
await moveStackRoots(nylorunRoot(deps), deps.err);
|
|
869
|
+
return await studio(deps, args, options);
|
|
560
870
|
}
|
|
561
871
|
const COMMANDS = {
|
|
562
872
|
start,
|
|
@@ -567,15 +877,18 @@ const COMMANDS = {
|
|
|
567
877
|
status,
|
|
568
878
|
logs,
|
|
569
879
|
reset,
|
|
570
|
-
studio: (
|
|
880
|
+
studio: (deps, args) => studio(deps, args),
|
|
881
|
+
ls,
|
|
882
|
+
delete: deleteStack,
|
|
571
883
|
};
|
|
572
884
|
export function isStackCommand(name) {
|
|
573
885
|
return name !== undefined && Object.hasOwn(COMMANDS, name);
|
|
574
886
|
}
|
|
575
|
-
/** Run one
|
|
887
|
+
/** Run one Tenant command (`up`/`start`, `down`/`stop`, `status`, `logs`, `reset`, `studio`, `ls`, `delete`). */
|
|
576
888
|
export async function runStackCommand(name, args, deps) {
|
|
577
889
|
const command = COMMANDS[name];
|
|
578
890
|
if (!command)
|
|
579
|
-
throw usageError(`Unknown
|
|
580
|
-
|
|
891
|
+
throw usageError(`Unknown command ${name}.\n${stackUsage}`);
|
|
892
|
+
await moveStackRoots(nylorunRoot(deps), deps.err);
|
|
893
|
+
return await command(deps, args);
|
|
581
894
|
}
|