From 6a3419516619e88f219988e499352ed6a604a169 Mon Sep 17 00:00:00 2001 From: Danilo Tuler Date: Mon, 21 Oct 2024 11:54:10 -0400 Subject: [PATCH] feat(cli): tools exec wrappers --- apps/cli/.eslintrc.cjs | 6 +- apps/cli/package.json | 2 +- apps/cli/src/exec/cartesi-machine.ts | 35 ++++++ apps/cli/src/exec/crane.ts | 28 +++++ apps/cli/src/exec/genext2fs.ts | 67 ++++++++++++ apps/cli/src/exec/index.ts | 4 + apps/cli/src/exec/mksquashfs.ts | 73 +++++++++++++ apps/cli/src/exec/util.ts | 117 +++++++++++++++++++++ apps/cli/test/exec/cartesi-machine.test.ts | 16 +++ apps/cli/test/exec/crane.test.ts | 16 +++ apps/cli/test/exec/genext2fs.test.ts | 17 +++ apps/cli/test/exec/mksquashfs.test.ts | 16 +++ apps/cli/test/tsconfig.json | 6 -- apps/cli/tsconfig.build.json | 7 ++ apps/cli/tsconfig.json | 3 +- 15 files changed, 403 insertions(+), 10 deletions(-) create mode 100644 apps/cli/src/exec/cartesi-machine.ts create mode 100644 apps/cli/src/exec/crane.ts create mode 100644 apps/cli/src/exec/genext2fs.ts create mode 100644 apps/cli/src/exec/index.ts create mode 100644 apps/cli/src/exec/mksquashfs.ts create mode 100644 apps/cli/src/exec/util.ts create mode 100644 apps/cli/test/exec/cartesi-machine.test.ts create mode 100644 apps/cli/test/exec/crane.test.ts create mode 100644 apps/cli/test/exec/genext2fs.test.ts create mode 100644 apps/cli/test/exec/mksquashfs.test.ts delete mode 100644 apps/cli/test/tsconfig.json create mode 100644 apps/cli/tsconfig.build.json diff --git a/apps/cli/.eslintrc.cjs b/apps/cli/.eslintrc.cjs index e12fe18a..1fe10e94 100644 --- a/apps/cli/.eslintrc.cjs +++ b/apps/cli/.eslintrc.cjs @@ -6,7 +6,11 @@ module.exports = { ], parser: "@typescript-eslint/parser", parserOptions: { - project: ["./tsconfig.eslint.json", "./tsconfig.json"], + project: [ + "./tsconfig.build.json", + "./tsconfig.eslint.json", + "./tsconfig.json", + ], tsconfigRootDir: __dirname, }, }; diff --git a/apps/cli/package.json b/apps/cli/package.json index cf889828..4055cb85 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -85,7 +85,7 @@ "clean": "rimraf dist", "codegen": "run-p codegen:wagmi", "codegen:wagmi": "wagmi generate", - "compile": "tsc -b", + "compile": "tsc -p tsconfig.build.json", "copy-files": "copyfiles -u 1 \"src/**/*.yaml\" \"src/**/*.env\" \"src/**/*.txt\" dist", "lint": "eslint \"src/**/*.ts*\"", "postpack": "rimraf oclif.manifest.json", diff --git a/apps/cli/src/exec/cartesi-machine.ts b/apps/cli/src/exec/cartesi-machine.ts new file mode 100644 index 00000000..d220758b --- /dev/null +++ b/apps/cli/src/exec/cartesi-machine.ts @@ -0,0 +1,35 @@ +import { parse, Range, SemVer } from "semver"; +import { + DockerFallbackOptions, + execaDockerFallback, + ExecaOptionsDockerFallback, +} from "./util.js"; + +export const requiredVersion = new Range("^0.18.1"); + +export const boot = async ( + args: readonly string[], + options: ExecaOptionsDockerFallback, +) => { + return execaDockerFallback("cartesi-machine", args, options); +}; + +export const version = async ( + options?: DockerFallbackOptions, +): Promise => { + const { image } = options || {}; + try { + const { stdout } = await execaDockerFallback( + "cartesi-machine", + ["--version-json"], + { image }, + ); + if (typeof stdout === "string") { + const output = JSON.parse(stdout); + return parse(output.version); + } + return null; + } catch (e: unknown) { + return null; + } +}; diff --git a/apps/cli/src/exec/crane.ts b/apps/cli/src/exec/crane.ts new file mode 100644 index 00000000..4a199d9c --- /dev/null +++ b/apps/cli/src/exec/crane.ts @@ -0,0 +1,28 @@ +import { parse, Range, SemVer } from "semver"; +import { Stream } from "stream"; +import { DockerFallbackOptions, spawnSyncDockerFallback } from "./util.js"; + +export const requiredVersion = new Range("^0.19.1"); + +export const exportImage = async ( + options: { + stdin: Stream; + stdout: Stream; + } & DockerFallbackOptions, +) => { + const { image, stdin, stdout } = options; + return spawnSyncDockerFallback("crane", ["export", "-", "-"], { + image, + stdio: [stdin, stdout, "inherit"], + }); +}; + +export const version = async ( + options?: DockerFallbackOptions, +): Promise => { + const result = spawnSyncDockerFallback("crane", ["version"], options || {}); + if (result.error) { + return null; + } + return parse(result.stdout.toString()); +}; diff --git a/apps/cli/src/exec/genext2fs.ts b/apps/cli/src/exec/genext2fs.ts new file mode 100644 index 00000000..c2f7d8fa --- /dev/null +++ b/apps/cli/src/exec/genext2fs.ts @@ -0,0 +1,67 @@ +import { parse, Range, SemVer } from "semver"; +import { DockerFallbackOptions, execaDockerFallback } from "./util.js"; + +const BLOCK_SIZE = 4096; // fixed at 4k + +export const requiredVersion: Range = new Range("^1.5.6"); + +const baseArgs = (options: { extraBlocks: number }) => [ + "--block-size", + BLOCK_SIZE.toString(), + "--faketime", + "--readjustment", + `+${options.extraBlocks}`, +]; + +export const fromDirectory = async ( + options: { + cwd?: string; + extraSize: number; + input: string; + output: string; + } & DockerFallbackOptions, +) => { + const { cwd, extraSize, image, input, output } = options; + const extraBlocks = Math.ceil(extraSize / BLOCK_SIZE); + return execaDockerFallback( + "xgenext2fs", + [...baseArgs({ extraBlocks }), "--root", input, output], + { cwd, image }, + ); +}; + +export const fromTar = async ( + options: { + cwd?: string; + extraSize: number; + input: string; + output: string; + } & DockerFallbackOptions, +) => { + const { cwd, extraSize, image, input, output } = options; + const extraBlocks = Math.ceil(extraSize / BLOCK_SIZE); + return execaDockerFallback( + "xgenext2fs", + [...baseArgs({ extraBlocks }), "--tarball", input, output], + { cwd, image }, + ); +}; + +export const version = async ( + options?: DockerFallbackOptions, +): Promise => { + const { stdout } = await execaDockerFallback( + "xgenext2fs", + ["--version"], + options || {}, + ); + if (typeof stdout === "string") { + const regex = + /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/; + const m = stdout.match(regex); + if (m && m[0]) { + return parse(m[0]); + } + } + return null; +}; diff --git a/apps/cli/src/exec/index.ts b/apps/cli/src/exec/index.ts new file mode 100644 index 00000000..7d3f5d50 --- /dev/null +++ b/apps/cli/src/exec/index.ts @@ -0,0 +1,4 @@ +export * as cartesiMachine from "./cartesi-machine.js"; +export * as crane from "./crane.js"; +export * as genext2fs from "./genext2fs.js"; +export * as mksquashfs from "./mksquashfs.js"; diff --git a/apps/cli/src/exec/mksquashfs.ts b/apps/cli/src/exec/mksquashfs.ts new file mode 100644 index 00000000..79e60235 --- /dev/null +++ b/apps/cli/src/exec/mksquashfs.ts @@ -0,0 +1,73 @@ +import { parse, Range, SemVer } from "semver"; +import { DockerFallbackOptions, execaDockerFallback } from "./util.js"; + +const COMPRESSION = "lzo"; // make customizable? default is gzip + +export const requiredVersion: Range = new Range("^4.5.1"); + +const baseArgs = () => [ + "--all-time", + "0", + "-all-root", // XXX: should we use this? + "-noappend", + "-comp", + COMPRESSION, + "-no-progress", +]; + +export const fromDirectory = ( + options: { + cwd?: string; + input: string; + output: string; + } & DockerFallbackOptions, +) => { + const { cwd, image, input, output } = options; + return execaDockerFallback("mksquashfs", [input, output, ...baseArgs()], { + cwd, + image, + }); +}; + +export const fromTar = ( + options: { + cwd?: string; + input: string; + output: string; + } & DockerFallbackOptions, +) => { + const { cwd, image, input, output } = options; + return execaDockerFallback( + "mksquashfs", + ["-", output, "-tar", ...baseArgs()], + { + cwd, + image, + inputFile: input, // use stdin in case of tar file + }, + ); +}; + +export const version = async ( + options?: DockerFallbackOptions, +): Promise => { + try { + const { stdout } = await execaDockerFallback( + "mksquashfs", + ["-version"], + options || {}, + ); + if (typeof stdout === "string") { + const regex = + /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/gm; + const m = stdout.match(regex); + if (m && m[0]) { + return parse(m[0]); + } + } + } catch (e: unknown) { + console.error(e); + return null; + } + return null; +}; diff --git a/apps/cli/src/exec/util.ts b/apps/cli/src/exec/util.ts new file mode 100644 index 00000000..7da5d86f --- /dev/null +++ b/apps/cli/src/exec/util.ts @@ -0,0 +1,117 @@ +import { spawnSync, SpawnSyncOptions } from "child_process"; +import { execa, ExecaError, Options } from "execa"; +import os from "os"; + +export type DockerFallbackOptions = + | { image: string; forceDocker: true } + | { image?: string; forceDocker?: false }; + +/** + * Calls execa and falls back to docker run if command (on the host) fails + * @param command command to be executed + * @param args arguments to be passed to the command + * @param options execution options + * @returns return of execa + */ +export type ExecaOptionsDockerFallback = Options & DockerFallbackOptions; +export const execaDockerFallback = async ( + command: string, + args: readonly string[], + options: ExecaOptionsDockerFallback, +) => { + try { + if (options.forceDocker) { + const error = new ExecaError(); + error.code = "ENOENT"; + throw error; + } + return await execa(command, args, options); + } catch (error) { + if (error instanceof ExecaError) { + if (error.code === "ENOENT" && options.image) { + if (!options.forceDocker) { + console.warn( + `error executing '${command}', falling back to docker execution using image '${options.image}'`, + ); + } + const userInfo = os.userInfo(); + const dockerOpts = [ + "--volume", + `${options.cwd}:/work`, + "--workdir", + "/work", + "--interactive", + "--user", + `${userInfo.uid}:${userInfo.gid}`, + ]; + return await execa( + "docker", + ["run", ...dockerOpts, options.image, command, ...args], + options, + ); + } else { + console.error(`error executing '${command}'`, error); + } + } + throw error; + } +}; + +/** + * Calls spawnSync and falls back to docker run if command (on the host) fails + * @param command command to be executed + * @param args arguments to be passed to the command + * @param options execution options + * @returns return of execa + */ +export type SpawnOptionsDockerFallback = SpawnSyncOptions & + DockerFallbackOptions; +export const spawnSyncDockerFallback = ( + command: string, + args: readonly string[], + options: SpawnOptionsDockerFallback, +) => { + const result = options.forceDocker + ? { error: { code: "ENOENT" }, stdout: "" } + : spawnSync(command, args, options); + if (result.error) { + const code = (result.error as any).code; + if (code === "ENOENT" && options.image) { + if (!options.forceDocker) { + console.warn( + `error executing '${command}', falling back to docker execution using image '${options.image}'`, + ); + } + const userInfo = os.userInfo(); + const dockerOpts = [ + "--volume", + `${options.cwd}:/work`, + "--workdir", + "/work", + "--interactive", + "--user", + `${userInfo.uid}:${userInfo.gid}`, + ]; + const dockerArgs = [ + "run", + ...dockerOpts, + options.image, + command, + ...args, + ]; + const dockerResult = spawnSync("docker", dockerArgs, options); + if (dockerResult.error) { + console.error( + `error executing '${command}'`, + dockerResult.error, + ); + throw dockerResult.error; + } + return dockerResult; + } else { + console.error(`error executing '${command}'`, result.error); + throw result.error; + } + } + return result; +}; diff --git a/apps/cli/test/exec/cartesi-machine.test.ts b/apps/cli/test/exec/cartesi-machine.test.ts new file mode 100644 index 00000000..99beccce --- /dev/null +++ b/apps/cli/test/exec/cartesi-machine.test.ts @@ -0,0 +1,16 @@ +import { satisfies } from "semver"; +import { describe, expect, it } from "vitest"; +import { cartesiMachine } from "../../src/exec/index.js"; + +describe("cartesi-machine", () => { + it("should report version", async () => { + const version = await cartesiMachine.version({ + forceDocker: true, + image: "cartesi/sdk:0.11.0", + }); + expect(version).toBeDefined(); + expect( + satisfies(version!.format(), cartesiMachine.requiredVersion), + ).toBeTruthy(); + }); +}); diff --git a/apps/cli/test/exec/crane.test.ts b/apps/cli/test/exec/crane.test.ts new file mode 100644 index 00000000..3cf9388a --- /dev/null +++ b/apps/cli/test/exec/crane.test.ts @@ -0,0 +1,16 @@ +import { satisfies } from "semver"; +import { describe, expect, it } from "vitest"; +import { crane } from "../../src/exec/index.js"; + +describe("crane", () => { + it("should report version", async () => { + const version = await crane.version({ + forceDocker: true, + image: "cartesi/sdk:0.11.0", + }); + expect(version).toBeDefined(); + expect( + satisfies(version!.format(), crane.requiredVersion), + ).toBeTruthy(); + }); +}); diff --git a/apps/cli/test/exec/genext2fs.test.ts b/apps/cli/test/exec/genext2fs.test.ts new file mode 100644 index 00000000..4fc21671 --- /dev/null +++ b/apps/cli/test/exec/genext2fs.test.ts @@ -0,0 +1,17 @@ +import { satisfies } from "semver"; +import { describe, expect, it } from "vitest"; +import { genext2fs } from "../../src/exec/index.js"; + +describe("genext2fs", () => { + it("should report version", async () => { + const version = await genext2fs.version({ + forceDocker: true, + image: "cartesi/sdk:0.11.0", + }); + + expect(version).toBeDefined(); + expect( + satisfies(version!.format(), genext2fs.requiredVersion), + ).toBeTruthy(); + }); +}); diff --git a/apps/cli/test/exec/mksquashfs.test.ts b/apps/cli/test/exec/mksquashfs.test.ts new file mode 100644 index 00000000..783ade86 --- /dev/null +++ b/apps/cli/test/exec/mksquashfs.test.ts @@ -0,0 +1,16 @@ +import { satisfies } from "semver"; +import { describe, expect, it } from "vitest"; +import { mksquashfs } from "../../src/exec/index.js"; + +describe("mksquashfs", () => { + it("should report version", async () => { + const version = await mksquashfs.version({ + forceDocker: true, + image: "cartesi/sdk:0.11.0", + }); + expect(version).toBeDefined(); + expect( + satisfies(version!.format(), mksquashfs.requiredVersion), + ).toBeTruthy(); + }); +}); diff --git a/apps/cli/test/tsconfig.json b/apps/cli/test/tsconfig.json deleted file mode 100644 index 342af470..00000000 --- a/apps/cli/test/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "../tsconfig", - "compilerOptions": { - "noEmit": true - } -} diff --git a/apps/cli/tsconfig.build.json b/apps/cli/tsconfig.build.json new file mode 100644 index 00000000..d5567cc5 --- /dev/null +++ b/apps/cli/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "rootDir": "src" + } +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 1dfaaee1..a1322dbe 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -1,12 +1,11 @@ { "extends": "tsconfig/base.json", - "include": ["src/**/*.ts"], + "include": ["**/*.ts"], "exclude": ["node_modules"], "compilerOptions": { "module": "ES2020", "importHelpers": true, "outDir": "dist", - "rootDir": "src", "target": "es2020" }, "ts-node": {