Skip to content

Commit 669d3ba

Browse files
committed
Studio.js: replace docker client with CLI implementation
Interacting with the docker CLI enables the transparent use of other docker-compatible runtimes, such as podman or nerdctl.
1 parent 9b37d28 commit 669d3ba

File tree

4 files changed

+133
-357
lines changed

4 files changed

+133
-357
lines changed

index.d.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,6 @@ declare module 'git-client' {
44
}
55
}
66

7-
declare module 'dockerode' {
8-
export class Docker {
9-
constructor(options: { socketPath: string });
10-
}
11-
}
12-
137
declare module 'hologit' {
148
import { Git as GitClient } from 'git-client';
159
import { Docker } from 'dockerode';

lib/Studio.js

Lines changed: 131 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,44 @@
1+
const { spawn } = require('child_process');
12
const stream = require('stream');
2-
const Docker = require('dockerode');
33
const os = require('os');
44
const exitHook = require('async-exit-hook');
55
const fs = require('mz/fs');
66

7-
87
const logger = require('./logger');
98

10-
119
const studioCache = new Map();
12-
let hab, docker;
10+
let hab;
11+
12+
/**
13+
* Helper function to execute a Docker CLI command.
14+
* @param {Array<string>} args - The arguments to pass to the docker command.
15+
* @param {Object} options - Options for child_process.spawn.
16+
* @returns {Promise<string>} - Resolves with stdout data.
17+
*/
18+
function execDocker(args, options = {}) {
19+
return new Promise((resolve, reject) => {
20+
const dockerProcess = spawn('docker', args, { stdio: 'pipe', ...options });
21+
22+
let stdout = '';
23+
let stderr = '';
1324

25+
dockerProcess.stdout.on('data', (data) => {
26+
stdout += data.toString();
27+
});
28+
29+
dockerProcess.stderr.on('data', (data) => {
30+
stderr += data.toString();
31+
});
32+
33+
dockerProcess.on('close', (code) => {
34+
if (code === 0) {
35+
resolve(stdout.trim());
36+
} else {
37+
reject(new Error(stderr.trim()));
38+
}
39+
});
40+
});
41+
}
1442

1543
/**
1644
* A studio session that can be used to run multiple commands, using chroot if available or docker container
@@ -23,11 +51,15 @@ class Studio {
2351
for (const [gitDir, studio] of studioCache) {
2452
const { container } = studio;
2553

26-
if (container && container.type != 'studio') {
54+
if (container && container.type !== 'studio') {
2755
logger.info(`terminating studio container: ${container.id}`);
28-
await container.stop();
29-
await container.remove();
30-
cleanupCount++;
56+
try {
57+
await execDocker(['stop', container.id]);
58+
await execDocker(['rm', container.id]);
59+
cleanupCount++;
60+
} catch (err) {
61+
logger.error(`Failed to stop/remove container ${container.id}: ${err.message}`);
62+
}
3163
}
3264

3365
studioCache.delete(gitDir);
@@ -46,19 +78,6 @@ class Studio {
4678
return hab;
4779
}
4880

49-
static async getDocker () {
50-
if (!docker) {
51-
const { DOCKER_HOST: dockerHost } = process.env;
52-
const dockerHostMatch = dockerHost && dockerHost.match(/^unix:\/\/(\/.*)$/);
53-
const socketPath = dockerHostMatch ? dockerHostMatch[1] : '/var/run/docker.sock';
54-
55-
docker = new Docker({ socketPath });
56-
logger.info(`connected to docker on: ${socketPath}`);
57-
}
58-
59-
return docker;
60-
}
61-
6281
static async isEnvironmentStudio () {
6382
return Boolean(process.env.STUDIO_TYPE);
6483
}
@@ -91,47 +110,17 @@ class Studio {
91110
}
92111

93112

94-
// connect with Docker API
95-
const docker = await Studio.getDocker();
96-
97-
98113
// pull latest studio container
99114
try {
100-
await new Promise((resolve, reject) => {
101-
docker.pull('jarvus/hologit-studio:latest', (streamErr, stream) => {
102-
if (streamErr) {
103-
reject(streamErr);
104-
return;
105-
}
106-
107-
let lastStatus;
108-
109-
docker.modem.followProgress(
110-
stream,
111-
(err, output) => {
112-
if (err) {
113-
reject(err);
114-
} else {
115-
resolve(output);
116-
}
117-
},
118-
event => {
119-
if (event.status != lastStatus) {
120-
logger.info(`docker pull: ${event.status}`);
121-
lastStatus = event.status;
122-
}
123-
}
124-
);
125-
});
126-
});
115+
await execDocker(['pull', 'jarvus/hologit-studio:latest']);
127116
} catch (err) {
128117
logger.error(`failed to pull studio image via docker: ${err.message}`);
129118
}
130119

131120

132121
// find artifact cache
133122
const volumesConfig = { '/git': {} };
134-
const bindsConfig = [ `${gitDir}:/git` ];
123+
const bindsConfig = [`${gitDir}:/git`];
135124

136125
let artifactCachePath;
137126

@@ -157,65 +146,69 @@ class Studio {
157146
}
158147

159148

160-
// start studio container
161-
let container;
149+
// create studio container
150+
let containerId;
151+
let defaultUser;
162152
try {
163-
container = await docker.createContainer({
164-
Image: 'jarvus/hologit-studio',
165-
Labels: {
166-
'sh.holo.studio': 'yes'
167-
},
168-
AttachStdin: false,
169-
AttachStdout: true,
170-
AttachStderr: true,
171-
Env: [
172-
'STUDIO_TYPE=holo',
173-
'GIT_DIR=/git',
174-
'GIT_WORK_TREE=/hab/cache',
175-
`DEBUG=${process.env.DEBUG||''}`,
176-
`HAB_LICENSE=accept-no-persist`
177-
],
178-
WorkingDir: '/git',
179-
Volumes: volumesConfig,
180-
HostConfig: {
181-
Binds: bindsConfig,
182-
// ExposedPorts: {
183-
// "9229/tcp": { }
184-
// },
185-
// PortBindings: {
186-
// '9229/tcp': [
187-
// {
188-
// HostIp: '0.0.0.0',
189-
// HostPort: '9229'
190-
// }
191-
// ]
192-
// }
193-
}
194-
});
153+
// Prepare environment variables
154+
const envArgs = [
155+
'--env', 'STUDIO_TYPE=holo',
156+
'--env', 'GIT_DIR=/git',
157+
'--env', 'GIT_WORK_TREE=/hab/cache',
158+
'--env', `DEBUG=${process.env.DEBUG || ''}`,
159+
'--env', 'HAB_LICENSE=accept-no-persist'
160+
];
161+
162+
// Prepare volume bindings
163+
const volumeArgs = [];
164+
for (const bind of bindsConfig) {
165+
volumeArgs.push('-v', bind);
166+
}
167+
168+
// Create container
169+
const createArgs = [
170+
'create',
171+
'--label', 'sh.holo.studio=yes',
172+
'--workdir', '/git',
173+
...envArgs,
174+
...volumeArgs,
175+
'jarvus/hologit-studio:latest'
176+
];
177+
178+
containerId = await execDocker(createArgs);
179+
containerId = containerId.split('\n').pop().trim(); // Get the container ID from output
195180

196181
logger.info('starting studio container');
197-
await container.start();
182+
await execDocker(['start', containerId]);
198183

199184
const { uid, gid, username } = os.userInfo();
200185

201186
if (uid && gid && username) {
202187
logger.info(`configuring container to use user: ${username}`);
203-
await containerExec(container, 'adduser', '-u', `${uid}`, '-G', 'developer', '-D', username);
204-
await containerExec(container, 'mkdir', '-p', `/home/${username}/.hab`);
205-
await containerExec(container, 'ln', '-sf', '/hab/cache', `/home/${username}/.hab/`);
206-
container.defaultUser = `${uid}`;
188+
await containerExec({ id: containerId }, 'adduser', '-u', `${uid}`, '-G', 'developer', '-D', username);
189+
await containerExec({ id: containerId }, 'mkdir', '-p', `/home/${username}/.hab`);
190+
await containerExec({ id: containerId }, 'ln', '-sf', '/hab/cache', `/home/${username}/.hab/`);
191+
defaultUser = `${uid}`;
207192
}
208193

209-
const studio = new Studio({ gitDir, container });
194+
const studio = new Studio({ gitDir, container: { id: containerId, defaultUser } });
210195
studioCache.set(gitDir, studio);
211196
return studio;
212197

213198
} catch (err) {
214-
logger.error('container failed: %o', err);
199+
logger.error(`container failed: ${err.message}`);
215200

216-
if (container) {
217-
await container.stop();
218-
await container.remove();
201+
if (containerId) {
202+
try {
203+
await execDocker(['stop', containerId]);
204+
} catch (stopErr) {
205+
logger.error(`Failed to stop container ${containerId}: ${stopErr.message}`);
206+
}
207+
try {
208+
await execDocker(['rm', containerId]);
209+
} catch (rmErr) {
210+
logger.error(`Failed to remove container ${containerId}: ${rmErr.message}`);
211+
}
219212
}
220213
}
221214
}
@@ -227,14 +220,14 @@ class Studio {
227220
}
228221

229222
isLocal () {
230-
return this.container.type == 'studio';
223+
return this.container.type === 'studio';
231224
}
232225

233226
/**
234227
* Run a command in the studio
235228
*/
236229
async habExec (...command) {
237-
const options = typeof command[command.length-1] == 'object'
230+
const options = typeof command[command.length-1] === 'object'
238231
? command.pop()
239232
: {};
240233

@@ -274,7 +267,7 @@ class Studio {
274267
// $env: { PATH }
275268
// }
276269
// );
277-
if (logger.level == 'debug') {
270+
if (logger.level === 'debug') {
278271
command.unshift('--debug');
279272
}
280273

@@ -286,55 +279,66 @@ class Studio {
286279
}
287280

288281
async getPackage (query, { install } = { install: false }) {
289-
let packagePath = await this.habExec('pkg', 'path', query, { $nullOnError: true, $relayStderr: false });
282+
let packagePath;
283+
try {
284+
packagePath = await this.habExec('pkg', 'path', query, { $nullOnError: true, $relayStderr: false });
285+
} catch (err) {
286+
packagePath = null;
287+
}
290288

291289
if (!packagePath && install) {
292290
await this.habExec('pkg', 'install', query);
293-
packagePath = await this.habExec('pkg', 'path', query);
291+
try {
292+
packagePath = await this.habExec('pkg', 'path', query);
293+
} catch (err) {
294+
packagePath = null;
295+
}
294296
}
295297

296298
return packagePath ? packagePath.substr(10) : null;
297299
}
298300
}
299301

300-
301302
exitHook(callback => Studio.cleanup().then(callback));
302303

303-
304+
/**
305+
* Executes a command inside the specified Docker container.
306+
* @param {Object} container - The container object containing at least the `id` and optionally `defaultUser`.
307+
* @param {...string} command - The command and its arguments to execute.
308+
* @returns {Promise<string>} - Resolves with the command's stdout output.
309+
*/
304310
async function containerExec (container, ...command) {
305-
const options = typeof command[command.length-1] == 'object'
311+
const options = typeof command[command.length-1] === 'object'
306312
? command.pop()
307313
: {};
308314

309315
logger.info(`studio-exec: ${command.join(' ')}`);
310316

311-
const env = [];
317+
const execArgs = ['exec'];
318+
319+
if (options.$user) {
320+
execArgs.push('--user', options.$user);
321+
} else if (container.defaultUser) {
322+
execArgs.push('--user', container.defaultUser);
323+
}
324+
312325
if (options.$env) {
313-
for (const key of Object.keys(options.$env)) {
314-
env.push(`${key}=${options.$env[key]}`);
326+
for (const [key, value] of Object.entries(options.$env)) {
327+
execArgs.push('--env', `${key}=${value}`);
315328
}
316329
}
317330

318-
const exec = await container.exec({
319-
Cmd: command,
320-
AttachStdout: true,
321-
AttachStderr: options.$relayStderr !== false,
322-
Env: env,
323-
User: `${container.defaultUser || options.$user || ''}`
324-
});
325-
326-
const execStream = await exec.start();
327-
328-
return new Promise((resolve, reject) => {
329-
const output = [];
330-
const outputStream = new stream.PassThrough();
331-
332-
outputStream.on('data', chunk => output.push(chunk.toString('utf8')));
333-
execStream.on('end', () => resolve(output.join('').trim()));
331+
execArgs.push(container.id, ...command);
334332

335-
container.modem.demuxStream(execStream, outputStream, process.stderr);
336-
});
333+
try {
334+
const output = await execDocker(execArgs);
335+
return output;
336+
} catch (err) {
337+
if (options.$nullOnError) {
338+
return null;
339+
}
340+
throw err;
341+
}
337342
}
338343

339-
340344
module.exports = Studio;

0 commit comments

Comments
 (0)