datagrok-tools 6.7.8 → 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 +33 -0
- package/CLAUDE.md +13 -0
- package/GROK_S.md +138 -6
- package/README.md +14 -0
- package/bin/commands/add.js +4 -4
- package/bin/commands/api.js +49 -20
- package/bin/commands/check.js +43 -1
- package/bin/commands/connect-folder.js +411 -0
- package/bin/commands/help.js +68 -2
- package/bin/commands/publish.js +6 -0
- package/bin/commands/server-domains.js +24 -7
- package/bin/commands/server-observe.js +854 -0
- package/bin/commands/server.js +72 -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 +2 -2
- package/bin/utils/migrate/walker.js +2 -1
- package/bin/utils/migrate/walker.ts +1 -1
- package/bin/utils/node-dapi.js +56 -17
- package/bin/utils/server-client.js +8 -1
- package/bin/utils/test-utils.js +54 -2
- package/domain-schema.schema.json +50 -1
- package/entity-template/domain-app-imports.js +2 -2
- package/package.json +5 -2
- package/plugins/func-gen-plugin.js +1 -1
- package/vitest.config.mts +4 -0
package/bin/grok.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// minimist sets every boolean option to false when it is absent, and commands such as `grok api`
|
|
3
|
-
// reject any key they do not expect — so
|
|
4
|
-
const commandBooleans = {dartium: ['test', 'stresstest'], 'send-email': ['server', 's']
|
|
3
|
+
// reject any key they do not expect — so an absent boolean reaches only the commands that read it.
|
|
4
|
+
const commandBooleans = {dartium: ['test', 'stresstest'], 'send-email': ['server', 's'],
|
|
5
|
+
'read-only': ['connect-folder'], foreground: ['connect-folder'], force: ['connect-folder'], follow: ['connect-folder'],
|
|
6
|
+
explain: ['src'], fix: ['src']};
|
|
5
7
|
const argv = require('minimist')(process.argv.slice(2), {
|
|
6
8
|
alias: {k: 'key', h: 'help', r: 'recursive'},
|
|
7
9
|
boolean: Object.keys(commandBooleans),
|
|
8
|
-
// keep
|
|
9
|
-
string: ['version', 'prefix'],
|
|
10
|
+
// keep these verbatim — minimist would coerce '1.10' to the number 1.1
|
|
11
|
+
string: ['version', 'prefix', 'url', 'dir', '_'],
|
|
10
12
|
});
|
|
11
13
|
// minimist maps `--no-retry` to `{retry: false}`, so the `args['no-retry']` checks in
|
|
12
14
|
// test.ts / playwright-runner.ts never fired and `--no-retry` was silently ignored
|
|
@@ -43,6 +45,8 @@ const commands = {
|
|
|
43
45
|
server: lazy('server', 'server'),
|
|
44
46
|
s: lazy('server', 'server'),
|
|
45
47
|
setup: lazy('setup', 'setup'),
|
|
48
|
+
src: lazy('src', 'src'),
|
|
49
|
+
'connect-folder': lazy('connect-folder', 'connectFolder'),
|
|
46
50
|
};
|
|
47
51
|
|
|
48
52
|
// `--version` is a string option (grok publish --version 1.10), so a bare `grok --version` parses as ''.
|
|
@@ -63,7 +67,7 @@ function printUsage(command) {
|
|
|
63
67
|
|
|
64
68
|
const command = argv['_'][0];
|
|
65
69
|
for (const [flag, owners] of Object.entries(commandBooleans))
|
|
66
|
-
if (!owners.includes(command))
|
|
70
|
+
if (!owners.includes(command) && argv[flag] === false)
|
|
67
71
|
delete argv[flag];
|
|
68
72
|
if (command in commands) {
|
|
69
73
|
try {
|
|
@@ -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
|
@@ -12,14 +12,14 @@ const domainApp = (appNames, tables) => `
|
|
|
12
12
|
The application${appNames.length > 1 ? 's' : ''} ${appNames.join(', ')} ` + `(over ${tables.join(', ')}) ${appNames.length > 1 ? 'have' : 'has'} been added successfully
|
|
13
13
|
|
|
14
14
|
Next steps:
|
|
15
|
-
pnpm install link @datagrok-libraries/
|
|
15
|
+
pnpm install link @datagrok-libraries/ems (a workspace dependency)
|
|
16
16
|
grok build && grok publish publish the package and open the app from the browse tree
|
|
17
17
|
|
|
18
18
|
The app is the u2 defaults alone — list, search, filters, entity page with children
|
|
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 => `
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", {
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
|
+
exports.ALL_USERS = void 0;
|
|
6
7
|
exports.collectExternals = collectExternals;
|
|
7
8
|
exports.compileFilter = compileFilter;
|
|
8
9
|
exports.expand = expand;
|
|
@@ -28,7 +29,7 @@ const noProgress = () => {};
|
|
|
28
29
|
|
|
29
30
|
/** What the bundle points at but does not carry: resolvable on the source, or dead there too. */
|
|
30
31
|
|
|
31
|
-
const ALL_USERS = 'a4b45840-9a50-11e6-9cc9-8546b8bf62e6';
|
|
32
|
+
const ALL_USERS = exports.ALL_USERS = 'a4b45840-9a50-11e6-9cc9-8546b8bf62e6';
|
|
32
33
|
|
|
33
34
|
/** One round trip per entity, tens of thousands per stand — modest, since the server is shared. */
|
|
34
35
|
const WALK_CONCURRENCY = 12;
|
|
@@ -30,7 +30,7 @@ export interface External {id: string; type: string; nqName: string}
|
|
|
30
30
|
/** What the bundle points at but does not carry: resolvable on the source, or dead there too. */
|
|
31
31
|
export interface Outside {externals: External[]; dangling: string[]}
|
|
32
32
|
|
|
33
|
-
const ALL_USERS = 'a4b45840-9a50-11e6-9cc9-8546b8bf62e6';
|
|
33
|
+
export const ALL_USERS = 'a4b45840-9a50-11e6-9cc9-8546b8bf62e6';
|
|
34
34
|
|
|
35
35
|
/** One round trip per entity, tens of thousands per stand — modest, since the server is shared. */
|
|
36
36
|
const WALK_CONCURRENCY = 12;
|