nylorun 0.7.0-beta → 0.9.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 +109 -0
- package/README.md +95 -25
- package/dist/cli.js +17 -2
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +10 -0
- package/dist/project/link.d.ts +13 -7
- package/dist/project/link.js +13 -2
- package/dist/sandbox/cluster-file.d.ts +47 -0
- package/dist/sandbox/cluster-file.js +55 -0
- package/dist/sandbox/commands.d.ts +41 -0
- package/dist/sandbox/commands.js +281 -0
- package/dist/sandbox/kubectl.d.ts +39 -0
- package/dist/sandbox/kubectl.js +70 -0
- package/dist/sandbox/manifests.d.ts +41 -0
- package/dist/sandbox/manifests.js +138 -0
- package/dist/sandbox/probe.d.ts +44 -0
- package/dist/sandbox/probe.js +137 -0
- package/dist/stack/commands.d.ts +71 -1
- package/dist/stack/commands.js +209 -38
- package/dist/stack/compose-file.d.ts +47 -7
- package/dist/stack/compose-file.js +223 -18
- package/dist/stack/env-file.d.ts +50 -14
- package/dist/stack/env-file.js +87 -40
- package/dist/stack/host-files.js +11 -1
- package/dist/stack/images.d.ts +8 -2
- package/dist/stack/images.js +8 -2
- package/dist/stack/index.d.ts +9 -0
- package/dist/stack/index.js +18 -1
- package/dist/stack/keys.d.ts +4 -0
- package/dist/stack/keys.js +72 -0
- package/dist/stack/mcp.d.ts +8 -0
- package/dist/stack/mcp.js +114 -0
- package/dist/stack/operator-keys.d.ts +39 -0
- package/dist/stack/operator-keys.js +146 -0
- package/dist/stack/paths.d.ts +32 -0
- package/dist/stack/paths.js +11 -0
- package/dist/stack/prepare.d.ts +12 -6
- package/dist/stack/prepare.js +24 -6
- package/dist/stack/sandbox.d.ts +4 -0
- package/dist/stack/sandbox.js +106 -0
- package/package.json +5 -5
- package/dist/project/derived-key.d.ts +0 -8
- package/dist/project/derived-key.js +0 -18
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator keys (F9 I1, Host feature `operator-keys`) as the nylorun commands use them: the
|
|
3
|
+
* Admin API's `/v1/admin/keys` on a local Tenant's operator listener, with its admin key, and
|
|
4
|
+
* the keys nylorun keeps in the Host root (`project`, `cli`). nylorun depends on
|
|
5
|
+
* `@nylorun/core` only, so these are plain requests (`admin.keys` in `@nylorun/admin` makes
|
|
6
|
+
* the same).
|
|
7
|
+
*/
|
|
8
|
+
import { open, rm, stat } from "node:fs/promises";
|
|
9
|
+
import { PROTOCOL_HEADER, PROTOCOL_VERSION } from "@nylorun/core/compatibility";
|
|
10
|
+
import { CliError } from "../errors.js";
|
|
11
|
+
import { readCredentialsFile, writeCredentialsFile, } from "../project/link.js";
|
|
12
|
+
/** The key `nylorun start` gives the projects it links. */
|
|
13
|
+
export const PROJECT_KEY_ID = "project";
|
|
14
|
+
/** The key nylorun commands use outside a linked project. */
|
|
15
|
+
export const CLI_KEY_ID = "cli";
|
|
16
|
+
async function adminRequest(admin, method, path) {
|
|
17
|
+
let response;
|
|
18
|
+
try {
|
|
19
|
+
response = await admin.fetch(`${admin.adminUrl}${path}`, {
|
|
20
|
+
method,
|
|
21
|
+
headers: {
|
|
22
|
+
authorization: `Bearer ${admin.adminKey}`,
|
|
23
|
+
[PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
|
|
24
|
+
accept: "application/json",
|
|
25
|
+
},
|
|
26
|
+
redirect: "error",
|
|
27
|
+
signal: AbortSignal.timeout(15_000),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
throw new CliError(`The Admin API at ${admin.adminUrl} did not answer ${method} ${path} (${error instanceof Error ? error.message : String(error)}).`, 7);
|
|
32
|
+
}
|
|
33
|
+
const body = (await response.json().catch(() => ({})));
|
|
34
|
+
return { status: response.status, body };
|
|
35
|
+
}
|
|
36
|
+
function refused(method, path, answer) {
|
|
37
|
+
const message = typeof answer.body.message === "string" ? `: ${answer.body.message}` : "";
|
|
38
|
+
// An older Runtime has no key routes: the Admin API's "Route not found".
|
|
39
|
+
if (answer.status === 404 && answer.body.message === "Route not found")
|
|
40
|
+
return new CliError(`The Runtime does not serve operator keys (Host feature operator-keys). Update it: npx nylorun@latest start.`, 1);
|
|
41
|
+
return new CliError(`${method} ${path} returned ${answer.status}${message}`, 1);
|
|
42
|
+
}
|
|
43
|
+
const keyPath = (id) => `/v1/admin/keys/${encodeURIComponent(id)}`;
|
|
44
|
+
/** Creates key `id` or rotates it; the answer holds the key, this once. */
|
|
45
|
+
export async function putOperatorKey(admin, id) {
|
|
46
|
+
const answer = await adminRequest(admin, "PUT", keyPath(id));
|
|
47
|
+
if (answer.status !== 200 || typeof answer.body.key !== "string")
|
|
48
|
+
throw refused("PUT", keyPath(id), answer);
|
|
49
|
+
return answer.body;
|
|
50
|
+
}
|
|
51
|
+
export async function listOperatorKeys(admin) {
|
|
52
|
+
const answer = await adminRequest(admin, "GET", "/v1/admin/keys");
|
|
53
|
+
if (answer.status !== 200 || !Array.isArray(answer.body.keys))
|
|
54
|
+
throw refused("GET", "/v1/admin/keys", answer);
|
|
55
|
+
return answer.body.keys;
|
|
56
|
+
}
|
|
57
|
+
/** Deletes key `id`: false when there is none. */
|
|
58
|
+
export async function deleteOperatorKey(admin, id) {
|
|
59
|
+
const answer = await adminRequest(admin, "DELETE", keyPath(id));
|
|
60
|
+
if (answer.status === 200)
|
|
61
|
+
return true;
|
|
62
|
+
if (answer.status === 404 && answer.body.message === `No key ${id}`)
|
|
63
|
+
return false;
|
|
64
|
+
throw refused("DELETE", keyPath(id), answer);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Whether `key` reaches the Tenant API at `runtimeUrl`: one authenticated read
|
|
68
|
+
* (`GET /v1/tenant`). An unknown key is the opaque 404; another answer is an error.
|
|
69
|
+
*/
|
|
70
|
+
export async function keyAuthenticates(fetch, runtimeUrl, key) {
|
|
71
|
+
let response;
|
|
72
|
+
try {
|
|
73
|
+
response = await fetch(`${runtimeUrl}/v1/tenant`, {
|
|
74
|
+
headers: {
|
|
75
|
+
authorization: `Bearer ${key}`,
|
|
76
|
+
[PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
|
|
77
|
+
accept: "application/json",
|
|
78
|
+
},
|
|
79
|
+
redirect: "error",
|
|
80
|
+
signal: AbortSignal.timeout(10_000),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
throw new CliError(`The Runtime at ${runtimeUrl} did not answer (${error instanceof Error ? error.message : String(error)}).`, 7);
|
|
85
|
+
}
|
|
86
|
+
await response.arrayBuffer().catch(() => undefined);
|
|
87
|
+
if (response.ok)
|
|
88
|
+
return true;
|
|
89
|
+
if (response.status === 401 || response.status === 403 || response.status === 404)
|
|
90
|
+
return false;
|
|
91
|
+
throw new CliError(`GET ${runtimeUrl}/v1/tenant returned ${response.status}.`, 7);
|
|
92
|
+
}
|
|
93
|
+
const LOCK_TIMEOUT_MS = 30_000;
|
|
94
|
+
/** A lock older than this was left by a process that died. */
|
|
95
|
+
const LOCK_STALE_MS = 60_000;
|
|
96
|
+
/** Runs `fn` holding the lock file `path` (created exclusively), so one process puts a key. */
|
|
97
|
+
async function withLock(path, fn) {
|
|
98
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
99
|
+
for (;;) {
|
|
100
|
+
try {
|
|
101
|
+
const handle = await open(path, "wx", 0o600);
|
|
102
|
+
await handle.close();
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error.code !== "EEXIST")
|
|
107
|
+
throw error;
|
|
108
|
+
const held = await stat(path).catch(() => undefined);
|
|
109
|
+
if (held && Date.now() - held.mtimeMs > LOCK_STALE_MS) {
|
|
110
|
+
await rm(path, { force: true });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (Date.now() > deadline)
|
|
114
|
+
throw new CliError(`Another nylorun command holds ${path}. Remove it if none runs.`, 1);
|
|
115
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return await fn();
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
await rm(path, { force: true });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The operator key `id` kept in the Host root file `file` (0600): the file's key while it
|
|
127
|
+
* still authenticates, else `adopt` when it does (a key a project already holds), else a new
|
|
128
|
+
* key put through the Admin API and written to the file. One process at a time (`lock`), so
|
|
129
|
+
* concurrent commands share one key instead of rotating each other's.
|
|
130
|
+
*/
|
|
131
|
+
export async function hostKey(options) {
|
|
132
|
+
const { admin, runtimeUrl, id, file } = options;
|
|
133
|
+
return await withLock(options.lock, async () => {
|
|
134
|
+
const kept = await readCredentialsFile(file);
|
|
135
|
+
if (kept && (await keyAuthenticates(admin.fetch, runtimeUrl, kept.applicationKey)))
|
|
136
|
+
return { ...kept, put: false };
|
|
137
|
+
if (options.adopt && (await keyAuthenticates(admin.fetch, runtimeUrl, options.adopt.applicationKey))) {
|
|
138
|
+
await writeCredentialsFile(file, options.adopt);
|
|
139
|
+
return { ...options.adopt, put: false };
|
|
140
|
+
}
|
|
141
|
+
const put = await putOperatorKey(admin, id);
|
|
142
|
+
const credentials = { applicationKey: put.key, principalId: put.id };
|
|
143
|
+
await writeCredentialsFile(file, credentials);
|
|
144
|
+
return { ...credentials, put: true };
|
|
145
|
+
});
|
|
146
|
+
}
|
package/dist/stack/paths.d.ts
CHANGED
|
@@ -8,10 +8,42 @@ export interface StackPaths {
|
|
|
8
8
|
credentials: string;
|
|
9
9
|
/** host-state.json: written only by a launcher-managed Runtime. */
|
|
10
10
|
state: string;
|
|
11
|
+
/**
|
|
12
|
+
* cli-credentials.json: the operator key `cli` that `nylorun` commands use outside a linked
|
|
13
|
+
* project (`nylorun sandbox`), mode 0600.
|
|
14
|
+
*/
|
|
15
|
+
cliCredentials: string;
|
|
16
|
+
/**
|
|
17
|
+
* project-credentials.json: the operator key `project` that `nylorun start` gives every
|
|
18
|
+
* project it links to this Tenant (a copy of their `.nylorun/credentials.json`), mode 0600.
|
|
19
|
+
*/
|
|
20
|
+
projectCredentials: string;
|
|
21
|
+
/** operator-keys.lock: held while a command puts one of the keys above. */
|
|
22
|
+
keysLock: string;
|
|
11
23
|
/** tenant.json: the Tenant's name and the project it was created for. */
|
|
12
24
|
record: string;
|
|
25
|
+
/**
|
|
26
|
+
* identity.yaml: the trusted issuers (F9 I2), written by the operator. When it exists the
|
|
27
|
+
* runtime reads it at boot (`NYLORUN_IDENTITY_FILE`).
|
|
28
|
+
*/
|
|
29
|
+
identity: string;
|
|
13
30
|
/** The Tenant directory: homes, sandboxes, plugin data, logs. */
|
|
14
31
|
tenant: string;
|
|
32
|
+
/**
|
|
33
|
+
* The Tenant directory's parts the harness container mounts (F6.2): workspaces, plugin data,
|
|
34
|
+
* and the home and temporary directory of MCP stdio servers.
|
|
35
|
+
*/
|
|
36
|
+
harness: {
|
|
37
|
+
sandboxes: string;
|
|
38
|
+
pluginData: string;
|
|
39
|
+
home: string;
|
|
40
|
+
tmp: string;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Plugin roots on this machine (`plugins/`), mounted read-only at the same path into the
|
|
44
|
+
* runtime and harness containers, so a stdio MCP server's plugin root resolves there.
|
|
45
|
+
*/
|
|
46
|
+
plugins: string;
|
|
15
47
|
/** The keys directory, mounted only into the gateway (F4.2). */
|
|
16
48
|
keys: string;
|
|
17
49
|
/** The Tenant's vault key (KEK), `keys/vault-kek`. */
|
package/dist/stack/paths.js
CHANGED
|
@@ -7,8 +7,19 @@ export function stackPaths(hostRoot) {
|
|
|
7
7
|
config: join(root, "host.json"),
|
|
8
8
|
credentials: join(root, "host-credentials.json"),
|
|
9
9
|
state: join(root, "host-state.json"),
|
|
10
|
+
cliCredentials: join(root, "cli-credentials.json"),
|
|
11
|
+
projectCredentials: join(root, "project-credentials.json"),
|
|
12
|
+
keysLock: join(root, "operator-keys.lock"),
|
|
10
13
|
record: join(root, "tenant.json"),
|
|
14
|
+
identity: join(root, "identity.yaml"),
|
|
11
15
|
tenant: join(root, "tenant"),
|
|
16
|
+
harness: {
|
|
17
|
+
sandboxes: join(root, "tenant", "sandboxes"),
|
|
18
|
+
pluginData: join(root, "tenant", "plugin-data"),
|
|
19
|
+
home: join(root, "tenant", "home"),
|
|
20
|
+
tmp: join(root, "tenant", "tmp"),
|
|
21
|
+
},
|
|
22
|
+
plugins: join(root, "plugins"),
|
|
12
23
|
keys: join(root, "keys"),
|
|
13
24
|
vaultKey: join(root, "keys", "vault-kek"),
|
|
14
25
|
home: join(root, "home"),
|
package/dist/stack/prepare.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type StackEnv } from "./env-file.js";
|
|
1
|
+
import { type SandboxStackEnv, type StackEnv } from "./env-file.js";
|
|
2
2
|
import { type HostConfigFile } from "./host-files.js";
|
|
3
3
|
import type { StackImages } from "./images.js";
|
|
4
4
|
import type { StackPaths } from "./paths.js";
|
|
@@ -16,7 +16,8 @@ export declare function readStackEnv(paths: Pick<StackPaths, "env">): Promise<im
|
|
|
16
16
|
* Write everything `docker compose up` needs under the Host root:
|
|
17
17
|
* host.json, host-credentials.json (0600), docker/compose.yaml,
|
|
18
18
|
* docker/.env (0600) and docker/restate-identity.pem (0600). Ports and the
|
|
19
|
-
* Postgres password persist in .env; the identity key persists in its PEM.
|
|
19
|
+
* Postgres password persist in .env; the identity key persists in its PEM. The harness token
|
|
20
|
+
* and mode persist too; whether Restate's UI is published is decided on every start.
|
|
20
21
|
*/
|
|
21
22
|
export declare function prepareStack(input: {
|
|
22
23
|
paths: StackPaths;
|
|
@@ -31,11 +32,9 @@ export declare function prepareStack(input: {
|
|
|
31
32
|
ports: PortProbe;
|
|
32
33
|
/** Ports other Tenants keep in their `.env`: never chosen for a port not chosen yet. */
|
|
33
34
|
reserved?: ReadonlySet<number>;
|
|
34
|
-
/** `NYLORUN_DERIVED_PRINCIPALS` from the environment of `nylorun start`; else kept. */
|
|
35
|
-
derivedPrincipals?: string;
|
|
36
35
|
/**
|
|
37
|
-
* Changes to the origins that may embed Studio: `reset`
|
|
38
|
-
*
|
|
36
|
+
* Changes to the origins that may embed Studio: `reset` empties the list (the
|
|
37
|
+
* default), `add` appends (for example a desktop app's dev server).
|
|
39
38
|
*/
|
|
40
39
|
studioEmbedOrigins?: {
|
|
41
40
|
add?: readonly string[];
|
|
@@ -43,4 +42,11 @@ export declare function prepareStack(input: {
|
|
|
43
42
|
};
|
|
44
43
|
/** The measurement id Studio reports page views to; absent when telemetry is off. */
|
|
45
44
|
studioAnalyticsId?: string;
|
|
45
|
+
/**
|
|
46
|
+
* The sandboxes settings (`nylorun sandbox enable`); null removes them (`disable`).
|
|
47
|
+
* Undefined keeps the persisted ones while `sandboxes/cluster.json` exists.
|
|
48
|
+
*/
|
|
49
|
+
sandboxes?: SandboxStackEnv | null;
|
|
50
|
+
/** Publish Restate's UI and admin port (`start --restate-ui`); undefined keeps the last start's choice. */
|
|
51
|
+
restateUi?: boolean;
|
|
46
52
|
}): Promise<PreparedStack>;
|
package/dist/stack/prepare.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
2
3
|
import { chmod, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
4
|
import { renderComposeFile } from "./compose-file.js";
|
|
4
5
|
import { parseFrameAncestors } from "@nylorun/core/contracts";
|
|
5
6
|
import { CliError } from "../errors.js";
|
|
6
|
-
import { DEFAULT_STUDIO_FRAME_ANCESTORS,
|
|
7
|
+
import { DEFAULT_STUDIO_FRAME_ANCESTORS, parsePersisted, renderEnvFile, } from "./env-file.js";
|
|
8
|
+
import { sandboxesEnabled } from "../sandbox/cluster-file.js";
|
|
7
9
|
import { ensureHostCredentials, ensureVaultKey, ensureHostLayout, writeStackHostConfig, } from "./host-files.js";
|
|
8
10
|
import { choosePort, DEFAULT_PORTS } from "./ports.js";
|
|
9
11
|
import { ensureIdentityKey } from "./restate-identity.js";
|
|
@@ -30,7 +32,8 @@ export async function readStackEnv(paths) {
|
|
|
30
32
|
* Write everything `docker compose up` needs under the Host root:
|
|
31
33
|
* host.json, host-credentials.json (0600), docker/compose.yaml,
|
|
32
34
|
* docker/.env (0600) and docker/restate-identity.pem (0600). Ports and the
|
|
33
|
-
* Postgres password persist in .env; the identity key persists in its PEM.
|
|
35
|
+
* Postgres password persist in .env; the identity key persists in its PEM. The harness token
|
|
36
|
+
* and mode persist too; whether Restate's UI is published is decided on every start.
|
|
34
37
|
*/
|
|
35
38
|
export async function prepareStack(input) {
|
|
36
39
|
const { paths } = input;
|
|
@@ -69,6 +72,12 @@ export async function prepareStack(input) {
|
|
|
69
72
|
postgresPassword: persisted.postgresPassword ?? randomBytes(24).toString("hex"),
|
|
70
73
|
// Kept across starts: a new token would recreate the runtime and gateway containers.
|
|
71
74
|
gatesToken: persisted.gatesToken ?? randomBytes(32).toString("hex"),
|
|
75
|
+
// Kept across starts: a new token would recreate the runtime and harness containers.
|
|
76
|
+
harnessToken: persisted.harnessToken ?? randomBytes(32).toString("hex"),
|
|
77
|
+
harness: persisted.harness ?? "remote",
|
|
78
|
+
restateUi: input.restateUi ?? persisted.restateUi ?? false,
|
|
79
|
+
// Kept across starts: a new key would recreate the runtime, gateway and rustfs containers.
|
|
80
|
+
objectStoreSecretKey: persisted.objectStoreSecretKey ?? randomBytes(32).toString("hex"),
|
|
72
81
|
restateIdentityKey: identity.publicKey,
|
|
73
82
|
uid: input.uid,
|
|
74
83
|
gid: input.gid,
|
|
@@ -78,10 +87,13 @@ export async function prepareStack(input) {
|
|
|
78
87
|
studioFrameAncestors,
|
|
79
88
|
studioAnalyticsId: input.studioAnalyticsId ?? "",
|
|
80
89
|
tenantName: input.name,
|
|
81
|
-
derivedPrincipals: (input.derivedPrincipals?.trim()
|
|
82
|
-
? parseDerivedPrincipals(input.derivedPrincipals, "NYLORUN_DERIVED_PRINCIPALS")
|
|
83
|
-
: (persisted.derivedPrincipals ?? ["project"])).join(","),
|
|
84
90
|
};
|
|
91
|
+
const sandboxes = input.sandboxes === null
|
|
92
|
+
? undefined
|
|
93
|
+
: (input.sandboxes ?? (sandboxesEnabled(paths.root) ? persisted.sandboxes : undefined));
|
|
94
|
+
// The sandboxes image follows the Runtime's, like the runtime image, on every start.
|
|
95
|
+
if (sandboxes)
|
|
96
|
+
env.sandboxes = { ...sandboxes, image: input.images.sandboxes };
|
|
85
97
|
const { adminKey } = await ensureHostCredentials(paths);
|
|
86
98
|
await ensureVaultKey(paths);
|
|
87
99
|
const host = await writeStackHostConfig(paths, {
|
|
@@ -90,6 +102,12 @@ export async function prepareStack(input) {
|
|
|
90
102
|
runtimeVersion: input.runtimeVersion,
|
|
91
103
|
});
|
|
92
104
|
await writeFileMode(paths.env, renderEnvFile(env), 0o600);
|
|
93
|
-
await writeFileMode(paths.compose, renderComposeFile(input.project, input.name
|
|
105
|
+
await writeFileMode(paths.compose, renderComposeFile(input.project, input.name, {
|
|
106
|
+
harness: env.harness,
|
|
107
|
+
...(env.sandboxes ? { sandboxes: true } : {}),
|
|
108
|
+
...(env.restateUi ? { restateUi: true } : {}),
|
|
109
|
+
// The trusted issuers, when the operator wrote an identity file; a change takes a restart.
|
|
110
|
+
...(existsSync(paths.identity) ? { identity: true } : {}),
|
|
111
|
+
}), 0o644);
|
|
94
112
|
return { env, host, adminKey, firstRun };
|
|
95
113
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type StackDeps } from "./commands.js";
|
|
2
|
+
export declare const sandboxUsage = " sandbox ls [--tenant <name>] [--label <key=value>]... [--json]\n the Tenant's sandboxes: id, kind, state, attached sessions and labels\n sandbox rm <id> [--tenant <name>] delete a sandbox and its files (refused while a turn runs in it)\n sandbox enable --context <name> [--tenant <name>] [--host-address <ip>] [--bind-address <ip>] [--no-pull]\n run the Tenant's sandboxes as pods on that kubeconfig context (agent-sandbox v1.0.5)\n sandbox disable [--tenant <name>] [--delete-namespace]\n stop the sandboxes service and forget the cluster\n sandbox status [--tenant <name>] [--json]\n the cluster, the sandboxes service's readiness and what it reports";
|
|
3
|
+
/** `nylorun sandbox <ls|rm|enable|disable|status> ...` */
|
|
4
|
+
export declare function sandboxCommand(deps: StackDeps, args: readonly string[]): Promise<number>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nylorun sandbox ls | rm`: the running local Tenant's sandbox resources (Host feature
|
|
3
|
+
* `sandboxes`), through its Tenant API with an operator key (`runningTenantApi`). Neither starts the
|
|
4
|
+
* Tenant. `nylorun sandbox enable|disable|status` (sandbox pods on a cluster, F7.2) live in
|
|
5
|
+
* `../sandbox/commands.ts`.
|
|
6
|
+
*/
|
|
7
|
+
import { PROTOCOL_HEADER, PROTOCOL_VERSION } from "@nylorun/core/compatibility";
|
|
8
|
+
import { CliError } from "../errors.js";
|
|
9
|
+
import { runSandboxCommand, sandboxUsage as clusterUsage } from "../sandbox/commands.js";
|
|
10
|
+
import { spawnKubectl } from "../sandbox/kubectl.js";
|
|
11
|
+
import { parseStackFlags, runningTenantApi } from "./commands.js";
|
|
12
|
+
export const sandboxUsage = ` sandbox ls [--tenant <name>] [--label <key=value>]... [--json]
|
|
13
|
+
the Tenant's sandboxes: id, kind, state, attached sessions and labels
|
|
14
|
+
sandbox rm <id> [--tenant <name>] delete a sandbox and its files (refused while a turn runs in it)
|
|
15
|
+
${clusterUsage}`;
|
|
16
|
+
const usageError = (message) => new CliError(message, 2);
|
|
17
|
+
async function call(deps, api, method, path) {
|
|
18
|
+
const response = await deps.fetch(`${api.runtimeUrl}${path}`, {
|
|
19
|
+
method,
|
|
20
|
+
headers: {
|
|
21
|
+
authorization: `Bearer ${api.applicationKey}`,
|
|
22
|
+
[PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
|
|
23
|
+
accept: "application/json",
|
|
24
|
+
},
|
|
25
|
+
redirect: "error",
|
|
26
|
+
signal: AbortSignal.timeout(15_000),
|
|
27
|
+
});
|
|
28
|
+
const body = (await response.json().catch(() => ({})));
|
|
29
|
+
if (response.status === 404 && path === "/v1/sandboxes")
|
|
30
|
+
throw new CliError(`Tenant ${api.name} runs a Runtime without sandboxes. Update it: npx nylorun@latest start.`, 1);
|
|
31
|
+
if (!response.ok && response.status !== 404)
|
|
32
|
+
throw new CliError(`${method} ${path} returned ${response.status}${typeof body.message === "string" ? `: ${body.message}` : ""}`, 1);
|
|
33
|
+
return { status: response.status, body };
|
|
34
|
+
}
|
|
35
|
+
function labelsText(labels) {
|
|
36
|
+
const entries = Object.entries(labels);
|
|
37
|
+
return entries.length === 0 ? "-" : entries.map(([key, value]) => `${key}=${value}`).join(",");
|
|
38
|
+
}
|
|
39
|
+
async function ls(deps, args) {
|
|
40
|
+
const usage = "nylorun sandbox ls [--tenant <name>] [--label <key=value>]... [--json]";
|
|
41
|
+
const flags = parseStackFlags(args, { booleans: ["--json"], values: ["--tenant"], lists: ["--label"] }, usage);
|
|
42
|
+
if (flags.rest.length)
|
|
43
|
+
throw usageError(`Usage: ${usage}`);
|
|
44
|
+
const query = new URLSearchParams();
|
|
45
|
+
for (const label of flags.lists.get("--label") ?? []) {
|
|
46
|
+
if (label.indexOf("=") <= 0)
|
|
47
|
+
throw usageError(`--label must be key=value, not ${label}`);
|
|
48
|
+
query.append("label", label);
|
|
49
|
+
}
|
|
50
|
+
const name = flags.values.get("--tenant");
|
|
51
|
+
const api = await runningTenantApi(deps, name === undefined ? {} : { name });
|
|
52
|
+
const search = query.toString();
|
|
53
|
+
const { body } = await call(deps, api, "GET", `/v1/sandboxes${search ? `?${search}` : ""}`);
|
|
54
|
+
const sandboxes = (body.sandboxes ?? []);
|
|
55
|
+
if (flags.booleans.has("--json")) {
|
|
56
|
+
deps.out(JSON.stringify(sandboxes, null, 2));
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
if (sandboxes.length === 0) {
|
|
60
|
+
deps.out(`No sandboxes on Tenant ${api.name}.`);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
const rows = [
|
|
64
|
+
["ID", "KIND", "STATE", "SESSIONS", "LABELS"],
|
|
65
|
+
...sandboxes.map((sandbox) => [
|
|
66
|
+
sandbox.id,
|
|
67
|
+
sandbox.kind,
|
|
68
|
+
sandbox.state,
|
|
69
|
+
String(sandbox.sessions.length),
|
|
70
|
+
labelsText(sandbox.labels),
|
|
71
|
+
]),
|
|
72
|
+
];
|
|
73
|
+
const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
|
|
74
|
+
for (const row of rows)
|
|
75
|
+
deps.out(row
|
|
76
|
+
.map((cell, column) => (column === row.length - 1 ? cell : cell.padEnd(widths[column])))
|
|
77
|
+
.join(" "));
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
async function rm(deps, args) {
|
|
81
|
+
const usage = "nylorun sandbox rm <id> [--tenant <name>]";
|
|
82
|
+
const flags = parseStackFlags(args, { values: ["--tenant"] }, usage);
|
|
83
|
+
if (flags.rest.length !== 1)
|
|
84
|
+
throw usageError(`Usage: ${usage}`);
|
|
85
|
+
const id = flags.rest[0];
|
|
86
|
+
const name = flags.values.get("--tenant");
|
|
87
|
+
const api = await runningTenantApi(deps, name === undefined ? {} : { name });
|
|
88
|
+
const { status, body } = await call(deps, api, "DELETE", `/v1/sandboxes/${encodeURIComponent(id)}`);
|
|
89
|
+
if (status === 404 || body.deleted !== true) {
|
|
90
|
+
deps.err(`No sandbox ${id} on Tenant ${api.name}.`);
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
deps.out(`Deleted sandbox ${id} and its files.`);
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
/** `nylorun sandbox <ls|rm|enable|disable|status> ...` */
|
|
97
|
+
export async function sandboxCommand(deps, args) {
|
|
98
|
+
const [sub, ...rest] = args;
|
|
99
|
+
if (sub === "ls" || sub === "list")
|
|
100
|
+
return ls(deps, rest);
|
|
101
|
+
if (sub === "rm" || sub === "delete")
|
|
102
|
+
return rm(deps, rest);
|
|
103
|
+
if (sub === "enable" || sub === "disable" || sub === "status")
|
|
104
|
+
return runSandboxCommand(args, { stack: deps, kubectl: spawnKubectl(deps.env) });
|
|
105
|
+
throw usageError(`Usage:\n${sandboxUsage}`);
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nylorun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0-beta",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "Set up and run local Nylorun Tenants (Runtime and Studio) with Docker Compose",
|
|
@@ -30,18 +30,18 @@
|
|
|
30
30
|
"prepack": "npm run build"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@nylorun/core": "0.
|
|
33
|
+
"@nylorun/core": "0.13.0-beta"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
37
37
|
"@types/node": "^26.5.0",
|
|
38
|
-
"vitest": "^5.0.
|
|
38
|
+
"vitest": "^5.0.2"
|
|
39
39
|
},
|
|
40
40
|
"bin": {
|
|
41
41
|
"nylorun": "dist/cli.js"
|
|
42
42
|
},
|
|
43
43
|
"nylorun": {
|
|
44
|
-
"runtime": "0.
|
|
45
|
-
"studio": "0.
|
|
44
|
+
"runtime": "0.17.0-beta",
|
|
45
|
+
"studio": "0.20.0-beta"
|
|
46
46
|
}
|
|
47
47
|
}
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
/** The derived principal a Project uses; the local Runtime registers it on its Tenant. */
|
|
2
|
-
export declare const PROJECT_PRINCIPAL_ID = "project";
|
|
3
|
-
/**
|
|
4
|
-
* Key of a derived principal on the local Tenant, from the admin key. A copy of
|
|
5
|
-
* `deriveTenantKey` in `@nylorun/admin` (nylorun depends on `@nylorun/core` only); both are
|
|
6
|
-
* tested against the same fixed vector.
|
|
7
|
-
*/
|
|
8
|
-
export declare function deriveTenantKey(adminKey: string, tenantId: string, principalId: string): string;
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { createHmac } from "node:crypto";
|
|
2
|
-
/** The derived principal a Project uses; the local Runtime registers it on its Tenant. */
|
|
3
|
-
export const PROJECT_PRINCIPAL_ID = "project";
|
|
4
|
-
/**
|
|
5
|
-
* Key of a derived principal on the local Tenant, from the admin key. A copy of
|
|
6
|
-
* `deriveTenantKey` in `@nylorun/admin` (nylorun depends on `@nylorun/core` only); both are
|
|
7
|
-
* tested against the same fixed vector.
|
|
8
|
-
*/
|
|
9
|
-
export function deriveTenantKey(adminKey, tenantId, principalId) {
|
|
10
|
-
const message = Buffer.concat([
|
|
11
|
-
Buffer.from("nylorun/principal/v1", "utf8"),
|
|
12
|
-
Buffer.from([0]),
|
|
13
|
-
Buffer.from(principalId, "utf8"),
|
|
14
|
-
Buffer.from([0]),
|
|
15
|
-
Buffer.from(tenantId, "utf8"),
|
|
16
|
-
]);
|
|
17
|
-
return createHmac("sha256", Buffer.from(adminKey, "utf8")).update(message).digest("hex");
|
|
18
|
-
}
|