From 96f7c2cfe786fa029772353d07ccb54e97c81974 Mon Sep 17 00:00:00 2001 From: kodemon Date: Sat, 6 Jul 2024 21:21:10 +0200 Subject: [PATCH] feat: initial commit --- .github/workflows/publish.yml | 34 ++++ .github/workflows/test.yml | 20 ++ .gitignore | 1 + .vscode/settings.json | 5 + README.md | 7 + containers/postgres.ts | 189 ++++++++++++++++++ deno.json | 30 +++ deno.lock | 68 +++++++ docker/libraries/container.ts | 124 ++++++++++++ docker/libraries/docker.ts | 32 +++ docker/libraries/exec.ts | 107 ++++++++++ docker/libraries/image.ts | 155 +++++++++++++++ docker/libraries/modem.ts | 114 +++++++++++ docker/mod.ts | 3 + docker/types/container.ts | 361 ++++++++++++++++++++++++++++++++++ http/libraries/client.ts | 40 ++++ http/libraries/common.ts | 3 + http/libraries/request.ts | 43 ++++ http/libraries/response.ts | 151 ++++++++++++++ http/mod.ts | 2 + tests/postgres.test.ts | 22 +++ 21 files changed, 1511 insertions(+) create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 containers/postgres.ts create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 docker/libraries/container.ts create mode 100644 docker/libraries/docker.ts create mode 100644 docker/libraries/exec.ts create mode 100644 docker/libraries/image.ts create mode 100644 docker/libraries/modem.ts create mode 100644 docker/mod.ts create mode 100644 docker/types/container.ts create mode 100644 http/libraries/client.ts create mode 100644 http/libraries/common.ts create mode 100644 http/libraries/request.ts create mode 100644 http/libraries/response.ts create mode 100644 http/mod.ts create mode 100644 tests/postgres.test.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..3a0472a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,34 @@ +name: Publish + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - "deno.json" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Deno + uses: maximousblk/setup-deno@v2 # Installs latest version + + - run: deno task test + + publish: + runs-on: ubuntu-latest + needs: test + + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v4 + + - name: Publish package + run: npx jsr publish \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..631d373 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Deno + uses: maximousblk/setup-deno@v2 # Installs latest version + + - run: deno task test \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5fcddbf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "deno.enable": true, + "editor.formatOnSave": true, + "editor.defaultFormatter": "denoland.vscode-deno" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd9c5ab --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +

+ +

+ +# Test Containers + +Test container solution for running third party solutions through docker. diff --git a/containers/postgres.ts b/containers/postgres.ts new file mode 100644 index 0000000..02e783b --- /dev/null +++ b/containers/postgres.ts @@ -0,0 +1,189 @@ +import delay from "delay"; +import getPort from "port"; +import psql, { type Sql } from "postgres"; + +import { Container } from "../docker/libraries/container.ts"; +import { docker } from "../docker/mod.ts"; + +export class PostgresTestContainer { + private constructor( + readonly container: Container, + readonly port: number, + readonly config: Config, + ) {} + + /* + |-------------------------------------------------------------------------------- + | Accessors + |-------------------------------------------------------------------------------- + */ + + /** + * Connection info for the Postgres container. + */ + get connectionInfo(): PostgresConnectionInfo { + return { + host: "127.0.0.1", + port: this.port, + user: this.config.username, + pass: this.config.password, + }; + } + + /** + * Execute a command in the Postgres container. + */ + get exec() { + return this.container.exec.bind(this.container); + } + + /* + |-------------------------------------------------------------------------------- + | Lifecycle + |-------------------------------------------------------------------------------- + */ + + /** + * Start a new Postgres container. + * + * @param config - Options for the Postgres container. + */ + static async start(image: string, config: Partial = {}): Promise { + const port = await getPort(); + if (port === undefined) { + throw new Error("Unable to assign to a random port"); + } + + await docker.pullImage(image); + + const container = await docker.createContainer({ + Image: image, + Env: [`POSTGRES_USER=${config.username ?? "postgres"}`, `POSTGRES_PASSWORD=${config.password ?? "postgres"}`], + ExposedPorts: { + "5432/tcp": {}, + }, + HostConfig: { + PortBindings: { "5432/tcp": [{ HostIp: "0.0.0.0", HostPort: String(port) }] }, + }, + }); + + await container.start(); + await container.logs((line) => { + if (line.includes("init process complete")) { + return true; + } + }); + + await delay(1000); + + return new PostgresTestContainer(container, port, { + username: config.username ?? "postgres", + password: config.password ?? "postgres", + }); + } + + /** + * Stop and remove the Postgres container. + */ + async stop(): Promise { + await this.container.remove({ force: true }); + } + + /* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + + /** + * Create a new database with the given name. + * + * @param name - Name of the database to create. + */ + async create(name: string): Promise { + await this.exec(["createdb", `--username=${this.config.username}`, name]); + } + + /** + * Get postgres client instance for the current container. + * + * @param name - Database name to connect to. + * @param options - Connection options to append to the URL. + */ + client(name: string, options?: PostgresConnectionOptions): Sql { + return psql(this.url(name, options)); + } + + /** + * Return the connection URL for the Postgres container in the format: + * `postgres://${user}:${pass}@${host}:${port}/${database}`. + * + * Make sure to start the container before accessing this method or it will + * throw an error. + * + * @param name - Name of the database to connect to. + * @param options - Connection options to append to the URL. + */ + url(name: string, options?: PostgresConnectionOptions): string { + return getConnectionUrl({ ...this.connectionInfo, name, options }); + } +} + +/* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + +function getConnectionUrl( + { host, port, user, pass, name, options }: ConnectionUrlConfig, +): PostgresConnectionUrl { + return `postgres://${user}:${pass}@${host}:${port}/${name}${postgresOptionsToString(options)}`; +} + +function postgresOptionsToString(options?: PostgresConnectionOptions) { + if (options === undefined) { + return ""; + } + const values: string[] = []; + for (const key in options) { + assertPostgresOptionKey(key); + values.push(`${key}=${options[key]}`); + } + return `?${values.join("&")}`; +} + +function assertPostgresOptionKey(key: string): asserts key is keyof PostgresConnectionOptions { + if (["schema"].includes(key) === false) { + throw new Error(`Invalid postgres option key: ${key}`); + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type Config = { + username: string; + password: string; +}; + +type PostgresConnectionUrl = `postgres://${string}:${string}@${string}:${number}/${string}`; + +type ConnectionUrlConfig = { + name: string; + options?: PostgresConnectionOptions; +} & PostgresConnectionInfo; + +type PostgresConnectionOptions = { + schema?: string; +}; + +type PostgresConnectionInfo = { + user: string; + pass: string; + host: string; + port: number; +}; diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..26bc1f7 --- /dev/null +++ b/deno.json @@ -0,0 +1,30 @@ +{ + "name": "@valkyr/testcontainers", + "version": "1.0.0-rc.1", + "exports": { + "./postgres": "./containers/postgres.ts" + }, + "imports": { + "std/": "https://deno.land/std@0.224.0/", + "delay": "npm:delay@6.0.0", + "port": "npm:get-port@7.1.0", + "postgres": "npm:postgres@3.4.4" + }, + "exclude": [ + ".vscode" + ], + "lint": { + "rules": { + "exclude": [ + "no-explicit-any", + "require-await" + ] + } + }, + "fmt": { + "lineWidth": 120 + }, + "tasks": { + "test": "export ENVIRONMENT=testing && deno test --allow-all --unstable-ffi" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..25fea21 --- /dev/null +++ b/deno.lock @@ -0,0 +1,68 @@ +{ + "version": "3", + "packages": { + "specifiers": { + "npm:delay@6.0.0": "npm:delay@6.0.0", + "npm:get-port@7.1.0": "npm:get-port@7.1.0", + "npm:postgres@3.4.4": "npm:postgres@3.4.4" + }, + "npm": { + "delay@6.0.0": { + "integrity": "sha512-2NJozoOHQ4NuZuVIr5CWd0iiLVIRSDepakaovIN+9eIDHEhdCAEvSy2cuf1DCrPPQLvHmbqTHODlhHg8UCy4zw==", + "dependencies": {} + }, + "get-port@7.1.0": { + "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", + "dependencies": {} + }, + "postgres@3.4.4": { + "integrity": "sha512-IbyN+9KslkqcXa8AO9fxpk97PA4pzewvpi2B3Dwy9u4zpV32QicaEdgmF3eSQUzdRk7ttDHQejNgAEr4XoeH4A==", + "dependencies": {} + } + } + }, + "remote": { + "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", + "https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7", + "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", + "https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", + "https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff", + "https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46", + "https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b", + "https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c", + "https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", + "https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68", + "https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3", + "https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7", + "https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29", + "https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a", + "https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a", + "https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8", + "https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693", + "https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", + "https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5", + "https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8", + "https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb", + "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", + "https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68", + "https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3", + "https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", + "https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", + "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", + "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", + "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", + "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e", + "https://deno.land/std@0.224.0/testing/_test_suite.ts": "f10a8a6338b60c403f07a76f3f46bdc9f1e1a820c0a1decddeb2949f7a8a0546", + "https://deno.land/std@0.224.0/testing/bdd.ts": "3e4de4ff6d8f348b5574661cef9501b442046a59079e201b849d0e74120d476b" + }, + "workspace": { + "dependencies": [ + "npm:delay@6.0.0", + "npm:get-port@7.1.0", + "npm:postgres@3.4.4" + ] + } +} diff --git a/docker/libraries/container.ts b/docker/libraries/container.ts new file mode 100644 index 0000000..adf614a --- /dev/null +++ b/docker/libraries/container.ts @@ -0,0 +1,124 @@ +import { CreateExecOptions, Exec } from "./exec.ts"; +import { modem } from "./modem.ts"; + +export class Container { + /** + * Instantiate a docker container. + * + * @param id - The container ID. + * @param warnings - Warnings encountered during the container creation. + */ + constructor( + readonly id: string, + readonly warnings: string[], + ) {} + + /** + * Start the container. + * + * @see https://docs.docker.com/engine/api/v1.45/#tag/Container/operation/ContainerStart + * + * @param query.signal - Signal to send to the container as an integer or string (e.g. SIGINT). + * @param query.t - An integer representing the number of seconds to wait before killing the container. + */ + async start(query: SignalQuery = {}) { + await modem.post({ path: `/containers/${this.id}/start`, query }); + } + + /** + * Stop the container. + * + * @see https://docs.docker.com/engine/api/v1.45/#tag/Container/operation/ContainerStop + * + * @param query.signal - Signal to send to the container as an integer or string (e.g. SIGINT). + * @param query.t - An integer representing the number of seconds to wait before killing the container. + */ + async stop(query: SignalQuery = {}) { + await modem.post({ path: `/containers/${this.id}/stop`, query }); + } + + /** + * Remove the container. + * + * @see https://docs.docker.com/engine/api/v1.45/#tag/Container/operation/ContainerDelete + * + * @param query.v - Remove the volumes associated with the container. + * @param query.force - Kill the container if it is running. + * @param query.link - Remove the specified link and not the underlying container. + */ + async remove(query: { v?: boolean; force?: boolean; link?: boolean } = {}) { + await modem.del({ path: `/containers/${this.id}`, query }); + } + + /** + * Return low-level information about a container. + * + * @see https://docs.docker.com/engine/api/v1.45/#tag/Container/operation/ContainerInspect + */ + async inspect() { + return modem.get({ path: `/containers/${this.id}/json` }); + } + + /** + * Get the logs of the container. + * + * @see https://docs.docker.com/engine/api/v1.45/#tag/Container/operation/ContainerLogs + * + * @param handler - Function to handle each line of the logs. + */ + async logs(handler: (line: string) => true | void) { + const res = await modem.request({ + method: "GET", + path: `/containers/${this.id}/logs`, + query: { + stdout: true, + follow: true, + tail: "all", + }, + }); + for await (const chunk of res.stream) { + if (handler(chunk) === true) { + break; + } + } + } + + /** + * Run a command inside the running container. + * + * @see https://docs.docker.com/engine/api/v1.45/#tag/Exec/operation/ContainerExec + * + * @param cmd - Command to run. + * @param opts - Options for the command. + */ + async exec(cmd: string | string[], opts: Partial = {}) { + const { Id } = await modem.post<{ Id: string }>({ + path: `/containers/${this.id}/exec`, + body: { + ...opts, + Cmd: Array.isArray(cmd) ? cmd : [cmd], + AttachStdout: true, + AttachStderr: true, + }, + }); + return new Exec(Id).start(); + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type SignalQuery = { + /** + * Signal to send to the container as an integer or string (e.g. SIGINT). + */ + signal?: string; + + /** + * An integer representing the number of seconds to wait before killing the container. + */ + t?: number; +}; diff --git a/docker/libraries/docker.ts b/docker/libraries/docker.ts new file mode 100644 index 0000000..25c3d29 --- /dev/null +++ b/docker/libraries/docker.ts @@ -0,0 +1,32 @@ +import { ContainerConfig } from "../types/container.ts"; +import { modem } from "./modem.ts"; +import { Container } from "./container.ts"; +import { Image } from "./image.ts"; + +export class Docker { + /** + * Create a new docker container. + * + * @see https://docs.docker.com/engine/api/v1.45/#tag/Container/operation/ContainerCreate + * + * @params config - The configuration for the container. + * @params query - Query parameters. + */ + async createContainer(config: Partial, query: Partial<{ name: string; platform: string }> = {}) { + const { Id, Warnings } = await modem.post<{ Id: string; Warnings: string[] }>({ + path: "/containers/create", + query, + body: config, + }); + return new Container(Id, Warnings); + } + + /** + * Pull an image from the docker registry. + * + * @param image - The image to pull. + */ + async pullImage(image: string) { + await new Image().create({ fromImage: image }); + } +} diff --git a/docker/libraries/exec.ts b/docker/libraries/exec.ts new file mode 100644 index 0000000..4034a13 --- /dev/null +++ b/docker/libraries/exec.ts @@ -0,0 +1,107 @@ +import delay from "delay"; + +import { modem } from "./modem.ts"; + +export class Exec { + constructor(readonly id: string) {} + + /** + * Starts the current exec instance. If detach is true, this endpoint + * returns immediately after starting the command. Otherwise, it sets up an + * interactive session with the command. + * + * @param body - Request body schema. + */ + async start(body: Partial = {}) { + await modem.post({ path: `/exec/${this.id}/start`, body }); + await this.#endSignal(); + } + + /** + * Return low-level information about the exec instance. + */ + async inspect() { + return modem.get({ path: `/exec/${this.id}/json` }); + } + + /* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + + /** + * Wait for the current exec instance to finish its execution by observing + * its running state. + * + * [TODO] Introduce a timeout signal in case we want to add a treshold to the + * running time. + */ + async #endSignal() { + while (true) { + const info = await this.inspect(); + if (info.Running === false) { + break; + } + await delay(250); + } + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type CreateExecOptions = { + AttachStdin: boolean; + AttachStdout: boolean; + AttachStderr: boolean; + ConsoleSize: [number, number]; + Tty: boolean; + Env: string[]; + Cmd: string[]; + Privileged: boolean; + User: string; + WorkingDir: string; +}; + +type StartSchema = { + /** + * Detach from the command. + */ + Detach: boolean; + + /** + * Allocate a pseudo-TTY. + */ + Tty: boolean; + + /** + * Initial console size, as an `[height, width]` array. + */ + ConsoleSize?: [number, number]; +}; + +type InspectResponse = { + CanRemove: boolean; + ContainerID: string; + DetachKeys: string; + ExitCode: number; + ID: string; + OpenStderr: boolean; + OpenStdin: boolean; + OpenStdout: boolean; + ProcessConfig: ProcessConfig; + Running: boolean; + Pid: number; +}; + +type ProcessConfig = { + arguments: string[]; + entrypoint: string; + privileged: boolean; + tty: boolean; + user: string; +}; diff --git a/docker/libraries/image.ts b/docker/libraries/image.ts new file mode 100644 index 0000000..e168294 --- /dev/null +++ b/docker/libraries/image.ts @@ -0,0 +1,155 @@ +import { modem } from "./modem.ts"; + +export class Image { + /** + * Pull or import an image. + * + * @see https://docs.docker.com/engine/api/v1.45/#tag/Image/operation/ImageCreate + * + * @param query - The configuration for the image. + */ + async create(query: Partial) { + if (query.fromImage !== undefined) { + const hasImage = await this.inspect(query.fromImage); + if (hasImage !== undefined) { + return; // we already have this image + } + } + const res = await modem.request({ + method: "POST", + path: "/images/create", + headers: { + "Content-Type": "text/plain", + }, + query, + }); + res.close(); + if (res.status !== 200) { + throw new Error("Docker Image > Failed to create new image"); + } + } + + async inspect(image: string): Promise { + try { + return await modem.get({ path: `/images/${image}/json` }); + } catch (_) { + return undefined; + } + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type CreateImageOptions = { + /** + * Name of the image to pull. The name may include a tag or digest. This + * parameter may only be used when pulling an image. The pull is cancelled if + * the HTTP connection is closed. + */ + fromImage: string; +}; + +type InspectImageResponse = { + Id: string; + RepoTags: string[]; + RepoDigests: string[]; + Parent: string; + Comment: string; + Created: string; + Container: string; + ContainerConfig: { + Hostname: string; + Domainname: string; + User: string; + AttachStdin: boolean; + AttachStdout: boolean; + AttachStderr: boolean; + ExposedPorts: Record; + Tty: boolean; + OpenStdin: boolean; + StdinOnce: boolean; + Env: string[]; + Cmd: string[]; + Healthcheck: { + Test: string[]; + Interval: number; + Timeout: number; + Retries: number; + StartPeriod: number; + StartInterval: number; + }; + ArgsEscaped: boolean; + Image: string; + Volumes: Record; + WorkingDir: string; + Entrypoint: string[]; + NetworkDisabled: boolean; + MacAddress: string; + OnBuild: string[]; + Labels: Record; + StopSignal: string; + StopTimeout: number; + Shell: string[]; + }; + DockerVersion: string; + Author: string; + Config: { + Hostname: string; + Domainname: string; + User: string; + AttachStdin: boolean; + AttachStdout: boolean; + AttachStderr: boolean; + ExposedPorts: Record; + Tty: boolean; + OpenStdin: boolean; + StdinOnce: boolean; + Env: string[]; + Cmd: string[]; + Healthcheck: { + Test: string[]; + Interval: number; + Timeout: number; + Retries: number; + StartPeriod: number; + StartInterval: number; + }; + ArgsEscaped: boolean; + Image: string; + Volumes: Record; + WorkingDir: string; + Entrypoint: string[]; + NetworkDisabled: boolean; + MacAddress: string; + OnBuild: string[]; + Labels: Record; + StopSignal: string; + StopTimeout: number; + Shell: string[]; + }; + Architecture: string; + Variant: string; + Os: string; + OsVersion: string; + Size: number; + VirtualSize: number; + GraphDriver: { + Name: string; + Data: { + MergedDir: string; + UpperDir: string; + WorkDir: string; + }; + }; + RootFS: { + Type: string; + Layers: string[]; + }; + Metadata: { + LastTagTime: string; + }; +}; diff --git a/docker/libraries/modem.ts b/docker/libraries/modem.ts new file mode 100644 index 0000000..bbc22eb --- /dev/null +++ b/docker/libraries/modem.ts @@ -0,0 +1,114 @@ +import { Client, type Response } from "../../http/mod.ts"; + +class Modem { + constructor(readonly options: Deno.ConnectOptions | Deno.UnixConnectOptions, readonly client = new Client(options)) {} + + /** + * Send a `POST` request to the Docker API. + * + * @param param.path - Path of the API endpoint. + * @param param.query - Query parameters. + * @param param.body - Request body. + */ + async post>({ path, query = {}, body }: RequestOptions): Promise { + return getParsedResponse(await this.request({ method: "POST", path, query, body })); + } + + /** + * Send a `GET` request to the Docker API. + * + * @param param.path - Path of the API endpoint. + * @param param.query - Query parameters. + */ + async get>({ path, query }: Omit): Promise { + return getParsedResponse(await this.request({ method: "GET", path, query })); + } + + /** + * Send a `DELETE` request to the Docker API. + * + * @param param.path - Path of the API endpoint. + * @param param.query - Query parameters. + */ + async del>({ path, query }: Omit): Promise { + return getParsedResponse(await this.request({ method: "DELETE", path, query })); + } + + /** + * Send a fetch request to the Docker API. + * + * Note! When calling this method directly, ensure to call the .close() method on the response + * or active connections may remain open causing dirty shutdown of services. Only when accessing + * the .stream of the response through an async itterator is the connection automatically closed + * when the itterator has completed. + * + * @param param.method - HTTP method to use. + * @param param.path - Path of the API endpoint. + * @param param.query - Query parameters. + * @param param.body - Request body. _(Ignored for `GET` requests.)_ + * @param param.headers - Headers to send with the request. + */ + async request( + { method, path, query = {}, body, headers = {} }: { method: "POST" | "GET" | "DELETE" } & RequestOptions, + ): Promise { + return this.client.fetch(`http://docker${path}${toSearchParams(query)}`, { + method, + body, + headers, + }); + } +} + +export const modem = new Modem({ + path: "/var/run/docker.sock", + transport: "unix", +}); + +/* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + +function getParsedResponse(res: Response): T { + res.close(); + if (res.status >= 400) { + const error = res.json; + assertError(error); + throw new Error(error.message); + } + if (res.status === 204 || res.status === 304) { + return {} as T; + } + return res.json as T; +} + +function toSearchParams(query: Record): string { + if (Object.keys(query).length === 0) { + return ""; + } + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + searchParams.append(key, String(value)); + } + return `?${searchParams.toString()}`; +} + +function assertError(error: unknown): asserts error is { message: string } { + if (typeof error !== "object" || error === null || !("message" in error)) { + throw new Error("Docker Modem > Could not parse error response."); + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type RequestOptions = { + path: string; + headers?: RequestInit["headers"]; + query?: Record; + body?: Record; +}; diff --git a/docker/mod.ts b/docker/mod.ts new file mode 100644 index 0000000..3aacbf3 --- /dev/null +++ b/docker/mod.ts @@ -0,0 +1,3 @@ +import { Docker } from "./libraries/docker.ts"; + +export const docker = new Docker(); diff --git a/docker/types/container.ts b/docker/types/container.ts new file mode 100644 index 0000000..16df06f --- /dev/null +++ b/docker/types/container.ts @@ -0,0 +1,361 @@ +export type ContainerConfig = { + /** + * The hostname to use for the container, as a valid RFC 1123 hostname. + */ + Hostname: string; + + /** + * The domain name to use for the container. + */ + Domainname: string; + + /** + * The user that commands are run as inside the container. + */ + User: string; + + /** + * Whether to attach to `stdin`. + */ + AttachStdin: boolean; + + /** + * Whether to attach to `stdout`. + */ + AttachStdout: boolean; + + /** + * Whether to attach to `stderr`. + */ + AttachStderr: boolean; + + /** + * An object mapping ports to an empty object in the form: + * `{"/": {}}`. + * ```ts + * { + * ExposedPorts: { + * "22/tcp": {}, + * } + * } + * ``` + */ + ExposedPorts: Record; + + /** + * Attach standard streams to a TTY, including `stdin` if it is not closed. + */ + Tty: boolean; + + /** + * Open `stdin`. + */ + OpenStdin: boolean; + + /** + * Close `stdin` after one attached client disconnects. + */ + StdinOnce: boolean; + + /** + * A list of environment variables to set inside the container in the form: + * `["VAR=value", ...]`. A variable without `=` is removed from the environment, + * rather than to have an empty value. + */ + Env: string[]; + + /** + * Command to run specified as a string or an array of strings. + */ + Cmd: string[]; + + /** + * A test to perform to check that the container is healthy. + */ + Healthcheck: HealthConfig; + + /** + * The name (or reference) of the image to use when creating the container, or + * which was used when the container was created. + */ + Image: string; + + /** + * An object mapping mount point paths inside the container to empty objects. + * ```ts + * { + * Volumes: { + * "/volumes/data": {} + * } + * } + * ``` + */ + Volumes: Record; + + /** + * The working directory for commands to run in. + */ + WorkingDir: string; + + /** + * The entry point for the container as a string or an array of strings. + * + * If the array consists of exactly one empty string (`[""]`) then the entry + * point is reset to system default (i.e., the entry point used by docker + * when there is no `ENTRYPOINT` instruction in the `Dockerfile`). + */ + Entrypoint: string[]; + + /** + * Disable networking for the container. + */ + NetworkDisabled: boolean | null; + + /** + * `ONBUILD` metadata that were defined in the image's `Dockerfile`. + */ + OnBuild: string[] | null; + + /** + * User-defined key/value metadata. + * ```ts + * { + * Labels: { + * "com.example.vendor": "Acme", + * "com.example.license": "GPL", + * "com.example.version": "1.0" + * } + * } + * ``` + */ + Labels: Record; + + /** + * Signal to stop a container as a string or unsigned integer. + */ + StopSignal: string; + + /** + * Timeout to stop a container in seconds. + * Default: `10` + */ + StopTimeout: number; + + /** + * Shell for when `RUN`, `CMD`, and `ENTRYPOINT` uses a shell. + */ + Shell: string[]; + + /** + * Container configuration that depends on the host we are running on + */ + HostConfig: Partial; + + /** + * NetworkingConfig represents the container's networking configuration for + * each of its interfaces. It is used for the networking configs specified + * in the docker create and docker network connect commands. + */ + NetworkingConfig: Partial; +}; + +type HealthConfig = { + /** + * The test to perform. Possible values are: + * - `[]` inherit healthcheck from image or parent image + * - `["NONE"]` disable healthcheck + * - `["CMD", args...]` exec arguments directly + * - `["CMD-SHELL", command]` run command with system's default shell + */ + Test: string[]; + + /** + * The time to wait between checks in nanoseconds. It should be 0 or at least + * 1_000_000 (1 ms). 0 means inherit + */ + Interval: number; + + /** + * The time to wait before considering the check to have hung. It should be 0 + * or at least 1_000_000 (1 ms). 0 means inherit. + */ + Timeout: number; + + /** + * The number of consecutive failures needed to consider a container as + * unhealthy. 0 means inherit. + */ + Retries: number; + + /** + * Start period for the container to initialize before starting health-retries + * countdown in nanoseconds. It should be 0 or at least 1_000_000 (1 ms). 0 + * means inherit. + */ + StartPeriod: number; + + /** + * The time to wait between checks in nanoseconds during the start period. + * It should be 0 or at least 1_000_000 (1 ms). 0 means inherit. + */ + StartInterval: number; +}; + +type HostConfig = { + CpuShares: number; + Memory: number; + CgroupParent: string; + BlkioWeight: number; + BlkioWeightDevice: { + Path: string; + Integer: number; + }[]; + BlkioDeviceReadBps: { + Path: string; + Rate: number; + }[]; + BlkioDeviceWriteBps: { + Path: string; + Rate: number; + }[]; + BlkioDeviceReadIOps: { + Path: string; + Rate: number; + }[]; + BlkioDeviceWriteIOps: { + Path: string; + Rate: number; + }[]; + CpuPeriod: number; + CpuQuota: number; + CpuRealtimePeriod: number; + CpuRealtimeRuntime: number; + CpusetCpus: string; + CpusetMems: string; + Devices: { + PathOnHost: string; + PathInContainer: string; + CgroupPermissions: string; + }[]; + DeviceCgroupRules: string[]; + DeviceRequests: { + Driver: string; + Count: number; + DeviceIDs: string[]; + Capabilities: string[]; + Options: { + Name: string; + Value: string; + }[]; + }[]; + KernelMemoryTCP: number; + MemoryReservation: number; + MemorySwap: number; + MemorySwappiness: number; + NanoCPUs: number; + OomKillDisable: boolean; + Init: boolean | null; + PidsLimit: number; + Ulimits: { + Name: string; + Soft: number; + Hard: number; + }[]; + CpuCount: number; + CpuPercent: number; + IOMaximumIOps: number; + IOMaximumBandwidth: number; + Binds: string[]; + ContainerIDFile: string; + LogConfig: { + Type: "json-file" | "syslog" | "journald" | "gelf" | "fluentd" | "awslogs" | "splunk" | "etwlogs" | "none"; + Config: Record; + }; + NetworkMode: string; + PortBindings: Record; + RestartPolicy: { + Name: "" | "no" | "always" | "unless-stopped" | "on-failure"; + MaximumRetryCount: number; + }; + AutoRemove: boolean; + VolumeDriver: string; + VolumesFrom: string[]; + Mounts: { + Target: string; + Source: string; + Type: "bind" | "volume" | "tmpfs" | "npipe" | "cluster"; + ReadOnly: boolean; + Consistency: "default" | "consistent" | "cached" | "delegated"; + BindOptions: { + Propagation: "private" | "rprivate" | "shared" | "rshared" | "slave" | "rslave"; + NonRecursive: boolean; + CreateMountPoint: boolean; + ReadOnlyNonRecursive: boolean; + ReadOnlyForcedRecursive: boolean; + }; + VolumeOptions: { + NoCopy: boolean; + Labels: Record; + DriverConfig: { + Name: string; + Options: Record; + }; + Subpath: string; + }; + TmpfsOptions: { + SizeBytes: number; + Mode: number; + }; + }[]; + ConsoleSize: number[]; + Annotations: Record; + CapAdd: string[]; + CapDrop: string[]; + CgroupnsMode: "host" | "private"; + Dns: string[]; + DnsOptions: string[]; + DnsSearch: string[]; + ExtraHosts: string[]; + GroupAdd: string[]; + IpcMode: "none" | "private" | "shareable" | "container:" | "host"; + Cgroup: string; + Links: string[]; + OomScoreAdj: number; + PidMode: "host" | "container:"; + Privileged: boolean; + PublishAllPorts: boolean; + ReadonlyRootfs: boolean; + SecurityOpt: string[]; + StorageOpt: Record; + Tmpfs: Record; + UTSMode: string; + UsernsMode: string; + ShmSize: number; + Sysctls: Record; + Runtime: string; + MaskedPaths: string[]; + ReadonlyPaths: string[]; +}; + +type NetworkingConfig = { + EndpointsConfig: Record; +}; + +type EndpointSettings = { + IPAMConfig: { + IPv4Address: string; + IPv6Address: string; + LinkLocalIPs: string[]; + }; + Links: string[]; + Aliases: string[]; + NetworkID: string; + EndpointID: string; + Gateway: string; + IPAddress: string; + IPPrefixLen: number; + IPv6Gateway: string; + GlobalIPv6Address: string; + GlobalIPv6PrefixLen: number; + MacAddress: string; +}; diff --git a/http/libraries/client.ts b/http/libraries/client.ts new file mode 100644 index 0000000..2a282c6 --- /dev/null +++ b/http/libraries/client.ts @@ -0,0 +1,40 @@ +import { Request, type RequestMethod } from "./request.ts"; +import { type Response } from "./response.ts"; + +export class Client { + constructor(readonly options: Deno.ConnectOptions | Deno.UnixConnectOptions) {} + + /** + * Connection instance to use for a new fetch operation. + * + * Note! A new connection is spawned for every fetch request and is only automatically + * closed when accessing the .stream on the response. Otherwise a manual .close must + * be executed on the response to ensure that the connection is cleaned up. + */ + get connection() { + if ("path" in this.options) { + return Deno.connect(this.options); + } + return Deno.connect(this.options); + } + + async fetch(path: string, { method, headers = {}, body }: RequestOptions): Promise { + const url = new URL(path); + return new Request(await this.connection, { + method, + path: url.pathname + url.search, + headers: { + Host: url.host, + "Content-Type": "application/json", + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }).send(); + } +} + +type RequestOptions = { + method: RequestMethod; + headers?: RequestInit["headers"]; + body?: Record; +}; diff --git a/http/libraries/common.ts b/http/libraries/common.ts new file mode 100644 index 0000000..39fbd77 --- /dev/null +++ b/http/libraries/common.ts @@ -0,0 +1,3 @@ +export const NEW_LINE = "\r\n"; + +export const PROTOCOL = "HTTP/1.1"; diff --git a/http/libraries/request.ts b/http/libraries/request.ts new file mode 100644 index 0000000..27cf73f --- /dev/null +++ b/http/libraries/request.ts @@ -0,0 +1,43 @@ +import { NEW_LINE, PROTOCOL } from "./common.ts"; +import { Response } from "./response.ts"; + +export class Request { + constructor(readonly connection: Deno.Conn, readonly options: RequestOptions) {} + + async send(): Promise { + const http = await this.encode(this.toHttp()); + await this.connection.write(http); + return new Response(this.connection).resolve(); + } + + toHttp() { + const { method, path, headers = {}, body } = this.options; + const parts: string[] = [ + `${method} ${path} ${PROTOCOL}`, + ]; + for (const key in headers) { + parts.push(`${key}: ${(headers as any)[key]}`); + } + if (body !== undefined) { + parts.push(`Content-Length: ${body.length}`); + } + return `${parts.join(NEW_LINE)}${NEW_LINE}${NEW_LINE}${body ?? ""}`; + } + + async encode(value: string): Promise { + return new TextEncoder().encode(value); + } + + async decode(buffer: Uint8Array): Promise { + return new TextDecoder().decode(buffer); + } +} + +export type RequestOptions = { + method: RequestMethod; + path: string; + headers?: RequestInit["headers"]; + body?: string; +}; + +export type RequestMethod = "HEAD" | "OPTIONS" | "POST" | "GET" | "PUT" | "DELETE"; diff --git a/http/libraries/response.ts b/http/libraries/response.ts new file mode 100644 index 0000000..2a3157b --- /dev/null +++ b/http/libraries/response.ts @@ -0,0 +1,151 @@ +import { PROTOCOL } from "./common.ts"; + +export class Response { + status: number = 500; + headers = new Map(); + body = ""; + + constructor(readonly connection: Deno.Conn) {} + + /* + |-------------------------------------------------------------------------------- + | Accessors + |-------------------------------------------------------------------------------- + */ + + /** + * ReadableStream of the HTTP response. + */ + get stream(): ReadableStream { + let isCancelled = false; + return new ReadableStream({ + start: (controller) => { + const push = () => { + this.#readLine().then((line) => { + const size = parseInt(line, 16); + if (size === 0 || isCancelled === true) { + if (size === 0 && isCancelled === false) { + controller.close(); + } + return this.connection.close(); + } + controller.enqueue(line); + push(); + }); + }; + push(); + }, + cancel: () => { + isCancelled = true; + }, + }); + } + + /** + * Parsed JSON instance of the response body. + */ + get json() { + if (this.body === "") { + return {}; + } + return JSON.parse(this.body); + } + + /* + |-------------------------------------------------------------------------------- + | Resolver + |-------------------------------------------------------------------------------- + */ + + /** + * Resolve the current response by reading the connection buffer and extracting + * the head, headers and body. + */ + async resolve(): Promise { + await this.#readHead(); + await this.#readHeader(); + if (this.headers.get("Content-Type") === "application/json") { + await this.#readBody(); + } + return this; + } + + async #readHead() { + const [protocol, statusCode] = await this.#readLine().then((head) => head.split(" ")); + if (protocol !== PROTOCOL) { + throw new Error(`HttpResponse > Unknown protocol ${protocol} received.`); + } + this.status = parseInt(statusCode, 10); + } + + async #readHeader() { + const header = await this.#readLine(); + if (header === "") { + return; + } + const [key, value] = header.split(":"); + this.headers.set(key.trim(), value.trim()); + await this.#readHeader(); + } + + async #readBody() { + if (this.headers.get("Transfer-Encoding") === "chunked") { + while (true) { + const line = await this.#readLine(); + const size = parseInt(line, 16); + if (size === 0) { + return; + } + const buf = new ArrayBuffer(size); + const arr = new Uint8Array(buf); + await this.connection.read(arr); + this.body += await this.#decode(arr); + } + } else if (this.headers.has("Content-Length") === true) { + const size = parseInt(this.headers.get("Content-Length")!, 10); + const buf = new ArrayBuffer(size); + const arr = new Uint8Array(buf); + await this.connection.read(arr); + this.body += await this.#decode(arr); + } + } + + async #readLine(): Promise { + let result = ""; + while (true) { + const buffer = new Uint8Array(1); + if (result.indexOf("\n") !== -1) { + return result.slice(0, result.length - 2); // return the full line without the \n flag + } + await this.connection.read(buffer); + result += await this.#decode(buffer); + } + } + + /* + |-------------------------------------------------------------------------------- + | Lifecycle + |-------------------------------------------------------------------------------- + */ + + /** + * Close the connection that was used to produce the current response instance. + * + * Note! If the response is not closed an active connection may remain open + * causing unclean shutdown of processes. + */ + close(): this { + this.connection.close(); + return this; + } + + /* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + + async #decode(value: Uint8Array): Promise { + return new TextDecoder().decode(value); + } +} diff --git a/http/mod.ts b/http/mod.ts new file mode 100644 index 0000000..406103e --- /dev/null +++ b/http/mod.ts @@ -0,0 +1,2 @@ +export * from "./libraries/client.ts"; +export * from "./libraries/response.ts"; diff --git a/tests/postgres.test.ts b/tests/postgres.test.ts new file mode 100644 index 0000000..acbf777 --- /dev/null +++ b/tests/postgres.test.ts @@ -0,0 +1,22 @@ +import { assertArrayIncludes, assertEquals } from "std/assert/mod.ts"; +import { afterAll, describe, it } from "std/testing/bdd.ts"; + +import { PostgresTestContainer } from "../containers/postgres.ts"; + +const DB_NAME = "sandbox"; + +const container = await PostgresTestContainer.start("postgres:14"); +await container.create(DB_NAME); + +const sql = await container.client(DB_NAME); + +describe("Postgres", () => { + afterAll(async () => { + await container.stop(); + }); + + it("should spin up a postgres container", async () => { + const res = await sql`SELECT 1`; + assertArrayIncludes(res, [{ "?column?": 1 }]); + }); +});