diff --git a/Dockerfile b/Dockerfile index 3887a0e..8d25fc6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ RUN --mount=type=cache,target=/var/cache/apt,id=framework-runtime-node \ apt update \ && apt install -y --no-install-recommends nodejs \ && npm install --global yarn +RUN npm install --global svgo # == python ====================== FROM base AS python @@ -59,6 +60,7 @@ RUN cd $(mktemp -d); \ # == rust ======================== FROM base AS rust # Based on https://github.com/rust-lang/docker-rust/blob/c8d1e4f5c563dacb16b2aadf827f1be3ff3ac25b/1.77.0/bookworm/Dockerfile +# Install rustup, and then use that to install Rust RUN set -eux; \ dpkgArch="$(dpkg --print-architecture)"; \ case "${dpkgArch##*-}" in \ @@ -73,10 +75,45 @@ RUN set -eux; \ ./rustup-init -y --no-modify-path --profile minimal --default-toolchain $RUST_VERSION --default-host ${rustArch}; \ rm rustup-init; \ chmod -R a+w $RUSTUP_HOME $CARGO_HOME; -RUN cargo install rust-script +# Install cargo-binstall, which looks for precompiled binaries of libraries instead of building them here +RUN set -eux; \ + dpkgArch="$(dpkg --print-architecture)"; \ + case "${dpkgArch##*-}" in \ + amd64) binstallArch='x86_64-unknown-linux-gnu' ;; \ + arm64) binstallArch='aarch64-unknown-linux-gnu' ;; \ + *) echo >&2 "unsupported architecture: ${dpkgArch}"; exit 1 ;; \ + esac; \ + url="https://github.com/cargo-bins/cargo-binstall/releases/download/v1.6.4/cargo-binstall-${binstallArch}.tgz"; \ + curl -L --proto '=https' --tlsv1.2 -sSf "$url" | tar -xvzf -; \ + ./cargo-binstall -y --force cargo-binstall +# rust-script is what Framework uses to run Rust data loaders +RUN cargo binstall -y --force rust-script +# all the apache arrow-tools +RUN cargo binstall -y --force csv2arrow csv2parquet json2arrow json2parquet + +# == general-cli ================= +FROM base AS general-cli +RUN --mount=type=cache,target=/var/cache/apt,id=framework-runtime-general-cli \ + set -eux; \ + apt update; \ + apt install -y --no-install-recommends \ + bind9-dnsutils \ + csvkit \ + iputils-ping \ + iputils-tracepath \ + jq \ + nano \ + netcat-openbsd \ + openssl \ + optipng \ + ripgrep \ + silversearcher-ag \ + vim \ + zstd # == runtime ===================== FROM base AS runtime +COPY --from=general-cli . . COPY --from=node . . COPY --from=python . . COPY --from=r . . diff --git a/bin/test.ts b/bin/test.ts new file mode 100755 index 0000000..b516874 --- /dev/null +++ b/bin/test.ts @@ -0,0 +1,43 @@ +import { spawn } from "node:child_process"; +import { glob } from "glob"; +import { IMAGE_TAG, StringStream } from "../tests/index.ts"; +import { dirname } from "node:path"; +import { run as runTests } from "node:test"; +import { spec } from "node:test/reporters"; + +export async function buildTestImage() { + // TODO dockerode uses the HTTP API which doesn't support buildx, so do it with exec instead. + console.log("building image..."); + let stdio = new StringStream(); + let process = spawn("docker", ["buildx", "build", "-t", IMAGE_TAG, ".."], { + cwd: import.meta.dirname, + stdio: "pipe", + }); + process.stdout.pipe(stdio); + process.stderr.pipe(stdio); + + await new Promise((resolve, reject) => { + process.on("close", (code) => { + if (code !== 0) + reject( + new Error( + `docker buildx build failed with code ${code}\n${stdio.string}` + ) + ); + resolve(undefined); + }); + process.on("error", reject); + }); +} + +const files = await glob(["tests/**/*.test.ts"], { + cwd: dirname(import.meta.dirname), +}); + +await buildTestImage(); +runTests({ files, concurrency: true }) + .on("test:fail", () => { + process.exitCode = 1; + }) + .compose(new spec()) + .pipe(process.stdout); diff --git a/package.json b/package.json index db4dd99..fd832c0 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,14 @@ "type": "module", "scripts": { "build": "docker build . -t observablehq/framework-runtime:latest", - "test": "node --import tsx/esm --test ./test.ts" + "test": "yarn tsx bin/test.ts" }, "packageManager": "yarn@4.1.1", "devDependencies": { + "@types/dockerode": "^3.3.28", "@types/node": "^20", "dockerode": "^4.0.2", + "glob": "^10.3.12", "semver": "^7.6.0", "tsx": "^4.7.1", "typescript": "^5.4.3" diff --git a/test.ts b/test.ts deleted file mode 100644 index 6f69f69..0000000 --- a/test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { test, before } from "node:test"; -import assert from "node:assert"; -import Dockerode from "dockerode"; -import { Stream } from "node:stream"; -import semverSatisfies from "semver/functions/satisfies"; -import {spawn} from "node:child_process"; - -const IMAGE_TAG = "observablehq/framework-runtime:test"; -let docker: Dockerode; - -before(async () => { - docker = new Dockerode(); - // TODO dockerode uses the HTTP API which doesn't seem to support buildx. - console.log("Building docker image..."); - let stdio = new StringStream(); - let process = spawn("docker", ["buildx", "build", "-t", IMAGE_TAG, "."], { - cwd: import.meta.dirname, - stdio: "pipe", - }); - process.stdout.pipe(stdio); - process.stderr.pipe(stdio); - - await new Promise((resolve, reject) => { - process.on("close", (code) => { - if (code !== 0) reject(new Error(`docker buildx build failed with code ${code}\n${stdio.string}`)); - resolve(undefined); - }); - process.on("error", reject); - }); -}); - -const binaryChecks: {binary: string, name?: string, semver: string, extract?: RegExp, prefix?: string}[] = [ - {binary: "node", semver: "^20"}, - {binary: "npm", semver: "^10.5"}, - {binary: "yarn", semver: "^1.22"}, - {binary: "python3", semver: "^3.11", prefix: "Python"}, - {binary: "Rscript", semver: "^4.3", extract: /^Rscript \(R\) version ([^\s]+)/}, - {binary: "duckdb", semver: "^0.10.1", extract: /^v(.*) [0-9a-f]*$/}, - {name: "Rust", binary: "cargo", semver: "^1.77", extract: /^cargo ([\d.]+)/}, - {binary: "rust-script", semver: "^0.34", prefix: "rust-script"}, -]; - -for (const {binary, name = binary, semver, extract, prefix} of binaryChecks) { - test(`${name} ${semver} is available`, async () => { - const res = await runCommandInContainer([binary, "--version"]); - assert.equal(res.stderr, ""); - assertSemver(res.stdout, semver, {extract, prefix}); - }); -} - -function assertSemver(actual, expected, {prefix, suffix, extract}: {prefix?: string, suffix?: string, extract?: RegExp} = {}) { - actual = actual.trim(); - if (prefix && actual.startsWith(prefix)) actual = actual.slice(prefix.length); - if (suffix && actual.endsWith(suffix)) actual = actual.slice(0, -suffix.length); - if (extract) { - const match = actual.match(extract); - if (!match) throw new Error(`Expected match for ${extract} in ${actual}`); - if (!match[1]) throw new Error("Expected extract regex to have a capture group"); - actual = match[1]; - } - actual = actual.trim(); - assert.ok(semverSatisfies(actual, expected), `Expected semver match for ${expected}, got ${actual}`); -} - -async function runCommandInContainer( - command: string[] -): Promise<{ stdout: string; stderr: string }> { - const container = await docker.createContainer({ - Image: IMAGE_TAG, - Cmd: command, - }); - const stdout = new StringStream(); - const stderr = new StringStream(); - const attach = await container.attach({ - stream: true, - stdout: true, - stderr: true, - }); - docker.modem.demuxStream(attach, stdout, stderr); - await container.start(); - const wait = (await container.wait()) as { StatusCode: number }; - if (wait.StatusCode !== 0) - throw new Error(`Command failed with status code ${wait.StatusCode}`); - return { stdout: stdout.string, stderr: stderr.string }; -} - -class StringStream extends Stream.PassThrough { - buffers: Buffer[]; - - constructor() { - super(); - this.buffers = []; - this.on("data", (chunk) => this.buffers.push(chunk)); - } - - get string(): string { - return Buffer.concat(this.buffers).toString(); - } -} diff --git a/tests/archives.test.ts b/tests/archives.test.ts new file mode 100644 index 0000000..13e34e5 --- /dev/null +++ b/tests/archives.test.ts @@ -0,0 +1,11 @@ +import { binaryOnPathTest } from "./index.ts"; + +const archiveTools = [ + { binary: "bzip2" }, + { binary: "gzip" }, + { binary: "tar" }, + { binary: "zip" }, + { binary: "zstd" }, +]; + +archiveTools.map(binaryOnPathTest); diff --git a/tests/data-manip.test.ts b/tests/data-manip.test.ts new file mode 100644 index 0000000..b738d76 --- /dev/null +++ b/tests/data-manip.test.ts @@ -0,0 +1,15 @@ +import { binaryOnPathTest, binaryVersionTest } from "./index.ts"; + +const dataManipTools = [ + { binary: "jq" }, + { binary: "in2csv", name: "csvkit" }, + { binary: "csv2parquet" }, +]; + +dataManipTools.map(binaryOnPathTest); + +binaryVersionTest({ + binary: "duckdb", + semver: "^0.10.1", + extract: /^v(.*) [0-9a-f]*$/, +}); diff --git a/tests/dataloader-languages.test.ts b/tests/dataloader-languages.test.ts new file mode 100644 index 0000000..4790244 --- /dev/null +++ b/tests/dataloader-languages.test.ts @@ -0,0 +1,30 @@ +import { binaryVersionTest } from "./index.ts"; + +const dataLoaderLanguages = [ + { binary: "node", semver: "^20" }, + { binary: "npm", semver: "^10.5" }, + { binary: "yarn", semver: "^1.22" }, + { + binary: "python3", + semver: "^3.11", + prefix: "Python", + }, + { + binary: "Rscript", + semver: "^4.3", + extract: /^Rscript \(R\) version ([^\s]+)/, + }, + { + name: "Rust", + binary: "cargo", + semver: "^1.77", + extract: /^cargo ([\d.]+)/, + }, + { + binary: "rust-script", + semver: "^0.34", + prefix: "rust-script", + }, +]; + +dataLoaderLanguages.map(binaryVersionTest); diff --git a/tests/general-cli.test.ts b/tests/general-cli.test.ts new file mode 100644 index 0000000..1ac2e79 --- /dev/null +++ b/tests/general-cli.test.ts @@ -0,0 +1,13 @@ +import { binaryOnPathTest } from "./index.ts"; + +const generalCliTools: { binary: string }[] = [ + { binary: "md5sum" }, + { binary: "sha256sum" }, + { binary: "sha512sum" }, + { binary: "shasum" }, + { binary: "top" }, + { binary: "uptime" }, + { binary: "vmstat" }, +]; + +generalCliTools.map(binaryOnPathTest); diff --git a/tests/images.test.ts b/tests/images.test.ts new file mode 100644 index 0000000..f5340d5 --- /dev/null +++ b/tests/images.test.ts @@ -0,0 +1,9 @@ +import { binaryOnPathTest } from "./index.ts"; + +const imageTools = [ + { binary: "svgo" }, + { binary: "optipng" }, + { binary: "convert", name: "imagemagick" }, +]; + +imageTools.map(binaryOnPathTest); diff --git a/tests/index.ts b/tests/index.ts new file mode 100644 index 0000000..ee32099 --- /dev/null +++ b/tests/index.ts @@ -0,0 +1,117 @@ +import { test, before } from "node:test"; +import assert from "node:assert"; +import Dockerode from "dockerode"; +import { Stream } from "node:stream"; +import semverSatisfies from "semver/functions/satisfies"; +import { basename } from "node:path"; + +export const IMAGE_TAG = "observablehq/framework-runtime:test"; + +export interface AssertBinaryVersionOptions { + binary: string; + name?: string; + semver: string; + extract?: RegExp; + prefix?: string; +} + +export function binaryVersionTest({ + binary, + name = binary, + semver, + extract, + prefix, +}: AssertBinaryVersionOptions) { + test(`${name} ${semver} is available`, async () => { + const res = await runCommandInContainer([binary, "--version"]); + assert.equal(res.stderr, ""); + assertSemver(res.stdout, semver, { extract, prefix }); + }); +} + +export interface AssertBinaryOptions { + binary: string; + name?: string; +} + +export function binaryOnPathTest({ + binary, + name = binary, +}: AssertBinaryOptions) { + test(`${name} is on PATH`, async () => { + const res = await runCommandInContainer(["which", binary]); + assert.equal(res.stderr, ""); + assert.ok(res.stdout.trim().length > 0, "no stdout"); + }); +} + +function assertSemver( + actual, + expected, + { + prefix, + suffix, + extract, + }: { prefix?: string; suffix?: string; extract?: RegExp } = {} +) { + actual = actual.trim(); + if (prefix && actual.startsWith(prefix)) actual = actual.slice(prefix.length); + if (suffix && actual.endsWith(suffix)) + actual = actual.slice(0, -suffix.length); + if (extract) { + const match = actual.match(extract); + if (!match) throw new Error(`Expected match for ${extract} in ${actual}`); + if (!match[1]) + throw new Error("Expected extract regex to have a capture group"); + actual = match[1]; + } + actual = actual.trim(); + assert.ok( + semverSatisfies(actual, expected), + `Expected semver match for ${expected}, got ${actual}` + ); +} + +let _docker: Dockerode; +function ensureDocker() { + return (_docker ??= new Dockerode()); +} + +before(ensureDocker); + +export async function runCommandInContainer( + command: string[] +): Promise<{ stdout: string; stderr: string }> { + const docker = ensureDocker(); + const container = await docker.createContainer({ + Image: IMAGE_TAG, + Cmd: command, + }); + const stdout = new StringStream(); + const stderr = new StringStream(); + const attach = await container.attach({ + stream: true, + stdout: true, + stderr: true, + }); + docker.modem.demuxStream(attach, stdout, stderr); + await container.start(); + const wait = (await container.wait()) as { StatusCode: number }; + if (wait.StatusCode !== 0) + throw new Error(`Command failed with status code ${wait.StatusCode}`); + return { stdout: stdout.string, stderr: stderr.string }; +} + +export class StringStream extends Stream.PassThrough { + buffers: Buffer[]; + + constructor() { + super(); + this.buffers = []; + this.on("data", (chunk) => this.buffers.push(chunk)); + } + + get string(): string { + return Buffer.concat(this.buffers).toString(); + } +} diff --git a/tests/networking.test.ts b/tests/networking.test.ts new file mode 100644 index 0000000..18edd29 --- /dev/null +++ b/tests/networking.test.ts @@ -0,0 +1,13 @@ +import { binaryOnPathTest } from "./index.ts"; + +const networkingTools = [ + { binary: "curl" }, + { binary: "dig" }, + { binary: "nc" }, + { binary: "openssl" }, + { binary: "ping" }, + { binary: "tracepath" }, + { binary: "wget" }, +]; + +networkingTools.map(binaryOnPathTest); diff --git a/tests/text-manip.test.ts b/tests/text-manip.test.ts new file mode 100644 index 0000000..7ac4ebc --- /dev/null +++ b/tests/text-manip.test.ts @@ -0,0 +1,15 @@ +import { binaryOnPathTest } from "./index.ts"; + +const textManipTools: { binary: string }[] = [ + { binary: "ag" }, + { binary: "awk" }, + { binary: "grep" }, + { binary: "nano" }, + { binary: "rg" }, + { binary: "sed" }, + { binary: "sort" }, + { binary: "uniq" }, + { binary: "vim" }, +]; + +textManipTools.map(binaryOnPathTest); diff --git a/yarn.lock b/yarn.lock index a68b3ab..15fab4c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -213,8 +213,10 @@ __metadata: version: 0.0.0-use.local resolution: "@observablehq/framework-runtime@workspace:." dependencies: + "@types/dockerode": "npm:^3.3.28" "@types/node": "npm:^20" dockerode: "npm:^4.0.2" + glob: "npm:^10.3.12" semver: "npm:^7.6.0" tsx: "npm:^4.7.1" typescript: "npm:^5.4.3" @@ -228,6 +230,45 @@ __metadata: languageName: node linkType: hard +"@types/docker-modem@npm:*": + version: 3.0.6 + resolution: "@types/docker-modem@npm:3.0.6" + dependencies: + "@types/node": "npm:*" + "@types/ssh2": "npm:*" + checksum: 10c0/d3ffd273148bc883ff9b1a972b1f84c1add6d9a197d2f4fc9774db4c814f39c2e51cc649385b55d781c790c16fb0bf9c1f4c62499bd0f372a4b920190919445d + languageName: node + linkType: hard + +"@types/dockerode@npm:^3.3.28": + version: 3.3.28 + resolution: "@types/dockerode@npm:3.3.28" + dependencies: + "@types/docker-modem": "npm:*" + "@types/node": "npm:*" + "@types/ssh2": "npm:*" + checksum: 10c0/ce43da3cd269e11e999366aa9019a067d97dc012d7c460b560dfe75d4ccacc618077f2dfc5f34da2c915d4d86ed883d00d3bb741fd8440ef4ca1136afbc29b9f + languageName: node + linkType: hard + +"@types/node@npm:*": + version: 20.12.7 + resolution: "@types/node@npm:20.12.7" + dependencies: + undici-types: "npm:~5.26.4" + checksum: 10c0/dce80d63a3b91892b321af823d624995c61e39c6a223cc0ac481a44d337640cc46931d33efb3beeed75f5c85c3bda1d97cef4c5cd4ec333caf5dee59cff6eca0 + languageName: node + linkType: hard + +"@types/node@npm:^18.11.18": + version: 18.19.31 + resolution: "@types/node@npm:18.19.31" + dependencies: + undici-types: "npm:~5.26.4" + checksum: 10c0/bfebae8389220c0188492c82eaf328f4ba15e6e9b4abee33d6bf36d3b13f188c2f53eb695d427feb882fff09834f467405e2ed9be6aeb6ad4705509822d2ea08 + languageName: node + linkType: hard + "@types/node@npm:^20": version: 20.11.30 resolution: "@types/node@npm:20.11.30" @@ -237,6 +278,15 @@ __metadata: languageName: node linkType: hard +"@types/ssh2@npm:*": + version: 1.15.0 + resolution: "@types/ssh2@npm:1.15.0" + dependencies: + "@types/node": "npm:^18.11.18" + checksum: 10c0/055c271845847867c365b0c002e59536608e400864aea4f54ebc72e8588b92dbc4b6572e3095092dba0d86d49898e2180a389810269d119f897972ebbddb4a7f + languageName: node + linkType: hard + "abbrev@npm:^2.0.0": version: 2.0.0 resolution: "abbrev@npm:2.0.0" @@ -679,7 +729,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.3.10": +"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.12": version: 10.3.12 resolution: "glob@npm:10.3.12" dependencies: