sindri
Version:
The Sindri Labs JavaScript SDK and CLI tool.
735 lines (690 loc) • 24 kB
text/typescript
import assert from "assert";
import { spawn } from "child_process";
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 { type Duplex, Writable } from "stream";
import { fileURLToPath } from "url";
import axios from "axios";
import { compareVersions } from "compare-versions";
import Docker from "dockerode";
import type { Schema } from "jsonschema";
import nunjucks from "nunjucks";
import type { PackageJson } from "type-fest";
import { type Meta, validateMetaEntry } from "lib/utils";
import type { Logger } from "lib/logging";
const currentFilePath = fileURLToPath(import.meta.url);
const currentDirectoryPath = path.dirname(currentFilePath);
/**
* Checks if a given command exists in the system's PATH.
*
* This function attempts to spawn the command with the `--version` flag, assuming that most
* commands will support it or at least not have side effects when it is passed.
*
* @param command - The name of the command to check.
*
* @returns A boolean indicating whether the command exists.
*/
export function checkCommandExists(command: string): Promise<boolean> {
return new Promise((resolve) => {
// TODO: Circomspect doesn't support this argument, so this will always fail for that command.
const process = spawn(command, ["--version"]);
process.on("error", () => {
// Command could not be spawned or was not found in the PATH
resolve(false);
});
process.on("exit", (code) => {
// Command exists if there are no errors or the exit code isn't 127.
resolve(code !== 127 && code !== null);
});
});
}
/**
* Checks whether we can connect to the Docker daemon.
*
* @returns A boolean value indicating whether the Docker daemon is accessible.
*/
export async function checkDockerAvailability(
logger?: Logger,
): Promise<boolean> {
const docker = new Docker();
try {
await docker.ping();
} catch (error) {
logger?.debug("Failed to connect to the Docker daemon.");
logger?.debug(error);
return false;
}
logger?.debug("Docker daemon is accessible.");
return true;
}
/**
* Collects a metadata key/value from the command line arguments.
*
* Note that this will exit the process if an entry is invalid.
*
* @param assignment - The key/value assignment to collect.
* @param previousMeta - The metadata collected so far.
* @param logger - An optional logger to use for logging messages.
* @returns The updated metadata.
*/
export function collectMeta(
assignment: string,
previousMeta: Meta,
logger: Logger,
): Meta {
// Split the assignment into a key and value.
const equalIndex = assignment.indexOf("=");
if (equalIndex === -1) {
logger.fatal(
`Invalid metadata segment '${assignment}' (missing '=', try 'key=value'). Aborting.`,
);
process.exit(1);
}
const [key, value] = [
assignment.slice(0, equalIndex),
assignment.slice(equalIndex + 1),
];
// Validate the key and value.
const validationError = validateMetaEntry(key, value);
if (validationError) {
logger.fatal(
{ key, value },
`Invalid metadata entry '${assignment}'. ${validationError} Aborting.`,
);
process.exit(1);
}
return { ...previousMeta, [key]: value };
}
/** Binds {@link collectMeta} to a specific logger instance.
*
* @param logger - The logger to use for logging messages.
*/
export const collectMetaWithLogger =
(logger: Logger) =>
(assignment: string, previousMeta: Meta): Meta =>
collectMeta(assignment, previousMeta, logger);
/**
* Supported external commands, each must correspond to a `docker-zkp` image repository.
*/
type ExternalCommand = "circomspect" | "nargo";
/**
* A writable stream that discards all input.
*/
export const devNull = new Writable({
write(_chunk, _encoding, callback) {
callback();
},
});
/**
* Executes an external command, either locally or 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 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/` if the command is executed in Docker.
* @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.
* @param options.tty - Whether to use a TTY for the command. Defaults to `false` which means that
* the command's output will be ignored.
*
* @returns The exit code of the command, or `null` if the command is not available locally or in
* Docker.
*/
export async function execCommand(
command: ExternalCommand,
args: string[] = [],
{
cwd = process.cwd(),
docker = new Docker(),
logger,
rootDirectory,
tag = "auto",
tty = false,
}: {
cwd?: string;
docker?: Docker;
logger?: Logger;
rootDirectory?: string;
tag?: string;
tty?: boolean;
},
): Promise<
{ code: number; method: "docker" | "local" } | { code: null; method: null }
> {
// Try using a local command first (unless `SINDRI_FORCE_DOCKER` is set).
if (isTruthy(process.env.SINDRI_FORCE_DOCKER ?? "false")) {
logger?.debug(
`Forcing docker usage for command "${command}" because "SINDRI_FORCE_DOCKER" is set to ` +
`"${process.env.SINDRI_FORCE_DOCKER}".`,
);
} else if (await checkCommandExists(command)) {
logger?.debug(
{ args, command },
`Executing the "${command}" command locally.`,
);
return {
code: await execLocalCommand(command, args, { cwd, logger, tty }),
method: "local",
};
} else {
logger?.debug(
`The "${command}" command was not found locally, trying Docker instead.`,
);
}
// Fall back to using Docker if possible.
if (await checkDockerAvailability(logger)) {
logger?.debug(
{ args, command },
`Executing the "${command}" command in a Docker container.`,
);
return {
code: await execDockerCommand(command, args, {
cwd,
docker,
logger,
rootDirectory,
tag,
tty,
}),
method: "docker",
};
}
// There's no way to run the command.
logger?.debug(
`The "${command}" command is not available locally or in Docker.`,
);
return { code: null, method: null };
}
/**
* 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.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.
* @param options.tty - Whether to use a TTY for the command. Defaults to `false` which means that
* the command's output will be ignored.
*
* @returns The exit code of the command.
*/
export async function execDockerCommand(
command: ExternalCommand,
args: string[] = [],
{
cwd = process.cwd(),
docker = new Docker(),
logger,
rootDirectory,
tag = "auto",
tty = false,
}: {
cwd?: string;
docker?: Docker;
logger?: Logger;
rootDirectory?: string;
tag?: string;
tty?: boolean;
},
): Promise<number> {
// Find the project root if one wasn't specified.
const sindriJsonPath = findFileUpwards(/^sindri.json$/i, cwd);
if (!rootDirectory) {
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));
// Determine the image to use.
let image: string;
if (command === "nargo" && tag === "auto") {
let tag = "latest";
if (sindriJsonPath) {
try {
const sindriJsonContent = await readFile(sindriJsonPath, {
encoding: "utf-8",
});
const sindriJson = JSON.parse(sindriJsonContent);
if (sindriJson.noirVersion) {
tag = sindriJson.noirVersion;
if (tag && !tag.startsWith("v")) {
tag = `v${tag}`;
}
}
} catch (error) {
logger?.error(
`Failed to parse the "${sindriJsonPath}" file, ` +
'using the "latest" tag for the "nargo" command.',
);
logger?.debug(error);
}
} else {
logger?.warn(
`No "sindri.json" file was found in or above "${cwd}", ` +
'using the "latest" tag for the "nargo" command.',
);
}
image = `sindrilabs/${command}:${tag}`;
} else if (["circomspect", "nargo"].includes(command)) {
image = `sindrilabs/${command}:${tag === "auto" ? "latest" : tag}`;
} else {
throw new Error(`The command "${command}" is not supported.`);
}
// 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 the command with the project root mounted and pipe the output to stdout.
const data: { StatusCode: number } = await new Promise((resolve, reject) => {
docker
.run(
image,
args,
tty ? [process.stdout, process.stderr] : devNull,
{
AttachStderr: tty,
AttachStdin: tty,
AttachStdout: tty,
HostConfig: {
Binds: [
// Circuit project root.
`${mountDirectory}:/sindri`,
// Shared temporary directory.
`/tmp/sindri/:/tmp/sindri/`,
],
},
OpenStdin: tty,
StdinOnce: false,
Tty: tty,
WorkingDir: internalCwd,
},
(error, data) => {
if (error) {
reject(error);
} else {
resolve(data);
}
},
)
.on("container", (container) => {
if (!tty) return;
// Attach stdin/stdout/stderr if we're running in TTY mode.
const stream = container.attach(
{
stream: true,
stdin: true,
stdout: true,
stderr: true,
},
function (error: Error, stream: Duplex) {
if (error) {
reject(error);
}
// Connect stdin and stdout.
// Note that stderr is redirected into stdout because this is the normal TTY behavior.
stream.pipe(process.stdout);
},
);
// Ensure the stream is resumed because streams start paused.
if (stream) {
stream.resume();
}
});
});
return data.StatusCode;
}
/**
* Executes a command locally.
*
* @param command - The command to execute.
* @param args - The arguments to pass to the command.
* @param options - Additional options for the command.
* @param options.cwd - The current working directory for the executed command.
* @param options.logger - The logger to use for logging messages. There will be no logging if not
* specified.
* @param options.tty - Whether to use a TTY for the command. Defaults to `false` which means that
* the command's output will be ignored.
*
* @returns The exit code of the command.
*/
export async function execLocalCommand(
command: ExternalCommand,
args: string[] = [],
{
cwd = process.cwd(),
logger,
tty = false,
}: {
cwd?: string;
logger?: Logger;
tty?: boolean;
},
): Promise<number> {
const child = spawn(command, args, {
cwd,
stdio: tty ? "inherit" : "ignore",
});
try {
const code: number = await new Promise((resolve, reject) => {
child.on("error", (error) => {
reject(error);
});
child.on("close", (code, signal) => {
// If the command exits with a signal (e.g. `SIGABRT`), then follow the common convention of
// mapping this to an exit code of: 128 + (the signal number).
if (code == null && signal != null) {
code = 128 + os.constants.signals[signal];
}
assert(code != null);
resolve(code);
});
});
return code;
} catch (error) {
logger?.error(`Failed to execute the "${command}" command.`);
logger?.error(error);
return process.exit(1);
}
}
/**
* Checks whether or not a file (including directories) exists.
*
* @param filePath - The path of the file to check.
* @returns A boolean value indicating whether the file path exists.
*/
export async function fileExists(filePath: string): Promise<boolean> {
try {
await access(filePath, fsConstants.F_OK);
return true;
} catch {
return false;
}
}
/**
* Recursively searches for a file in the given directory and its parent directories.
*
* @param filename - The name or regular expression of the file to find.
* @param initialDirectory - The directory to start the search in.
* @returns The fully qualified path of the first file found, or `null` if none is found.
*/
export function findFileUpwards(
filename: string | RegExp,
initialDirectory: string = currentDirectoryPath,
): string | null {
// List files in the current directory.
const files = readdirSync(initialDirectory);
// Check if any file matches the filename.
for (const file of files) {
if (
typeof filename === "string" ? file === filename : filename.test(file)
) {
return path.join(initialDirectory, file);
}
}
// If the parent directory is the same as the current, we've reached the root.
const parentDirectory = path.dirname(initialDirectory);
if (parentDirectory === initialDirectory) {
return null;
}
// Recursively search in the parent directory.
return findFileUpwards(filename, parentDirectory);
}
/**
* Retrieves the available tags for a Docker image from DockerHub, ordered from oldest to newest.
*
* @param repository - The name of the Docker image repository.
* @param username - The DockerHub username of the repository owner (default: "sindrilabs").
*
* @returns An array of available tags for the Docker image.
*/
export async function getDockerImageTags(
repository: string,
username: string = "sindrilabs",
): Promise<string[]> {
let url: string | undefined =
`https://hub.docker.com/v2/repositories/${username}/${repository}/tags/?page_size=1`;
interface Result {
last_updated: string;
name: string;
tag_status: string;
}
interface Response {
count: number;
next?: string;
previous: string | null;
results: Result[];
}
let results: Result[] = [];
while (url) {
const response: { data: Response } = await axios.get<Response>(url);
results = results.concat(response.data.results);
url = response.data.next; // Update the URL for the next request, or null if no more pages
}
return results
.filter(({ tag_status }) => tag_status === "active")
.filter(({ name }) => name !== "dev")
.sort((a, b) => a.last_updated.localeCompare(b.last_updated))
.map(({ name }) => name)
.sort((a, b) =>
a === "latest" ? 1 : b === "latest" ? -1 : compareVersions(a, b),
);
}
/**
* Determines if a string represents a truthy value.
*
* @param {string} str - The string to check for truthiness.
*
* @returns {boolean} `true` if the string represents a truthy value, otherwise `false`.
*/
export function isTruthy(str: string): boolean {
const truthyValues = ["1", "true", "t", "yes", "y", "on"];
return truthyValues.includes(str.toLowerCase());
}
/**
* Loads the project's `package.json` file.
*
* @returns The contents of `package.json`.
*/
export function loadPackageJson(): PackageJson {
const packageJsonPath = locatePackageJson();
const packageJsonContent = readFileSync(packageJsonPath, {
encoding: "utf-8",
});
const packageJson: PackageJson = JSON.parse(packageJsonContent);
return packageJson;
}
/**
* Loads the project's `sindri-manifest.json` file.
*
* @returns The contents of `sindri-manifest.json`.
*/
export function loadSindriManifestJsonSchema(): Schema {
const sindriManifestJsonPath = findFileUpwards("sindri-manifest.json");
if (!sindriManifestJsonPath) {
throw new Error(
"A `sindri-manifest.json` file was unexpectedly not found.",
);
}
const sindriManifestJsonContent = readFileSync(sindriManifestJsonPath, {
encoding: "utf-8",
});
const sindriManifestJson: Schema = JSON.parse(sindriManifestJsonContent);
return sindriManifestJson;
}
/**
* Locates the project's `package.json` file.
*
* @returns The fully qualified path to `package.json`.
*/
export function locatePackageJson(): string {
const packageJsonPath = findFileUpwards("package.json");
if (!packageJsonPath) {
throw new Error("A `package.json` file was unexpectedly not found.");
}
return packageJsonPath;
}
/**
* Recursively copies and populates the contents of a template directory into an output directory.
*
* @param templateDirectory - The path to the template directory. Can be an absolute path or a
* subdirectory of the `templates/` directory in the project root.
* @param outputDirectory - The path to the output directory where the populated templates will be
* written.
* @param context - The nunjucks template context.
* @param logger - The logger to use for debug messages.
*/
export async function scaffoldDirectory(
templateDirectory: string,
outputDirectory: string,
context: object,
logger?: Logger,
): Promise<void> {
// Normalize the paths and create the output directory if necessary.
const fullOutputDirectory = path.resolve(outputDirectory);
if (!(await fileExists(fullOutputDirectory))) {
await mkdir(fullOutputDirectory, { recursive: true });
}
const rootTemplateDirectory = findFileUpwards("templates");
if (!rootTemplateDirectory) {
throw new Error("Root template directory not found.");
}
const fullTemplateDirectory = path.isAbsolute(templateDirectory)
? templateDirectory
: path.resolve(rootTemplateDirectory, templateDirectory);
if (!(await fileExists(fullTemplateDirectory))) {
throw new Error(`The "${fullTemplateDirectory}" directory does not exist.`);
}
// Render a template using two syntaxes:
// * hacky `templateVARIABLENAME` syntax.
// * `nunjucks` template syntax.
const render = (content: string, context: object): string => {
let newContent = content;
// Poor man's templating with `templateVARIABLENAME`:
Object.entries(context).forEach(([key, value]) => {
if (typeof value !== "string") return;
newContent = newContent.replace(
new RegExp(`template${key.toUpperCase()}`, "gi"),
value,
);
});
// Real templating:
return nunjucks.renderString(newContent, context);
};
// Process the template directory recursively.
const processPath = async (
inputPath: string,
outputPath: string,
): Promise<void> => {
// Handle directories.
if ((await stat(inputPath)).isDirectory()) {
// Ensure the output directory exists.
if (!(await fileExists(outputPath))) {
await mkdir(outputPath, { recursive: true });
logger?.debug(`Created directory: "${outputPath}"`);
}
if (!(await stat(outputPath)).isDirectory()) {
throw new Error(`"File ${outputPath} exists and is not a directory.`);
}
// Process all files in the directory.
const files = await readdir(inputPath);
await Promise.all(
files.map(async (file) => {
// Render the filename so that `outputPath` always corresponds to the true output path.
// This handles situations like `{{ circuitName }}.go` where there's a variable in the name.
const populatedFile = render(file, context);
await processPath(
path.join(inputPath, file),
path.join(outputPath, populatedFile),
);
}),
);
return;
}
// Handle files, rendering them and writing them out.
const template = await readFile(inputPath, { encoding: "utf-8" });
const renderedTemplate = render(template, context);
await writeFile(outputPath, renderedTemplate, { encoding: "utf-8" });
logger?.debug(`Rendered "${inputPath}" template to "${outputPath}".`);
};
await processPath(fullTemplateDirectory, fullOutputDirectory);
}