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