hologit
Version:
Hologit automates the projection of layered composite file trees based on flat, declarative plans
356 lines (284 loc) • 10.8 kB
JavaScript
const { spawn } = require('child_process');
const stream = require('stream');
const os = require('os');
const exitHook = require('async-exit-hook');
const fs = require('mz/fs');
const logger = require('./logger');
const studioCache = new Map();
let hab;
/**
* Helper function to execute a Docker CLI command.
* @param {Array<string>} args - The arguments to pass to the docker command.
* @param {Object} options - Options for child_process.spawn.
* @returns {Promise<string>} - Resolves with stdout data.
*/
function execDocker(args, options = { $relayStderr: false, $relayStdout: false }) {
logger.debug(`docker ${args.join(' ')}`);
return new Promise((resolve, reject) => {
const dockerProcess = spawn('docker', args, { stdio: 'pipe', ...options });
if (options.$relayStderr !== false) {
dockerProcess.stderr.pipe(process.stderr);
}
if (options.$relayStdout !== false) {
dockerProcess.stdout.pipe(process.stdout);
}
let stdout = '';
let stderr = '';
dockerProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
dockerProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
dockerProcess.on('close', (code) => {
if (code === 0) {
resolve(stdout.trim());
} else {
reject(new Error(stderr.trim()));
}
});
});
}
/**
* A studio session that can be used to run multiple commands, using chroot if available or docker container
*/
class Studio {
static async cleanup () {
let cleanupCount = 0;
for (const [gitDir, studio] of studioCache) {
const { container } = studio;
if (container && container.type !== 'studio') {
logger.info(`terminating studio container: ${container.id}`);
try {
await execDocker(['stop', container.id]);
await execDocker(['rm', container.id]);
cleanupCount++;
} catch (err) {
logger.error(`Failed to stop/remove container ${container.id}: ${err.message}`);
}
}
studioCache.delete(gitDir);
}
if (cleanupCount > 0) {
logger.debug(`cleaned up ${cleanupCount} studio${cleanupCount > 1 ? 's' : ''}`);
}
}
static async getHab () {
if (!hab) {
hab = await require('hab-client').requireVersion('>=0.62');
}
return hab;
}
static async isEnvironmentStudio () {
return Boolean(process.env.STUDIO_TYPE);
}
static async get (gitDir) {
const cachedStudio = studioCache.get(gitDir);
if (cachedStudio) {
return cachedStudio;
}
// detect environmental studio
if (await Studio.isEnvironmentStudio()) {
const studio = new Studio({
gitDir,
container: {
type: 'studio',
env: {
GIT_DIR: gitDir,
GIT_WORK_TREE: '/hab/cache'
}
}
});
studioCache.set(gitDir, studio);
return studio;
}
// pull latest studio container
try {
await execDocker(['pull', 'jarvus/hologit-studio:latest'], { $relayStdout: true, $relayStderr: true });
} catch (err) {
logger.error(`failed to pull studio image via docker: ${err.message}`);
}
// find artifact cache
const volumesConfig = { '/git': {} };
const bindsConfig = [ `${gitDir}:/git` ];
let artifactCachePath;
if (process.env.HOME) {
artifactCachePath = `${process.env.HOME}/.hab/cache/artifacts`;
if (!await fs.exists(artifactCachePath)) {
artifactCachePath = null;
}
}
if (!artifactCachePath) {
artifactCachePath = '/hab/cache/artifacts';
if (!await fs.exists(artifactCachePath)) {
artifactCachePath = null;
}
}
if (artifactCachePath) {
volumesConfig['/hab/cache/artifacts'] = {};
bindsConfig.push(`${artifactCachePath}:/hab/cache/artifacts`);
}
// create studio container
let containerId;
let defaultUser;
try {
// Prepare environment variables
const envArgs = [
'--env', 'STUDIO_TYPE=holo',
'--env', 'GIT_DIR=/git',
'--env', 'GIT_WORK_TREE=/hab/cache',
'--env', `DEBUG=${process.env.DEBUG || ''}`,
'--env', 'HAB_LICENSE=accept-no-persist'
];
// Prepare volume bindings
const volumeArgs = [];
for (const bind of bindsConfig) {
volumeArgs.push('-v', bind);
}
// Create container
const createArgs = [
'create',
'--label', 'sh.holo.studio=yes',
'--workdir', '/git',
...envArgs,
...volumeArgs,
'jarvus/hologit-studio:latest'
];
containerId = await execDocker(createArgs);
containerId = containerId.split('\n').pop().trim(); // Get the container ID from output
logger.info('starting studio container');
await execDocker(['start', containerId]);
const { uid, gid, username } = os.userInfo();
if (uid && gid && username) {
logger.info(`configuring container to use user: ${username}`);
await containerExec({ id: containerId }, 'adduser', '-u', `${uid}`, '-G', 'developer', '-D', username);
await containerExec({ id: containerId }, 'mkdir', '-p', `/home/${username}/.hab`);
await containerExec({ id: containerId }, 'ln', '-sf', '/hab/cache', `/home/${username}/.hab/`);
if (!artifactCachePath) await containerExec({ id: containerId }, 'chown', '-R', `${uid}:${gid}`, '/hab/cache');
defaultUser = `${uid}`;
}
const studio = new Studio({ gitDir, container: { id: containerId, defaultUser } });
studioCache.set(gitDir, studio);
return studio;
} catch (err) {
logger.error(`container failed: ${err.message}`);
if (containerId) {
try {
await execDocker(['stop', containerId]);
} catch (stopErr) {
logger.error(`Failed to stop container ${containerId}: ${stopErr.message}`);
}
try {
await execDocker(['rm', containerId]);
} catch (rmErr) {
logger.error(`Failed to remove container ${containerId}: ${rmErr.message}`);
}
}
}
}
constructor ({ gitDir, container }) {
this.container = container;
this.gitDir = gitDir;
Object.freeze(this);
}
isLocal () {
return this.container.type === 'studio';
}
/**
* Run a command in the studio
*/
async habExec (...command) {
const options = typeof command[command.length-1] === 'object'
? command.pop()
: {};
if (this.isLocal()) {
const hab = await Studio.getHab();
const habProcess = await hab.exec(...command, {
$spawn: true,
$env: this.container.env,
...options
});
if (options.$relayStderr !== false) {
habProcess.stderr.pipe(process.stderr);
}
return habProcess.captureOutputTrimmed();
}
return containerExec(this.container, 'hab', ...command, options);
}
async habPkgExec (pkg, bin, ...args) {
return this.habExec('pkg', 'exec', pkg, bin, ...args);
}
async holoExec (...command) {
// const holoPath = await this.exec('hab', 'pkg', 'path', 'jarvus/hologit');
// const PATH = await this.exec('cat', `${holoPath}/RUNTIME_PATH`);
// return this.exec(
// 'node',
// '--inspect-brk=0.0.0.0:9229',
// '--nolazy',
// '/src/bin/cli.js',
// ...command,
// {
// $env: { PATH }
// }
// );
if (logger.level === 'debug') {
command.unshift('--debug');
}
return this.habPkgExec('jarvus/hologit', 'git-holo', ...command);
}
async holoLensExec(spec) {
return this.holoExec('lens', 'exec', spec);
}
async getPackage (query, { install } = { install: false }) {
let packagePath;
try {
packagePath = await this.habExec('pkg', 'path', query, { $nullOnError: true, $relayStderr: false });
} catch (err) {
packagePath = null;
}
if (!packagePath && install) {
await this.habExec('pkg', 'install', query);
try {
packagePath = await this.habExec('pkg', 'path', query);
} catch (err) {
packagePath = null;
}
}
return packagePath ? packagePath.substr(10) : null;
}
}
exitHook(callback => Studio.cleanup().then(callback));
/**
* Executes a command inside the specified Docker container.
* @param {Object} container - The container object containing at least the `id` and optionally `defaultUser`.
* @param {...string} command - The command and its arguments to execute.
* @returns {Promise<string>} - Resolves with the command's stdout output.
*/
async function containerExec (container, ...command) {
const options = typeof command[command.length-1] === 'object'
? command.pop()
: {};
logger.info(`studio-exec: ${command.join(' ')}`);
const execArgs = ['exec'];
if (options.$user) {
execArgs.push('--user', options.$user);
} else if (container.defaultUser) {
execArgs.push('--user', container.defaultUser);
}
if (options.$env) {
for (const [key, value] of Object.entries(options.$env)) {
execArgs.push('--env', `${key}=${value}`);
}
}
execArgs.push(container.id, ...command);
try {
const output = await execDocker(execArgs, { $relayStdout: true, $relayStderr: true });
return output;
} catch (err) {
if (options.$nullOnError) {
return null;
}
throw err;
}
}
module.exports = Studio;