sindri
Version:
The Sindri Labs JavaScript SDK and CLI tool.
244 lines (227 loc) • 8.52 kB
text/typescript
import assert from "node:assert";
import { Blob } from "buffer";
import { existsSync, readFileSync } from "fs";
import path from "path";
import process from "process";
import { Command } from "@commander-js/extra-typings";
import { FormData } from "formdata-node";
import walk from "ignore-walk";
import tar from "tar";
import { collectMetaWithLogger, findFileUpwards } from "cli/utils";
import sindri from "lib";
import { ApiError, CircuitInfoResponse, CircuitStatusResponse } from "lib/api";
import { getDefaultMeta } from "lib/utils";
interface CollectedTags extends Array<string> {
isNotDefault?: boolean;
}
function collectTags(value: string, previous: CollectedTags): CollectedTags {
// The first time this function is called, `previous` will be the default `["latest"]` value
// without `isNotDefault` set. It will only be called if at least one explicit tag was specified,
// so we want to drop this initial default value when this happens. We track that we've already
// done this by setting `isNotDefault` on the array so that we don't do it again.
const merged: CollectedTags = previous.isNotDefault
? previous.concat([value])
: [value];
merged.isNotDefault = true;
return merged;
}
export const deployCommand = new Command()
.name("deploy")
.description("Deploy the current Sindri project.")
.option(
"-m, --meta <key=value>",
"Metadata key/value to attach to the circuit build.",
collectMetaWithLogger(sindri.logger),
getDefaultMeta({ logger: sindri.logger, raiseExceptions: false }),
)
.option("-t, --tag <tag>", "Tag to apply to the circuit.", collectTags, [
"latest",
])
.option("-u, --untagged", "Discard the current circuit after compiling.")
.argument("[directory]", "The location of the Sindri project to deploy.", ".")
.action(async (directory, { meta, tag: tags, untagged }) => {
// Validate the tags and "untagged" option.
if (untagged) {
if (tags.length !== 1 || tags[0] !== "latest" || tags.isNotDefault) {
sindri.logger.error(
"You cannot use both the `--tag` and `--untagged` options together.",
);
return process.exit(1);
}
} else {
for (const tag of tags) {
if (!/^[-a-zA-Z0-9_.]+$/.test(tag)) {
sindri.logger.error(
`"${tag}" is not a valid tag. Tags may only contain alphanumeric characters, ` +
"underscores, hyphens, and periods.",
);
return process.exit(1);
}
}
}
// Find `sindri.json` and move into the root of the project directory.
const directoryPath = path.resolve(directory);
if (!existsSync(directoryPath)) {
sindri.logger.error(
`The "${directoryPath}" directory does not exist. Aborting.`,
);
return process.exit(1);
}
const sindriJsonPath = findFileUpwards(/^sindri.json$/i, directoryPath);
if (!sindriJsonPath) {
sindri.logger.error(
`No "sindri.json" file was found in or above "${directoryPath}". Aborting.`,
);
return process.exit(1);
}
sindri.logger.debug(`Found "sindri.json" at "${sindriJsonPath}".`);
const rootDirectory = path.dirname(sindriJsonPath);
sindri.logger.debug(`Changing current directory to "${rootDirectory}".`);
process.chdir(rootDirectory);
// Load `sindri.json`.
let sindriJson: object = {};
try {
const sindriJsonContent = readFileSync(sindriJsonPath, {
encoding: "utf-8",
});
sindriJson = JSON.parse(sindriJsonContent);
sindri.logger.debug(
`Successfully loaded "sindri.json" from "${sindriJsonPath}":`,
);
sindri.logger.debug(sindriJson);
} catch (error) {
sindri.logger.fatal(
`Error loading "${sindriJsonPath}", perhaps it is not valid JSON?`,
);
sindri.logger.error(error);
return process.exit(1);
}
if (!("name" in sindriJson)) {
sindri.logger.error('No "name" field found in "sindri.json". Aborting.');
return process.exit(1);
}
const circuitName = sindriJson.name;
// Check that the API client is authorized.
if (!sindri.apiKey || !sindri.baseUrl) {
sindri.logger.warn("You must login first with `sindri login`.");
return process.exit(1);
}
// Create a project tarball and prepare the form data for upload.
const files = walk
.sync({
follow: true,
ignoreFiles: [".sindriignore"],
path: ".",
})
.filter(
(file) =>
// Always exclude `.git` subdirectories.
!/(^|\/)\.git(\/|$)/.test(file),
);
// Always include the `sindri.json` file.
const sindriJsonFilename = path.basename(sindriJsonPath);
if (!files.includes(sindriJsonFilename)) {
files.push(sindriJsonFilename);
}
const formData = new FormData();
const tarballFilename = `${circuitName}.tar.gz`;
sindri.logger.info(
`Creating "${tarballFilename}" package with ${files.length} files.`,
);
formData.append(
"files",
new Blob([
tar
.c(
{
gzip: true,
onwarn: (code: string, message: string) => {
sindri.logger.warn(
`While creating tarball: ${code} - ${message}`,
);
},
prefix: `${circuitName}/`,
sync: true,
},
files,
)
// @ts-expect-error - @types/tar doesn't handle the `sync` option correctly.
.read(),
]),
tarballFilename,
);
// Atach the metadata to the form data.
formData.append("meta", JSON.stringify(meta));
// Attach the tags to the form data.
if (untagged) {
formData.append("tags", "");
} else {
for (const tag of tags) {
formData.append("tags", tag);
}
}
// Upload the tarball.
let circuitId: string | undefined;
try {
sindri.logger.info("Circuit compilation initiated.");
const response = await sindri._client.circuits.circuitCreate(formData);
circuitId = response.circuit_id;
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
sindri.logger.error(
"Your credentials are invalid. Please log in again with `sindri login`.",
);
} else {
sindri.logger.fatal("An unknown error occurred.");
sindri.logger.error(error);
return process.exit(1);
}
}
if (!circuitId) {
sindri.logger.error("No circuit ID was returned from the API. Aborting.");
return process.exit(1);
}
// Poll for circuit compilation to complete.
const startTime = Date.now();
while (true) {
try {
sindri.logger.debug("Polling for circuit compilation status.");
// Get the fast status response first for performance.
const statusResponse: CircuitStatusResponse =
await sindri._client.internal.circuitStatus(circuitId);
// Get the full circuit response only if the compilation is complete or failed.
const response: CircuitInfoResponse | null = [
"Failed",
"Ready",
].includes(statusResponse.status)
? await sindri._client.circuits.circuitDetail(circuitId, false)
: null;
// Check the circuit status and log the appropriate message.
const elapsedSeconds = ((Date.now() - startTime) / 1000).toFixed(1);
if (statusResponse.status === "Ready") {
sindri.logger.info(
`Circuit compiled successfully after ${elapsedSeconds} seconds.`,
);
break;
} else if (statusResponse.status === "Failed") {
assert(response != null);
sindri.logger.error(
`Circuit compilation failed after ${elapsedSeconds} seconds: ` +
(response.error ?? "Unknown error."),
);
return process.exit(1);
} else if (statusResponse.status === "Queued") {
sindri.logger.debug("Circuit compilation is queued.");
} else if (statusResponse.status === "In Progress") {
sindri.logger.debug("Circuit compilation is in progress.");
}
} catch (error) {
sindri.logger.fatal(
"An unknown error occurred while polling for compilation to finish.",
);
sindri.logger.error(error);
return process.exit(1);
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
});