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.
- package/.devcontainer/Dockerfile.pkg_dev +1 -1
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +22 -1
- package/CLAUDE.md +1 -0
- package/GROK_S.md +69 -1
- package/README.md +14 -0
- package/bin/commands/api.js +29 -8
- package/bin/commands/check.js +43 -1
- package/bin/commands/connect-folder.js +411 -0
- package/bin/commands/help.js +66 -0
- package/bin/commands/publish.js +6 -0
- package/bin/commands/server-observe.js +335 -7
- package/bin/commands/server.js +65 -5
- package/bin/commands/src.js +458 -0
- package/bin/grok.js +9 -5
- package/bin/utils/connect-folder/docker.js +244 -0
- package/bin/utils/connect-folder/docker.ts +259 -0
- package/bin/utils/ent-helpers.js +1 -1
- package/bin/utils/node-dapi.js +29 -7
- package/bin/utils/server-client.js +8 -1
- package/bin/utils/test-utils.js +54 -2
- package/package.json +1 -1
- package/plugins/func-gen-plugin.js +1 -1
- package/vitest.config.mts +4 -0
|
@@ -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);
|
package/bin/utils/ent-helpers.js
CHANGED
|
@@ -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({
|
|
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 => `
|
package/bin/utils/node-dapi.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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`);
|