Skip to content

Commit

Permalink
Refactor the Docker command execution into a utility function.
Browse files Browse the repository at this point in the history
  • Loading branch information
sangaline committed Feb 23, 2024
1 parent 5ba8fc3 commit 36eec97
Show file tree
Hide file tree
Showing 2 changed files with 189 additions and 74 deletions.
96 changes: 22 additions & 74 deletions src/cli/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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()
Expand All @@ -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 <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);
Expand All @@ -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);
}
});
167 changes: 167 additions & 0 deletions src/cli/utils.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;

Check failure on line 58 in src/cli/utils.ts

View workflow job for this annotation

GitHub Actions / lint-and-test (18)

';' expected.

Check failure on line 58 in src/cli/utils.ts

View workflow job for this annotation

GitHub Actions / lint-and-test (19)

';' expected.

Check failure on line 58 in src/cli/utils.ts

View workflow job for this annotation

GitHub Actions / lint-and-test (20)

';' expected.

Check failure on line 58 in src/cli/utils.ts

View workflow job for this annotation

GitHub Actions / lint-and-test (21)

';' expected.
logger?: Logger;
rootDirectory?: string;
stream: NodeJS.WritableStream | NodeJS.WritableStream[];
tag?: string;
},
): Promise<number> {
// 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.
*
Expand Down

0 comments on commit 36eec97

Please sign in to comment.