datagrok-tools 6.7.9 → 6.7.10

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.
@@ -0,0 +1,244 @@
1
+ "use strict";
2
+
3
+ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
4
+ Object.defineProperty(exports, "__esModule", {
5
+ value: true
6
+ });
7
+ exports.SHARE_ROOT = exports.LABEL = exports.DockerError = exports.AGENT_REPOSITORY = void 0;
8
+ exports.canonicalFolder = canonicalFolder;
9
+ exports.canonicalHost = canonicalHost;
10
+ exports.containerName = containerName;
11
+ exports.containerUrl = containerUrl;
12
+ exports.docker = docker;
13
+ exports.dockerAvailable = dockerAvailable;
14
+ exports.dockerOk = dockerOk;
15
+ exports.ensureImage = ensureImage;
16
+ exports.hostFolder = hostFolder;
17
+ exports.isAgentImage = isAgentImage;
18
+ exports.isRunning = isRunning;
19
+ exports.linuxUser = linuxUser;
20
+ exports.proc = exports.logs = void 0;
21
+ exports.ps = ps;
22
+ exports.rm = void 0;
23
+ exports.runArgs = runArgs;
24
+ exports.sameSettings = sameSettings;
25
+ exports.stop = exports.start = void 0;
26
+ var _child_process = _interopRequireDefault(require("child_process"));
27
+ var _crypto = _interopRequireDefault(require("crypto"));
28
+ var _fs = _interopRequireDefault(require("fs"));
29
+ var _os = _interopRequireDefault(require("os"));
30
+ var _path = _interopRequireDefault(require("path"));
31
+ /** The `docker` CLI for `grok connect-folder`: always an argument vector, never a shell, so no quoting or MSYS path mangling. */
32
+
33
+ const LABEL = exports.LABEL = 'ai.datagrok.folder';
34
+ const SHARE_ROOT = exports.SHARE_ROOT = '/share';
35
+ /** The only image run with the user's folder mounted unless `--image` names another. */
36
+ const AGENT_REPOSITORY = exports.AGENT_REPOSITORY = 'datagrok/proxy_storage';
37
+
38
+ /** Whether [image] is [AGENT_REPOSITORY] on Docker Hub, by tag or digest: a server cannot name any other. */
39
+ function isAgentImage(image) {
40
+ return /^((docker\.io|index\.docker\.io)\/)?datagrok\/proxy_storage(:\w[\w.-]{0,127})?(@sha256:[0-9a-f]{64})?$/.test(image);
41
+ }
42
+ const DOCKER_HOST = 'host.docker.internal';
43
+ class DockerError extends Error {
44
+ constructor(message, stderr = '') {
45
+ super(message);
46
+ this.stderr = stderr;
47
+ this.name = 'DockerError';
48
+ }
49
+ }
50
+
51
+ /** Where `docker` is started; the tests replace it, since a module mock cannot reach the transpiled require. */
52
+ exports.DockerError = DockerError;
53
+ const proc = exports.proc = {
54
+ spawn: _child_process.default.spawn
55
+ };
56
+ /** Runs `docker <args>`; [inherit] attaches the terminal (logs -f, a foreground share). */
57
+ function docker(args, inherit = false) {
58
+ return new Promise((resolve, reject) => {
59
+ const child = proc.spawn('docker', args, {
60
+ stdio: inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'],
61
+ windowsHide: true
62
+ });
63
+ let stdout = '';
64
+ let stderr = '';
65
+ child.stdout?.on('data', d => stdout += d);
66
+ child.stderr?.on('data', d => stderr += d);
67
+ child.on('error', e => reject(e?.code === 'ENOENT' ? new DockerError('grok connect-folder runs the folder agent in Docker, which is not installed. ' + 'Install Docker Desktop (Windows/macOS) or Docker Engine (Linux).') : e));
68
+ child.on('close', code => resolve({
69
+ code: code ?? 1,
70
+ stdout,
71
+ stderr
72
+ }));
73
+ });
74
+ }
75
+ async function dockerOk(args) {
76
+ const r = await docker(args);
77
+ if (r.code !== 0) throw new DockerError(r.stderr.trim() || `docker ${args[0]} failed with exit code ${r.code}`, r.stderr);
78
+ return r.stdout;
79
+ }
80
+ async function dockerAvailable() {
81
+ const version = await docker(['version', '--format', '{{.Server.Version}}']);
82
+ if (version.code !== 0) throw new DockerError('Docker is installed but not running: start Docker Desktop / the docker service.', version.stderr);
83
+ const osType = (await dockerOk(['info', '--format', '{{.OSType}}'])).trim();
84
+ if (osType !== 'linux') {
85
+ throw new DockerError(`Docker runs ${osType} containers; the folder agent needs Linux containers. ` + 'Switch Docker Desktop to Linux containers.');
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The absolute path Docker mounts for [folder]. Refuses network and `\\wsl$` paths, which Docker Desktop
91
+ * cannot bind; [note] is set for a Windows drive seen from WSL, which works but is slow.
92
+ */
93
+ function hostFolder(folder, platform = process.platform, resolve = p => _fs.default.realpathSync.native(p)) {
94
+ const raw = folder.trim();
95
+ if (/^[\\/]{2}(wsl\$|wsl\.localhost)([\\/]|$)/i.test(raw)) throw new Error(`${raw} is inside WSL: run grok connect-folder inside WSL for WSL paths`);
96
+ if (/^[\\/]{2}[^\\/]/.test(raw)) throw new Error(`${raw} is a network path; Docker cannot mount it. Connect a local folder instead`);
97
+ const p = platform === 'win32' ? _path.default.win32 : _path.default.posix;
98
+ const resolved = resolve(p.resolve(raw));
99
+ if (/^[\\/]{2}/.test(resolved)) throw new Error(`${raw} resolves to the network path ${resolved}; Docker cannot mount it`);
100
+ const note = platform === 'linux' && /^\/mnt\/[a-z](\/|$)/i.test(resolved) ? 'Windows drives seen from WSL (/mnt/...) are slow to read through Docker; consider a folder in the WSL filesystem' : undefined;
101
+ return {
102
+ path: resolved,
103
+ note
104
+ };
105
+ }
106
+
107
+ /**
108
+ * The folder the server derives the share id from: `/` separators, no trailing slash, lower case where paths are
109
+ * case-insensitive, and a Windows drive seen from WSL (`/mnt/c/x`) the same as on Windows (`c:/x`).
110
+ */
111
+ function canonicalFolder(abs, platform = process.platform) {
112
+ let f = abs.replace(/\\/g, '/');
113
+ const wslDrive = platform === 'linux' ? /^\/mnt\/([a-z])(\/.*)?$/i.exec(f) : null;
114
+ if (wslDrive) f = `${wslDrive[1]}:${wslDrive[2] ?? '/'}`;
115
+ while (f.length > 1 && f.endsWith('/') && !f.endsWith(':/')) f = f.slice(0, -1);
116
+ return platform === 'win32' || platform === 'darwin' || wslDrive ? f.toLowerCase() : f;
117
+ }
118
+
119
+ /** `Mac.local` and `Mac` are one machine: DHCP and macOS flip between them. */
120
+ function canonicalHost(hostname = _os.default.hostname()) {
121
+ return hostname.trim().toLowerCase().split('.')[0];
122
+ }
123
+
124
+ /** `localhost` in a container is the container itself; Docker reaches the host as `host.docker.internal`. */
125
+ function containerUrl(url) {
126
+ const u = new URL(url);
127
+ if (!['localhost', '127.0.0.1', '[::1]'].includes(u.hostname)) return {
128
+ url,
129
+ rewritten: false
130
+ };
131
+ u.hostname = DOCKER_HOST;
132
+ return {
133
+ url: u.href.replace(/\/$/, ''),
134
+ rewritten: true
135
+ };
136
+ }
137
+
138
+ /** The same login shares a folder under the same id on every server, so the name hashes the server in. */
139
+ function containerName(apiRoot, connectionId, foreground = false) {
140
+ const hash = _crypto.default.createHash('sha256').update(`${apiRoot}|${connectionId}`).digest('hex').slice(0, 12);
141
+ return `grok-folder-${hash}${foreground ? '-fg' : ''}`;
142
+ }
143
+
144
+ /** A `--mount` field: the value is CSV, so a path with a comma or a quote must be quoted. */
145
+ function mountField(field) {
146
+ return /[",]/.test(field) ? `"${field.replace(/"/g, '""')}"` : field;
147
+ }
148
+ /** Whether [c] runs [spec] as is: the mount, the mode, the image and the user are fixed at `docker run`. */
149
+ function sameSettings(c, spec) {
150
+ const l = c.labels;
151
+ return c.image === spec.image && c.imageId === (spec.imageId ?? c.imageId) && l[`${LABEL}.folder`] === spec.folder && l[`${LABEL}.readonly`] === `${spec.readOnly}` && (l[`${LABEL}.user`] ?? '') === (runUser(spec) ?? '');
152
+ }
153
+ function runUser(spec) {
154
+ return (spec.platform ?? process.platform) === 'linux' ? spec.user : undefined;
155
+ }
156
+ function runArgs(spec) {
157
+ const platform = spec.platform ?? process.platform;
158
+ const server = containerUrl(spec.apiRoot);
159
+ const labels = {
160
+ [LABEL]: '1',
161
+ [`${LABEL}.connection`]: spec.connectionId,
162
+ [`${LABEL}.alias`]: spec.alias,
163
+ [`${LABEL}.url`]: spec.apiRoot,
164
+ [`${LABEL}.name`]: spec.name,
165
+ [`${LABEL}.folder`]: spec.folder,
166
+ [`${LABEL}.readonly`]: `${spec.readOnly}`,
167
+ [`${LABEL}.user`]: runUser(spec) ?? '',
168
+ [`${LABEL}.key`]: spec.keyId ?? ''
169
+ };
170
+ const args = ['run', '--name', containerName(spec.apiRoot, spec.connectionId, spec.foreground)];
171
+ args.push(...(spec.foreground ? ['--rm'] : ['-d', '--restart', 'unless-stopped']));
172
+ for (const [k, v] of Object.entries(labels)) args.push('--label', `${k}=${v}`);
173
+ args.push('--mount', ['type=bind', mountField(`source=${spec.folder}`), `target=${SHARE_ROOT}`, ...(spec.readOnly ? ['readonly'] : [])].join(','));
174
+ args.push('--env-file', spec.envFile);
175
+ args.push('--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--memory', '256m', '-e', 'DEBUG_MODE=false');
176
+ if (runUser(spec)) args.push('--user', runUser(spec));
177
+ // Docker Desktop resolves host.docker.internal by itself; Docker Engine on Linux (and WSL) needs it mapped to the host.
178
+ if (platform === 'linux' && server.rewritten) args.push('--add-host', `${DOCKER_HOST}:host-gateway`);
179
+ args.push(spec.image, '--tunnel', '--server', server.url, '--root', SHARE_ROOT, '--connection', spec.connectionId, '--host-label', spec.hostLabel, '--folder-label', spec.folder);
180
+ if (spec.readOnly) args.push('--read-only');
181
+ return args;
182
+ }
183
+ function linuxUser(platform = process.platform) {
184
+ return platform === 'linux' && process.getuid && process.getgid ? `${process.getuid()}:${process.getgid()}` : undefined;
185
+ }
186
+
187
+ /** Share containers with all of [labels], stopped ones included. */
188
+ async function ps(labels = {}) {
189
+ const filters = [`label=${LABEL}=1`, ...Object.entries(labels).map(([k, v]) => `label=${k}=${v}`)];
190
+ const ids = (await dockerOk(['ps', '-a', '-q', ...filters.flatMap(f => ['--filter', f])])).split(/\s+/).filter(s => s);
191
+ if (!ids.length) return [];
192
+ // `docker ps --format` joins labels with commas, which a folder name can contain; inspect keeps them apart.
193
+ return JSON.parse(await dockerOk(['inspect', ...ids])).map(c => ({
194
+ id: c.Id,
195
+ name: String(c.Name ?? '').replace(/^\//, ''),
196
+ state: c.State?.Status ?? '',
197
+ image: c.Config?.Image ?? '',
198
+ imageId: c.Image ?? '',
199
+ labels: c.Config?.Labels ?? {},
200
+ mounts: c.Mounts ?? []
201
+ }));
202
+ }
203
+
204
+ /**
205
+ * The local id of [image]. A tag is pulled every time, since the server moves it, and Docker's copy is used when the
206
+ * pull fails ([pullError]: offline, a local build); a digest is pulled only when missing.
207
+ */
208
+ async function ensureImage(image) {
209
+ const local = async () => {
210
+ const r = await docker(['image', 'inspect', '--format', '{{.Id}}', image]);
211
+ return r.code === 0 ? r.stdout.trim() : '';
212
+ };
213
+ const pinned = image.includes('@sha256:') ? await local() : '';
214
+ if (pinned) return {
215
+ id: pinned
216
+ };
217
+ const pull = await docker(['pull', image]);
218
+ const id = await local();
219
+ if (!id) throw new DockerError(pull.stderr.trim() || `docker pull ${image} failed with exit code ${pull.code}`, pull.stderr);
220
+ return pull.code === 0 ? {
221
+ id
222
+ } : {
223
+ id,
224
+ pullError: pull.stderr.trim() || `exit code ${pull.code}`
225
+ };
226
+ }
227
+
228
+ /** Whether [container] is running, waiting up to [seconds] for it to get there. */
229
+ async function isRunning(container, seconds = 0) {
230
+ for (let i = 0;; i++) {
231
+ const r = await docker(['inspect', '--format', '{{.State.Running}}', container]);
232
+ if (r.code === 0 && r.stdout.trim() === 'true') return true;
233
+ if (i >= seconds * 4) return false;
234
+ await new Promise(resolve => setTimeout(resolve, 250));
235
+ }
236
+ }
237
+ const start = container => dockerOk(['start', container]);
238
+ exports.start = start;
239
+ const stop = container => dockerOk(['stop', container]);
240
+ exports.stop = stop;
241
+ const rm = container => dockerOk(['rm', '-f', container]);
242
+ exports.rm = rm;
243
+ const logs = (container, follow = false) => docker(['logs', ...(follow ? ['-f'] : []), container], true);
244
+ exports.logs = logs;
@@ -0,0 +1,259 @@
1
+ import childProcess from 'child_process';
2
+ import crypto from 'crypto';
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ import path from 'path';
6
+
7
+ /** The `docker` CLI for `grok connect-folder`: always an argument vector, never a shell, so no quoting or MSYS path mangling. */
8
+
9
+ export const LABEL = 'ai.datagrok.folder';
10
+ export const SHARE_ROOT = '/share';
11
+ /** The only image run with the user's folder mounted unless `--image` names another. */
12
+ export const AGENT_REPOSITORY = 'datagrok/proxy_storage';
13
+
14
+ /** Whether [image] is [AGENT_REPOSITORY] on Docker Hub, by tag or digest: a server cannot name any other. */
15
+ export function isAgentImage(image: string): boolean {
16
+ return /^((docker\.io|index\.docker\.io)\/)?datagrok\/proxy_storage(:\w[\w.-]{0,127})?(@sha256:[0-9a-f]{64})?$/.test(image);
17
+ }
18
+
19
+ const DOCKER_HOST = 'host.docker.internal';
20
+
21
+ export class DockerError extends Error {
22
+ constructor(message: string, public readonly stderr: string = '') {
23
+ super(message);
24
+ this.name = 'DockerError';
25
+ }
26
+ }
27
+
28
+ /** Where `docker` is started; the tests replace it, since a module mock cannot reach the transpiled require. */
29
+ export const proc = {spawn: childProcess.spawn};
30
+
31
+ export interface DockerResult { code: number, stdout: string, stderr: string }
32
+
33
+ export interface ShareContainer {
34
+ id: string,
35
+ name: string,
36
+ state: string,
37
+ image: string,
38
+ imageId: string,
39
+ labels: Record<string, string>,
40
+ mounts: {Source: string, Destination: string, RW: boolean}[],
41
+ }
42
+
43
+ /** Runs `docker <args>`; [inherit] attaches the terminal (logs -f, a foreground share). */
44
+ export function docker(args: string[], inherit: boolean = false): Promise<DockerResult> {
45
+ return new Promise((resolve, reject) => {
46
+ const child = proc.spawn('docker', args, {stdio: inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'], windowsHide: true});
47
+ let stdout = '';
48
+ let stderr = '';
49
+ child.stdout?.on('data', (d) => stdout += d);
50
+ child.stderr?.on('data', (d) => stderr += d);
51
+ child.on('error', (e: any) => reject(e?.code === 'ENOENT'
52
+ ? new DockerError('grok connect-folder runs the folder agent in Docker, which is not installed. ' +
53
+ 'Install Docker Desktop (Windows/macOS) or Docker Engine (Linux).')
54
+ : e));
55
+ child.on('close', (code) => resolve({code: code ?? 1, stdout, stderr}));
56
+ });
57
+ }
58
+
59
+ export async function dockerOk(args: string[]): Promise<string> {
60
+ const r = await docker(args);
61
+ if (r.code !== 0)
62
+ throw new DockerError(r.stderr.trim() || `docker ${args[0]} failed with exit code ${r.code}`, r.stderr);
63
+ return r.stdout;
64
+ }
65
+
66
+ export async function dockerAvailable(): Promise<void> {
67
+ const version = await docker(['version', '--format', '{{.Server.Version}}']);
68
+ if (version.code !== 0)
69
+ throw new DockerError('Docker is installed but not running: start Docker Desktop / the docker service.', version.stderr);
70
+ const osType = (await dockerOk(['info', '--format', '{{.OSType}}'])).trim();
71
+ if (osType !== 'linux') {
72
+ throw new DockerError(`Docker runs ${osType} containers; the folder agent needs Linux containers. ` +
73
+ 'Switch Docker Desktop to Linux containers.');
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The absolute path Docker mounts for [folder]. Refuses network and `\\wsl$` paths, which Docker Desktop
79
+ * cannot bind; [note] is set for a Windows drive seen from WSL, which works but is slow.
80
+ */
81
+ export function hostFolder(folder: string, platform: string = process.platform,
82
+ resolve: (p: string) => string = (p) => fs.realpathSync.native(p)): {path: string, note?: string} {
83
+ const raw = folder.trim();
84
+ if (/^[\\/]{2}(wsl\$|wsl\.localhost)([\\/]|$)/i.test(raw))
85
+ throw new Error(`${raw} is inside WSL: run grok connect-folder inside WSL for WSL paths`);
86
+ if (/^[\\/]{2}[^\\/]/.test(raw))
87
+ throw new Error(`${raw} is a network path; Docker cannot mount it. Connect a local folder instead`);
88
+ const p = platform === 'win32' ? path.win32 : path.posix;
89
+ const resolved = resolve(p.resolve(raw));
90
+ if (/^[\\/]{2}/.test(resolved))
91
+ throw new Error(`${raw} resolves to the network path ${resolved}; Docker cannot mount it`);
92
+ const note = platform === 'linux' && /^\/mnt\/[a-z](\/|$)/i.test(resolved)
93
+ ? 'Windows drives seen from WSL (/mnt/...) are slow to read through Docker; consider a folder in the WSL filesystem'
94
+ : undefined;
95
+ return {path: resolved, note};
96
+ }
97
+
98
+ /**
99
+ * The folder the server derives the share id from: `/` separators, no trailing slash, lower case where paths are
100
+ * case-insensitive, and a Windows drive seen from WSL (`/mnt/c/x`) the same as on Windows (`c:/x`).
101
+ */
102
+ export function canonicalFolder(abs: string, platform: string = process.platform): string {
103
+ let f = abs.replace(/\\/g, '/');
104
+ const wslDrive = platform === 'linux' ? /^\/mnt\/([a-z])(\/.*)?$/i.exec(f) : null;
105
+ if (wslDrive)
106
+ f = `${wslDrive[1]}:${wslDrive[2] ?? '/'}`;
107
+ while (f.length > 1 && f.endsWith('/') && !f.endsWith(':/'))
108
+ f = f.slice(0, -1);
109
+ return platform === 'win32' || platform === 'darwin' || wslDrive ? f.toLowerCase() : f;
110
+ }
111
+
112
+ /** `Mac.local` and `Mac` are one machine: DHCP and macOS flip between them. */
113
+ export function canonicalHost(hostname: string = os.hostname()): string {
114
+ return hostname.trim().toLowerCase().split('.')[0];
115
+ }
116
+
117
+ /** `localhost` in a container is the container itself; Docker reaches the host as `host.docker.internal`. */
118
+ export function containerUrl(url: string): {url: string, rewritten: boolean} {
119
+ const u = new URL(url);
120
+ if (!['localhost', '127.0.0.1', '[::1]'].includes(u.hostname))
121
+ return {url, rewritten: false};
122
+ u.hostname = DOCKER_HOST;
123
+ return {url: u.href.replace(/\/$/, ''), rewritten: true};
124
+ }
125
+
126
+ /** The same login shares a folder under the same id on every server, so the name hashes the server in. */
127
+ export function containerName(apiRoot: string, connectionId: string, foreground: boolean = false): string {
128
+ const hash = crypto.createHash('sha256').update(`${apiRoot}|${connectionId}`).digest('hex').slice(0, 12);
129
+ return `grok-folder-${hash}${foreground ? '-fg' : ''}`;
130
+ }
131
+
132
+ /** A `--mount` field: the value is CSV, so a path with a comma or a quote must be quoted. */
133
+ function mountField(field: string): string {
134
+ return /[",]/.test(field) ? `"${field.replace(/"/g, '""')}"` : field;
135
+ }
136
+
137
+ export interface RunSpec {
138
+ image: string,
139
+ connectionId: string,
140
+ alias: string,
141
+ apiRoot: string,
142
+ name: string,
143
+ folder: string,
144
+ hostLabel: string,
145
+ readOnly: boolean,
146
+ envFile: string,
147
+ foreground?: boolean,
148
+ platform?: string,
149
+ /** `uid:gid` on Linux, so files the agent writes belong to the user. */
150
+ user?: string,
151
+ /** The server id of the share's own key, whose private half is in [envFile]. */
152
+ keyId?: string,
153
+ /** The local id [image] resolves to, which a tag the server moved changes. */
154
+ imageId?: string,
155
+ }
156
+
157
+ /** Whether [c] runs [spec] as is: the mount, the mode, the image and the user are fixed at `docker run`. */
158
+ export function sameSettings(c: ShareContainer, spec: RunSpec): boolean {
159
+ const l = c.labels;
160
+ return c.image === spec.image && c.imageId === (spec.imageId ?? c.imageId) && l[`${LABEL}.folder`] === spec.folder && l[`${LABEL}.readonly`] === `${spec.readOnly}` &&
161
+ (l[`${LABEL}.user`] ?? '') === (runUser(spec) ?? '');
162
+ }
163
+
164
+ function runUser(spec: RunSpec): string | undefined {
165
+ return (spec.platform ?? process.platform) === 'linux' ? spec.user : undefined;
166
+ }
167
+
168
+ export function runArgs(spec: RunSpec): string[] {
169
+ const platform = spec.platform ?? process.platform;
170
+ const server = containerUrl(spec.apiRoot);
171
+ const labels: Record<string, string> = {
172
+ [LABEL]: '1',
173
+ [`${LABEL}.connection`]: spec.connectionId,
174
+ [`${LABEL}.alias`]: spec.alias,
175
+ [`${LABEL}.url`]: spec.apiRoot,
176
+ [`${LABEL}.name`]: spec.name,
177
+ [`${LABEL}.folder`]: spec.folder,
178
+ [`${LABEL}.readonly`]: `${spec.readOnly}`,
179
+ [`${LABEL}.user`]: runUser(spec) ?? '',
180
+ [`${LABEL}.key`]: spec.keyId ?? '',
181
+ };
182
+ const args = ['run', '--name', containerName(spec.apiRoot, spec.connectionId, spec.foreground)];
183
+ args.push(...(spec.foreground ? ['--rm'] : ['-d', '--restart', 'unless-stopped']));
184
+ for (const [k, v] of Object.entries(labels))
185
+ args.push('--label', `${k}=${v}`);
186
+ args.push('--mount', ['type=bind', mountField(`source=${spec.folder}`), `target=${SHARE_ROOT}`,
187
+ ...(spec.readOnly ? ['readonly'] : [])].join(','));
188
+ args.push('--env-file', spec.envFile);
189
+ args.push('--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--memory', '256m', '-e', 'DEBUG_MODE=false');
190
+ if (runUser(spec))
191
+ args.push('--user', runUser(spec)!);
192
+ // Docker Desktop resolves host.docker.internal by itself; Docker Engine on Linux (and WSL) needs it mapped to the host.
193
+ if (platform === 'linux' && server.rewritten)
194
+ args.push('--add-host', `${DOCKER_HOST}:host-gateway`);
195
+ args.push(spec.image, '--tunnel', '--server', server.url, '--root', SHARE_ROOT, '--connection', spec.connectionId,
196
+ '--host-label', spec.hostLabel, '--folder-label', spec.folder);
197
+ if (spec.readOnly)
198
+ args.push('--read-only');
199
+ return args;
200
+ }
201
+
202
+ export function linuxUser(platform: string = process.platform): string | undefined {
203
+ return platform === 'linux' && process.getuid && process.getgid ? `${process.getuid()}:${process.getgid()}` : undefined;
204
+ }
205
+
206
+ /** Share containers with all of [labels], stopped ones included. */
207
+ export async function ps(labels: Record<string, string> = {}): Promise<ShareContainer[]> {
208
+ const filters = [`label=${LABEL}=1`, ...Object.entries(labels).map(([k, v]) => `label=${k}=${v}`)];
209
+ const ids = (await dockerOk(['ps', '-a', '-q', ...filters.flatMap((f) => ['--filter', f])])).split(/\s+/).filter((s) => s);
210
+ if (!ids.length)
211
+ return [];
212
+ // `docker ps --format` joins labels with commas, which a folder name can contain; inspect keeps them apart.
213
+ return (JSON.parse(await dockerOk(['inspect', ...ids])) as any[]).map((c) => ({
214
+ id: c.Id,
215
+ name: String(c.Name ?? '').replace(/^\//, ''),
216
+ state: c.State?.Status ?? '',
217
+ image: c.Config?.Image ?? '',
218
+ imageId: c.Image ?? '',
219
+ labels: c.Config?.Labels ?? {},
220
+ mounts: c.Mounts ?? [],
221
+ }));
222
+ }
223
+
224
+ /**
225
+ * The local id of [image]. A tag is pulled every time, since the server moves it, and Docker's copy is used when the
226
+ * pull fails ([pullError]: offline, a local build); a digest is pulled only when missing.
227
+ */
228
+ export async function ensureImage(image: string): Promise<{id: string, pullError?: string}> {
229
+ const local = async () => {
230
+ const r = await docker(['image', 'inspect', '--format', '{{.Id}}', image]);
231
+ return r.code === 0 ? r.stdout.trim() : '';
232
+ };
233
+ const pinned = image.includes('@sha256:') ? await local() : '';
234
+ if (pinned)
235
+ return {id: pinned};
236
+ const pull = await docker(['pull', image]);
237
+ const id = await local();
238
+ if (!id)
239
+ throw new DockerError(pull.stderr.trim() || `docker pull ${image} failed with exit code ${pull.code}`, pull.stderr);
240
+ return pull.code === 0 ? {id} : {id, pullError: pull.stderr.trim() || `exit code ${pull.code}`};
241
+ }
242
+
243
+ /** Whether [container] is running, waiting up to [seconds] for it to get there. */
244
+ export async function isRunning(container: string, seconds: number = 0): Promise<boolean> {
245
+ for (let i = 0; ; i++) {
246
+ const r = await docker(['inspect', '--format', '{{.State.Running}}', container]);
247
+ if (r.code === 0 && r.stdout.trim() === 'true')
248
+ return true;
249
+ if (i >= seconds * 4)
250
+ return false;
251
+ await new Promise((resolve) => setTimeout(resolve, 250));
252
+ }
253
+ }
254
+
255
+ export const start = (container: string) => dockerOk(['start', container]);
256
+ export const stop = (container: string) => dockerOk(['stop', container]);
257
+ export const rm = (container: string) => dockerOk(['rm', '-f', container]);
258
+ export const logs = (container: string, follow: boolean = false) =>
259
+ docker(['logs', ...(follow ? ['-f'] : []), container], true);
@@ -19,7 +19,7 @@ The app is the u2 defaults alone — list, search, filters, entity page with chi
19
19
  and history, editing under one session, permissions and deep links. src/app.spec.json
20
20
  is the same app as a designer-editable spec. To go further, run \`grok api --ui\` for
21
21
  typed handles (get<Schema>Db()) and declare on a table: actions.add(...),
22
- validators.add(column, ...), renderer, and app({app: <a DomainApp subclass>}) for
22
+ validators.add(column, ...), renderer, and app({ribbon, shortcuts, menu, setup}) for
23
23
  presets and shortcuts — the Grit package is the reference.
24
24
  Read more at https://datagrok.ai/help/develop/how-to/build-an-app`;
25
25
  const connection = connectionName => `
@@ -64,6 +64,17 @@ async function fetchOrRetry(url, opts, retriable, timeoutMs = setting('TIMEOUT',
64
64
  class NodeApiClient {
65
65
  /** Set by `createClient` when the run asked for an admin session, so a re-login restores it. */
66
66
  adminMode = false;
67
+ /**
68
+ * `--log`: one id for every request of the command, wherever the server runs it, and the logger settings
69
+ * its records are kept at until `GET /log/requests/<logId>` collects them.
70
+ */
71
+
72
+ get logHeaders() {
73
+ const headers = {};
74
+ if (this.logId) headers['datlas-log-id'] = this.logId;
75
+ if (this.logSpec) headers['datlas-log'] = this.logSpec;
76
+ return headers;
77
+ }
67
78
  constructor(baseUrl, token, devKey, privateKey) {
68
79
  this.baseUrl = baseUrl;
69
80
  this.token = token;
@@ -120,6 +131,7 @@ class NodeApiClient {
120
131
  headers: {
121
132
  'Authorization': this.token,
122
133
  'Content-Type': 'application/json',
134
+ ...this.logHeaders,
123
135
  ...headers
124
136
  }
125
137
  };
@@ -165,7 +177,8 @@ class NodeApiClient {
165
177
  method: 'POST',
166
178
  headers: {
167
179
  'Authorization': this.token,
168
- 'Content-Type': contentType
180
+ 'Content-Type': contentType,
181
+ ...this.logHeaders
169
182
  },
170
183
  body: bytes
171
184
  }, false, BYTES_TIMEOUT_MS);
@@ -180,7 +193,8 @@ class NodeApiClient {
180
193
  method: 'POST',
181
194
  headers: {
182
195
  'Authorization': this.token,
183
- 'Content-Type': 'application/json'
196
+ 'Content-Type': 'application/json',
197
+ ...this.logHeaders
184
198
  },
185
199
  body: JSON.stringify(body),
186
200
  signal: AbortSignal.timeout(BYTES_TIMEOUT_MS)
@@ -193,7 +207,8 @@ class NodeApiClient {
193
207
  async getBytes(path) {
194
208
  const res = await fetchOrRetry(`${this.baseUrl}${path}`, {
195
209
  headers: {
196
- 'Authorization': this.token
210
+ 'Authorization': this.token,
211
+ ...this.logHeaders
197
212
  }
198
213
  }, true, BYTES_TIMEOUT_MS);
199
214
  if (!res.ok) await throwHttpError(res);
@@ -641,9 +656,11 @@ class NodeFuncsDataSource extends NodeHttpDataSource {
641
656
  constructor(client) {
642
657
  super(client, 'functions', '/log/funcs');
643
658
  }
644
- async run(name, params) {
659
+
660
+ /** [debug] runs it as a debug call, as the query debugger does; its log is kept with the command's `--log`. */
661
+ async run(name, params, debug = false) {
645
662
  const normalizedName = name.replace(':', '.');
646
- return this.client.post(`/public/v1/functions/${encodeURIComponent(normalizedName)}/call`, params ?? {});
663
+ return this.client.post(`/public/v1/functions/${encodeURIComponent(normalizedName)}/call${debug ? '?debug=true' : ''}`, params ?? {});
647
664
  }
648
665
 
649
666
  /** The public API has no DELETE for functions; scripts and queries go to their own routers. */
@@ -1170,9 +1187,14 @@ class NodeDapi {
1170
1187
  internal(route) {
1171
1188
  return new InternalDataSource(this.client, route);
1172
1189
  }
1190
+
1191
+ /** `/info/server`; [extended] adds the in-flight requests per endpoint (admins only). */
1192
+ async serverStatus(extended = false) {
1193
+ const raw = await this.client.get(extended ? '/info/server?extended=true' : '/info/server');
1194
+ return typeof raw === 'string' ? JSON.parse(raw) : raw;
1195
+ }
1173
1196
  async serverInfo() {
1174
- const raw = await this.client.get('/info/server');
1175
- const info = typeof raw === 'string' ? JSON.parse(raw) : raw;
1197
+ const info = await this.serverStatus();
1176
1198
  return {
1177
1199
  version: info?.Version ?? '',
1178
1200
  commit: info?.Commit
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.createClient = createClient;
7
+ exports.logSpecOf = logSpecOf;
7
8
  var _nodeDapi = require("./node-dapi");
8
9
  var _keypair = require("./keypair");
9
10
  /**
@@ -13,11 +14,17 @@ var _keypair = require("./keypair");
13
14
  * flag into the token, and a dev key mints a fresh session per invocation, so it cannot outlive
14
15
  * the command or reach another session.
15
16
  */
16
- async function createClient(hostArg, admin = false) {
17
+ /** `--log` alone is `verbose`. */
18
+ function logSpecOf(argv) {
19
+ return argv.log === undefined || argv.log === false ? undefined : argv.log === true ? 'verbose' : String(argv.log);
20
+ }
21
+ async function createClient(hostArg, admin = false, log) {
17
22
  // Resolved from the alias the caller named, not from its URL: two aliases can point at the
18
23
  // same server, and only this side knows which one was asked for.
19
24
  const cred = (0, _keypair.getServerCredentials)(hostArg ?? '');
20
25
  const client = await _nodeDapi.NodeApiClient.login(cred.url, cred.key ?? '', cred.privateKey);
26
+ client.logId = log?.logId;
27
+ client.logSpec = log?.spec;
21
28
  if (!admin) return client;
22
29
  const token = (await client.post('/users/sessions/current/admin'))?.token;
23
30
  if (!token) throw new Error(`${cred.url} refused an admin session — the account behind this key cannot start one`);