diff --git a/src/cli/exec.ts b/src/cli/exec.ts index 612030d..9ff79a4 100644 --- a/src/cli/exec.ts +++ b/src/cli/exec.ts @@ -6,11 +6,12 @@ import { Command } from "@commander-js/extra-typings"; import Docker from "dockerode"; import sindri from "lib"; -import { findFileUpwards } from "cli/utils"; +import { execDockerCommand, findFileUpwards } from "cli/utils"; // Shared globals between the different subcommands. let docker: Docker; let rootDirectory: string; +let tag: string; const circomspectCommand = new Command() .name("circomspect") @@ -23,82 +24,20 @@ const circomspectCommand = new Command() .passThroughOptions() .argument("[args...]", "Arguments to pass to the tool.") .action(async (args) => { - const image = "sindrilabs/circomspect:latest"; - - // Pull the circomspect image. - sindri.logger.debug(`Pulling the "${image}" image.`); try { - await new Promise((resolve, reject) => { - docker.pull( - image, - (error: Error | null, stream: NodeJS.ReadableStream) => { - if (error) { - reject(error); - } else { - docker.modem.followProgress(stream, (error, result) => - error ? reject(error) : resolve(result), - ); - } - }, - ); + const code = await execDockerCommand("circomspect", args, { + docker, + logger: sindri.logger, + rootDirectory, + stream: process.stdout, + tag, }); - } catch (error) { - sindri.logger.error(`Failed to pull the "${image}" image.`); - sindri.logger.error(error); - return process.exit(1); - } - - // Remap the root directory to its location on the host system when running in development mode. - let mountDirectory: string = rootDirectory; - if (process.env.SINDRI_DEVELOPMENT_HOST_ROOT) { - if (rootDirectory === "/sindri" || rootDirectory.startsWith("/sindri/")) { - mountDirectory = rootDirectory.replace( - "/sindri", - process.env.SINDRI_DEVELOPMENT_HOST_ROOT, - ); - sindri.logger.debug( - `Remapped "${rootDirectory}" to "${mountDirectory}" for bind mount on the Docker host.`, - ); - } else { - sindri.logger.fatal( - `The root directory path "${rootDirectory}" must be under "/sindri/"` + - 'when using "SINDRI_DEVELOPMENT_HOST_ROOT".', - ); - return process.exit(1); - } - } - - // Run circomspect with the project root mounted and pipe the output to stdout. - let status: number; - try { - const data: { StatusCode: number } = await new Promise( - (resolve, reject) => { - docker.run( - image, - args, - process.stdout, - { - HostConfig: { - Binds: [`${mountDirectory}:/sindri`], - }, - }, - (error, data) => { - if (error) { - reject(error); - } else { - resolve(data); - } - }, - ); - }, - ); - status = data.StatusCode; + process.exit(code); } catch (error) { sindri.logger.error("Failed to run the circomspect command."); - sindri.logger.error(error); + sindri.logger.debug(error); return process.exit(1); } - process.exit(status); }); export const execCommand = new Command() @@ -108,8 +47,15 @@ export const execCommand = new Command() "Run a ZK tool in your project root inside of an optimized docker container.", ) .passThroughOptions() + .option( + "-t, --tag ", + "The version tag of the docker image to use.", + "auto", + ) .addCommand(circomspectCommand) - .hook("preAction", async () => { + .hook("preAction", async ({ tag: tagOption }) => { + tag = tagOption; + // Find the project root. const cwd = process.cwd(); const sindriJsonPath = findFileUpwards(/^sindri.json$/i, cwd); @@ -130,9 +76,11 @@ export const execCommand = new Command() await docker.ping(); } catch (error) { sindri.logger.error( - 'Docker is not installed or running, but is requiring for "sindri exec".', + "Docker is either not installed or the daemon isn't currently running, but it is " + + 'required by "sindri exec". Please install Docker by following the instructions at: ' + + "https://docs.docker.com/get-docker/", ); - sindri.logger.error(error); + sindri.logger.debug(error); process.exit(1); } }); diff --git a/src/cli/utils.ts b/src/cli/utils.ts index d21dedf..d684b9e 100644 --- a/src/cli/utils.ts +++ b/src/cli/utils.ts @@ -1,8 +1,12 @@ import { constants as fsConstants, readdirSync, readFileSync } from "fs"; import { access, mkdir, readdir, readFile, stat, writeFile } from "fs/promises"; +import os from "os"; import path from "path"; +import process from "process"; +import { Writable } from "stream"; import { fileURLToPath } from "url"; +import Docker from "dockerode"; import type { Schema } from "jsonschema"; import nunjucks from "nunjucks"; import type { PackageJson } from "type-fest"; @@ -12,6 +16,169 @@ import type { Logger } from "lib/logging"; const currentFilePath = fileURLToPath(import.meta.url); const currentDirectoryPath = path.dirname(currentFilePath); +/** A writable stream that discards all input. */ +export const devNull = new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, +}); + +/** Executes an external command in a Docker container. + * + * @param command - The command to execute, corresponds to a `docker-zkp` image. + * @param args - The arguments to pass to the command. + * @param options - Additional options for the command. + * @param options.cwd - The current working directory on the host for the executed command. + * @param options.docker - The `Docker` instance to use for running the command. Defaults to a new + * `Docker` instance with default options. + * @param options.logger - The logger to use for logging messages. There will be no logging if not + * specified. + * @param options.rootDirectory - The project root directory on the host. Will be determined by + * searching up the directory tree for a `sindri.json` file if not specified. This directory is + * mounted into the Docker container at `/sindri/`. + * @param options.stream - The stream to write the command's output to. Defaults to discarding all + * output if not specified. + * @param options.tag - The tag of the Docker image to use. Defaults to `auto`, which will map to + * the `latest` tag unless a version specifier is found in `sindri.json` that supersedes it. + * + * @returns The exit code of the command. + */ +export async function execDockerCommand( + command: "circomspect", + args: string[] = [], + { + cwd = process.cwd(), + docker = new Docker(), + logger, + rootDirectory, + stream = devNull, + tag = "auto", + }: { + cwd?: string; + docker? Docker; + logger?: Logger; + rootDirectory?: string; + stream: NodeJS.WritableStream | NodeJS.WritableStream[]; + tag?: string; + }, +): Promise { + // Determine the image to use. + const image = + command === "circomspect" + ? `sindrilabs/circomspect:${tag === "auto" ? "latest" : tag}` + : null; + if (!image) { + throw new Error(`The command "${command}" is not supported.`); + } + + // Find the project root if one wasn't specified. + if (!rootDirectory) { + const cwd = process.cwd(); + const sindriJsonPath = findFileUpwards(/^sindri.json$/i, cwd); + if (sindriJsonPath) { + rootDirectory = path.dirname(sindriJsonPath); + } else { + rootDirectory = cwd; + logger?.warn( + `No "sindri.json" file was found in or above "${cwd}", ` + + `using the current directory as the project root.`, + ); + } + } + rootDirectory = path.normalize(path.resolve(rootDirectory)); + + // Pull the appropriate image. + logger?.debug(`Pulling the "${image}" image.`); + try { + await new Promise((resolve, reject) => { + docker.pull( + image, + (error: Error | null, stream: NodeJS.ReadableStream) => { + if (error) { + reject(error); + } else { + docker.modem.followProgress(stream, (error, result) => + error ? reject(error) : resolve(result), + ); + } + }, + ); + }); + } catch (error) { + logger?.error(`Failed to pull the "${image}" image.`); + logger?.error(error); + return process.exit(1); + } + + // Remap the root directory to its location on the host system when running in development mode. + // This is because the development container has the project root mounted at `/sindri/`, but the + // mounts are performed on the host system so the paths need to exist there. + let mountDirectory: string = rootDirectory; + if (process.env.SINDRI_DEVELOPMENT_HOST_ROOT) { + if (rootDirectory === "/sindri" || rootDirectory.startsWith("/sindri/")) { + mountDirectory = rootDirectory.replace( + "/sindri", + process.env.SINDRI_DEVELOPMENT_HOST_ROOT, + ); + logger?.debug( + `Remapped "${rootDirectory}" to "${mountDirectory}" for bind mount on the Docker host.`, + ); + } else { + logger?.fatal( + `The root directory path "${rootDirectory}" must be under "/sindri/"` + + 'when using "SINDRI_DEVELOPMENT_HOST_ROOT".', + ); + return process.exit(1); + } + } + + // Remap the current working directory to its location inside the container. If the user is in a + // subdirectory of the project root, we need to remap the current working directory to the same + // subdirectory inside the container. + const relativeCwd = path.relative(rootDirectory, cwd); + let internalCwd: string; + if (relativeCwd.startsWith("..")) { + internalCwd = "/sindri/"; + logger?.warn( + `The current working directory ("${cwd}") is not under the project root ` + + `("${rootDirectory}"), will use the project root as the current working directory.`, + ); + } else { + internalCwd = path.join("/sindri/", relativeCwd); + } + logger?.debug( + `Remapped the "${cwd}" working directory to "${internalCwd}" in the Docker container.`, + ); + + // Run circomspect with the project root mounted and pipe the output to stdout. + const data: { StatusCode: number } = await new Promise((resolve, reject) => { + docker.run( + image, + args, + stream, + { + HostConfig: { + Binds: [ + // Circuit project root. + `${mountDirectory}:/sindri`, + // Shared temporary directory. + `${os.tmpdir()}/sindri/:/tmp/sindri/`, + ], + }, + WorkingDir: internalCwd, + }, + (error, data) => { + if (error) { + reject(error); + } else { + resolve(data); + } + }, + ); + }); + return data.StatusCode; +} + /** * Checks whether or not a file (including directories) exists. *