feat: initial commit

This commit is contained in:
2024-07-06 21:21:10 +02:00
commit 96f7c2cfe7
21 changed files with 1511 additions and 0 deletions

34
.github/workflows/publish.yml vendored Normal file
View File

@@ -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

20
.github/workflows/test.yml vendored Normal file
View File

@@ -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

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules

5
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"deno.enable": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "denoland.vscode-deno"
}

7
README.md Normal file
View File

@@ -0,0 +1,7 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/1998130/229430454-ca0f2811-d874-4314-b13d-c558de8eec7e.svg" />
</p>
# Test Containers
Test container solution for running third party solutions through docker.

189
containers/postgres.ts Normal file
View File

@@ -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<Config> = {}): Promise<PostgresTestContainer> {
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<void> {
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<void> {
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;
};

30
deno.json Normal file
View File

@@ -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"
}
}

68
deno.lock generated Normal file
View File

@@ -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"
]
}
}

View File

@@ -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<CreateExecOptions> = {}) {
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;
};

View File

@@ -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<ContainerConfig>, 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 });
}
}

107
docker/libraries/exec.ts Normal file
View File

@@ -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<StartSchema> = {}) {
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<InspectResponse>({ 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;
};

155
docker/libraries/image.ts Normal file
View File

@@ -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<CreateImageOptions>) {
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<InspectImageResponse | undefined> {
try {
return await modem.get<InspectImageResponse>({ 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<string, any>;
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<string, any>;
WorkingDir: string;
Entrypoint: string[];
NetworkDisabled: boolean;
MacAddress: string;
OnBuild: string[];
Labels: Record<string, string>;
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<string, any>;
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<string, any>;
WorkingDir: string;
Entrypoint: string[];
NetworkDisabled: boolean;
MacAddress: string;
OnBuild: string[];
Labels: Record<string, string>;
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;
};
};

114
docker/libraries/modem.ts Normal file
View File

@@ -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<T = Record<string, never>>({ path, query = {}, body }: RequestOptions): Promise<T> {
return getParsedResponse<T>(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<T = Record<string, never>>({ path, query }: Omit<RequestOptions, "body">): Promise<T> {
return getParsedResponse<T>(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<T = Record<string, never>>({ path, query }: Omit<RequestOptions, "body">): Promise<T> {
return getParsedResponse<T>(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<Response> {
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<T>(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, unknown>): 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<string, unknown>;
body?: Record<string, unknown>;
};

3
docker/mod.ts Normal file
View File

@@ -0,0 +1,3 @@
import { Docker } from "./libraries/docker.ts";
export const docker = new Docker();

361
docker/types/container.ts Normal file
View File

@@ -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:
* `{"<port>/<tcp|udp>": {}}`.
* ```ts
* {
* ExposedPorts: {
* "22/tcp": {},
* }
* }
* ```
*/
ExposedPorts: Record<string, unknown>;
/**
* 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<string, unknown>;
/**
* 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<string, string>;
/**
* 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<HostConfig>;
/**
* 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<NetworkingConfig>;
};
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<string, string>;
};
NetworkMode: string;
PortBindings: Record<string, unknown>;
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<string, string>;
DriverConfig: {
Name: string;
Options: Record<string, string>;
};
Subpath: string;
};
TmpfsOptions: {
SizeBytes: number;
Mode: number;
};
}[];
ConsoleSize: number[];
Annotations: Record<string, string>;
CapAdd: string[];
CapDrop: string[];
CgroupnsMode: "host" | "private";
Dns: string[];
DnsOptions: string[];
DnsSearch: string[];
ExtraHosts: string[];
GroupAdd: string[];
IpcMode: "none" | "private" | "shareable" | "container:<name_or_id>" | "host";
Cgroup: string;
Links: string[];
OomScoreAdj: number;
PidMode: "host" | "container:<name_or_id>";
Privileged: boolean;
PublishAllPorts: boolean;
ReadonlyRootfs: boolean;
SecurityOpt: string[];
StorageOpt: Record<string, string>;
Tmpfs: Record<string, string>;
UTSMode: string;
UsernsMode: string;
ShmSize: number;
Sysctls: Record<string, string>;
Runtime: string;
MaskedPaths: string[];
ReadonlyPaths: string[];
};
type NetworkingConfig = {
EndpointsConfig: Record<string, EndpointSettings>;
};
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;
};

40
http/libraries/client.ts Normal file
View File

@@ -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<Response> {
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<string, unknown>;
};

3
http/libraries/common.ts Normal file
View File

@@ -0,0 +1,3 @@
export const NEW_LINE = "\r\n";
export const PROTOCOL = "HTTP/1.1";

43
http/libraries/request.ts Normal file
View File

@@ -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<Response> {
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<Uint8Array> {
return new TextEncoder().encode(value);
}
async decode(buffer: Uint8Array): Promise<string> {
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";

151
http/libraries/response.ts Normal file
View File

@@ -0,0 +1,151 @@
import { PROTOCOL } from "./common.ts";
export class Response {
status: number = 500;
headers = new Map<string, string>();
body = "";
constructor(readonly connection: Deno.Conn) {}
/*
|--------------------------------------------------------------------------------
| Accessors
|--------------------------------------------------------------------------------
*/
/**
* ReadableStream of the HTTP response.
*/
get stream(): ReadableStream<string> {
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<this> {
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<string> {
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<string> {
return new TextDecoder().decode(value);
}
}

2
http/mod.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "./libraries/client.ts";
export * from "./libraries/response.ts";

22
tests/postgres.test.ts Normal file
View File

@@ -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 }]);
});
});