feat: initial commit

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

View File

@@ -0,0 +1,124 @@
import { CreateExecOptions, Exec } from "./exec.ts";
import { modem } from "./modem.ts";
export class Container {
/**
* Instantiate a docker container.
*
* @param id - The container ID.
* @param warnings - Warnings encountered during the container creation.
*/
constructor(
readonly id: string,
readonly warnings: string[],
) {}
/**
* Start the container.
*
* @see https://docs.docker.com/engine/api/v1.45/#tag/Container/operation/ContainerStart
*
* @param query.signal - Signal to send to the container as an integer or string (e.g. SIGINT).
* @param query.t - An integer representing the number of seconds to wait before killing the container.
*/
async start(query: SignalQuery = {}) {
await modem.post({ path: `/containers/${this.id}/start`, query });
}
/**
* Stop the container.
*
* @see https://docs.docker.com/engine/api/v1.45/#tag/Container/operation/ContainerStop
*
* @param query.signal - Signal to send to the container as an integer or string (e.g. SIGINT).
* @param query.t - An integer representing the number of seconds to wait before killing the container.
*/
async stop(query: SignalQuery = {}) {
await modem.post({ path: `/containers/${this.id}/stop`, query });
}
/**
* Remove the container.
*
* @see https://docs.docker.com/engine/api/v1.45/#tag/Container/operation/ContainerDelete
*
* @param query.v - Remove the volumes associated with the container.
* @param query.force - Kill the container if it is running.
* @param query.link - Remove the specified link and not the underlying container.
*/
async remove(query: { v?: boolean; force?: boolean; link?: boolean } = {}) {
await modem.del({ path: `/containers/${this.id}`, query });
}
/**
* Return low-level information about a container.
*
* @see https://docs.docker.com/engine/api/v1.45/#tag/Container/operation/ContainerInspect
*/
async inspect() {
return modem.get({ path: `/containers/${this.id}/json` });
}
/**
* Get the logs of the container.
*
* @see https://docs.docker.com/engine/api/v1.45/#tag/Container/operation/ContainerLogs
*
* @param handler - Function to handle each line of the logs.
*/
async logs(handler: (line: string) => true | void) {
const res = await modem.request({
method: "GET",
path: `/containers/${this.id}/logs`,
query: {
stdout: true,
follow: true,
tail: "all",
},
});
for await (const chunk of res.stream) {
if (handler(chunk) === true) {
break;
}
}
}
/**
* Run a command inside the running container.
*
* @see https://docs.docker.com/engine/api/v1.45/#tag/Exec/operation/ContainerExec
*
* @param cmd - Command to run.
* @param opts - Options for the command.
*/
async exec(cmd: string | string[], opts: Partial<CreateExecOptions> = {}) {
const { Id } = await modem.post<{ Id: string }>({
path: `/containers/${this.id}/exec`,
body: {
...opts,
Cmd: Array.isArray(cmd) ? cmd : [cmd],
AttachStdout: true,
AttachStderr: true,
},
});
return new Exec(Id).start();
}
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type SignalQuery = {
/**
* Signal to send to the container as an integer or string (e.g. SIGINT).
*/
signal?: string;
/**
* An integer representing the number of seconds to wait before killing the container.
*/
t?: number;
};

View File

@@ -0,0 +1,32 @@
import { ContainerConfig } from "../types/container.ts";
import { modem } from "./modem.ts";
import { Container } from "./container.ts";
import { Image } from "./image.ts";
export class Docker {
/**
* Create a new docker container.
*
* @see https://docs.docker.com/engine/api/v1.45/#tag/Container/operation/ContainerCreate
*
* @params config - The configuration for the container.
* @params query - Query parameters.
*/
async createContainer(config: Partial<ContainerConfig>, query: Partial<{ name: string; platform: string }> = {}) {
const { Id, Warnings } = await modem.post<{ Id: string; Warnings: string[] }>({
path: "/containers/create",
query,
body: config,
});
return new Container(Id, Warnings);
}
/**
* Pull an image from the docker registry.
*
* @param image - The image to pull.
*/
async pullImage(image: string) {
await new Image().create({ fromImage: image });
}
}

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

@@ -0,0 +1,107 @@
import delay from "delay";
import { modem } from "./modem.ts";
export class Exec {
constructor(readonly id: string) {}
/**
* Starts the current exec instance. If detach is true, this endpoint
* returns immediately after starting the command. Otherwise, it sets up an
* interactive session with the command.
*
* @param body - Request body schema.
*/
async start(body: Partial<StartSchema> = {}) {
await modem.post({ path: `/exec/${this.id}/start`, body });
await this.#endSignal();
}
/**
* Return low-level information about the exec instance.
*/
async inspect() {
return modem.get<InspectResponse>({ path: `/exec/${this.id}/json` });
}
/*
|--------------------------------------------------------------------------------
| Utilities
|--------------------------------------------------------------------------------
*/
/**
* Wait for the current exec instance to finish its execution by observing
* its running state.
*
* [TODO] Introduce a timeout signal in case we want to add a treshold to the
* running time.
*/
async #endSignal() {
while (true) {
const info = await this.inspect();
if (info.Running === false) {
break;
}
await delay(250);
}
}
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
export type CreateExecOptions = {
AttachStdin: boolean;
AttachStdout: boolean;
AttachStderr: boolean;
ConsoleSize: [number, number];
Tty: boolean;
Env: string[];
Cmd: string[];
Privileged: boolean;
User: string;
WorkingDir: string;
};
type StartSchema = {
/**
* Detach from the command.
*/
Detach: boolean;
/**
* Allocate a pseudo-TTY.
*/
Tty: boolean;
/**
* Initial console size, as an `[height, width]` array.
*/
ConsoleSize?: [number, number];
};
type InspectResponse = {
CanRemove: boolean;
ContainerID: string;
DetachKeys: string;
ExitCode: number;
ID: string;
OpenStderr: boolean;
OpenStdin: boolean;
OpenStdout: boolean;
ProcessConfig: ProcessConfig;
Running: boolean;
Pid: number;
};
type ProcessConfig = {
arguments: string[];
entrypoint: string;
privileged: boolean;
tty: boolean;
user: string;
};

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

@@ -0,0 +1,155 @@
import { modem } from "./modem.ts";
export class Image {
/**
* Pull or import an image.
*
* @see https://docs.docker.com/engine/api/v1.45/#tag/Image/operation/ImageCreate
*
* @param query - The configuration for the image.
*/
async create(query: Partial<CreateImageOptions>) {
if (query.fromImage !== undefined) {
const hasImage = await this.inspect(query.fromImage);
if (hasImage !== undefined) {
return; // we already have this image
}
}
const res = await modem.request({
method: "POST",
path: "/images/create",
headers: {
"Content-Type": "text/plain",
},
query,
});
res.close();
if (res.status !== 200) {
throw new Error("Docker Image > Failed to create new image");
}
}
async inspect(image: string): Promise<InspectImageResponse | undefined> {
try {
return await modem.get<InspectImageResponse>({ path: `/images/${image}/json` });
} catch (_) {
return undefined;
}
}
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type CreateImageOptions = {
/**
* Name of the image to pull. The name may include a tag or digest. This
* parameter may only be used when pulling an image. The pull is cancelled if
* the HTTP connection is closed.
*/
fromImage: string;
};
type InspectImageResponse = {
Id: string;
RepoTags: string[];
RepoDigests: string[];
Parent: string;
Comment: string;
Created: string;
Container: string;
ContainerConfig: {
Hostname: string;
Domainname: string;
User: string;
AttachStdin: boolean;
AttachStdout: boolean;
AttachStderr: boolean;
ExposedPorts: Record<string, any>;
Tty: boolean;
OpenStdin: boolean;
StdinOnce: boolean;
Env: string[];
Cmd: string[];
Healthcheck: {
Test: string[];
Interval: number;
Timeout: number;
Retries: number;
StartPeriod: number;
StartInterval: number;
};
ArgsEscaped: boolean;
Image: string;
Volumes: Record<string, any>;
WorkingDir: string;
Entrypoint: string[];
NetworkDisabled: boolean;
MacAddress: string;
OnBuild: string[];
Labels: Record<string, string>;
StopSignal: string;
StopTimeout: number;
Shell: string[];
};
DockerVersion: string;
Author: string;
Config: {
Hostname: string;
Domainname: string;
User: string;
AttachStdin: boolean;
AttachStdout: boolean;
AttachStderr: boolean;
ExposedPorts: Record<string, any>;
Tty: boolean;
OpenStdin: boolean;
StdinOnce: boolean;
Env: string[];
Cmd: string[];
Healthcheck: {
Test: string[];
Interval: number;
Timeout: number;
Retries: number;
StartPeriod: number;
StartInterval: number;
};
ArgsEscaped: boolean;
Image: string;
Volumes: Record<string, any>;
WorkingDir: string;
Entrypoint: string[];
NetworkDisabled: boolean;
MacAddress: string;
OnBuild: string[];
Labels: Record<string, string>;
StopSignal: string;
StopTimeout: number;
Shell: string[];
};
Architecture: string;
Variant: string;
Os: string;
OsVersion: string;
Size: number;
VirtualSize: number;
GraphDriver: {
Name: string;
Data: {
MergedDir: string;
UpperDir: string;
WorkDir: string;
};
};
RootFS: {
Type: string;
Layers: string[];
};
Metadata: {
LastTagTime: string;
};
};

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

@@ -0,0 +1,114 @@
import { Client, type Response } from "../../http/mod.ts";
class Modem {
constructor(readonly options: Deno.ConnectOptions | Deno.UnixConnectOptions, readonly client = new Client(options)) {}
/**
* Send a `POST` request to the Docker API.
*
* @param param.path - Path of the API endpoint.
* @param param.query - Query parameters.
* @param param.body - Request body.
*/
async post<T = Record<string, never>>({ path, query = {}, body }: RequestOptions): Promise<T> {
return getParsedResponse<T>(await this.request({ method: "POST", path, query, body }));
}
/**
* Send a `GET` request to the Docker API.
*
* @param param.path - Path of the API endpoint.
* @param param.query - Query parameters.
*/
async get<T = Record<string, never>>({ path, query }: Omit<RequestOptions, "body">): Promise<T> {
return getParsedResponse<T>(await this.request({ method: "GET", path, query }));
}
/**
* Send a `DELETE` request to the Docker API.
*
* @param param.path - Path of the API endpoint.
* @param param.query - Query parameters.
*/
async del<T = Record<string, never>>({ path, query }: Omit<RequestOptions, "body">): Promise<T> {
return getParsedResponse<T>(await this.request({ method: "DELETE", path, query }));
}
/**
* Send a fetch request to the Docker API.
*
* Note! When calling this method directly, ensure to call the .close() method on the response
* or active connections may remain open causing dirty shutdown of services. Only when accessing
* the .stream of the response through an async itterator is the connection automatically closed
* when the itterator has completed.
*
* @param param.method - HTTP method to use.
* @param param.path - Path of the API endpoint.
* @param param.query - Query parameters.
* @param param.body - Request body. _(Ignored for `GET` requests.)_
* @param param.headers - Headers to send with the request.
*/
async request(
{ method, path, query = {}, body, headers = {} }: { method: "POST" | "GET" | "DELETE" } & RequestOptions,
): Promise<Response> {
return this.client.fetch(`http://docker${path}${toSearchParams(query)}`, {
method,
body,
headers,
});
}
}
export const modem = new Modem({
path: "/var/run/docker.sock",
transport: "unix",
});
/*
|--------------------------------------------------------------------------------
| Utilities
|--------------------------------------------------------------------------------
*/
function getParsedResponse<T>(res: Response): T {
res.close();
if (res.status >= 400) {
const error = res.json;
assertError(error);
throw new Error(error.message);
}
if (res.status === 204 || res.status === 304) {
return {} as T;
}
return res.json as T;
}
function toSearchParams(query: Record<string, unknown>): string {
if (Object.keys(query).length === 0) {
return "";
}
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
searchParams.append(key, String(value));
}
return `?${searchParams.toString()}`;
}
function assertError(error: unknown): asserts error is { message: string } {
if (typeof error !== "object" || error === null || !("message" in error)) {
throw new Error("Docker Modem > Could not parse error response.");
}
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type RequestOptions = {
path: string;
headers?: RequestInit["headers"];
query?: Record<string, unknown>;
body?: Record<string, unknown>;
};