feat: initial commit
This commit is contained in:
34
.github/workflows/publish.yml
vendored
Normal file
34
.github/workflows/publish.yml
vendored
Normal 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
20
.github/workflows/test.yml
vendored
Normal 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
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"deno.enable": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "denoland.vscode-deno"
|
||||
}
|
||||
7
README.md
Normal file
7
README.md
Normal 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
189
containers/postgres.ts
Normal 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
30
deno.json
Normal 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
68
deno.lock
generated
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
124
docker/libraries/container.ts
Normal file
124
docker/libraries/container.ts
Normal 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;
|
||||
};
|
||||
32
docker/libraries/docker.ts
Normal file
32
docker/libraries/docker.ts
Normal 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
107
docker/libraries/exec.ts
Normal 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
155
docker/libraries/image.ts
Normal 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
114
docker/libraries/modem.ts
Normal 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
3
docker/mod.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Docker } from "./libraries/docker.ts";
|
||||
|
||||
export const docker = new Docker();
|
||||
361
docker/types/container.ts
Normal file
361
docker/types/container.ts
Normal 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
40
http/libraries/client.ts
Normal 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
3
http/libraries/common.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const NEW_LINE = "\r\n";
|
||||
|
||||
export const PROTOCOL = "HTTP/1.1";
|
||||
43
http/libraries/request.ts
Normal file
43
http/libraries/request.ts
Normal 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
151
http/libraries/response.ts
Normal 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
2
http/mod.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./libraries/client.ts";
|
||||
export * from "./libraries/response.ts";
|
||||
22
tests/postgres.test.ts
Normal file
22
tests/postgres.test.ts
Normal 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 }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user