podkeeper 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +7 -0
- package/README.md +102 -0
- package/deadmanswitch/README.md +34 -0
- package/deadmanswitch/bin/deadmanswitch_linux_aarch64 +0 -0
- package/deadmanswitch/bin/deadmanswitch_linux_x86_64 +0 -0
- package/lib/dockerApi.js +181 -0
- package/lib/genericService.js +84 -0
- package/lib/index.js +8 -0
- package/lib/minio.js +51 -0
- package/lib/mysql.js +49 -0
- package/lib/postgres.js +53 -0
- package/package.json +38 -0
- package/types/dockerApi.d.ts +71 -0
- package/types/genericService.d.ts +25 -0
- package/types/index.d.ts +5 -0
- package/types/minio.d.ts +16 -0
- package/types/mysql.d.ts +20 -0
- package/types/postgres.d.ts +22 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2024 Degu Labs, Inc
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# PodKeeper
|
|
2
|
+
|
|
3
|
+
> ⚠️ **Warning:** PodKeeper is currently in pre-1.0.0 release. Expect potential changes and experimental features that may not be fully stable yet.
|
|
4
|
+
|
|
5
|
+
PodKeeper is a node.js open-source library for starting and stopping Docker containers in a way that ensures they won’t linger if the host program crashes or exits unexpectedly without properly stopping them.
|
|
6
|
+
|
|
7
|
+
PodKeeper is written in TypeScript and comes bundled with TypeScript types.
|
|
8
|
+
|
|
9
|
+
- [Getting Started](#getting-started)
|
|
10
|
+
- [Bundled Services & API](#bundled-services--api)
|
|
11
|
+
- [How It Works](#how-it-works)
|
|
12
|
+
- [PodKeeper vs. TestContainers](#podkeeper-vs-testcontainers)
|
|
13
|
+
|
|
14
|
+
## Getting Started
|
|
15
|
+
|
|
16
|
+
1. Install podkeeper:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npm i --save-dev podkeeper
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
1. Pull container you wish to launch beforehand:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
docker pull postgres:latest
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
1. Start / stop container programmatically:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { Postgres } from 'podkeeper';
|
|
32
|
+
|
|
33
|
+
const postgres = await Postgres.start();
|
|
34
|
+
// do something with container...
|
|
35
|
+
await postgres.stop();
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Bundled services & API
|
|
39
|
+
|
|
40
|
+
PodKeeper comes bundled with the following pre-configured services:
|
|
41
|
+
|
|
42
|
+
* MySQL:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { MySQL } from 'podkeeper';
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
* Postgres
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { Postgres } from 'podkeeper';
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
* Minio
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { Minio } from 'podkeeper';
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
If a popular service is missing, please do not hesitate to submit a pull request.
|
|
61
|
+
Alternatively, you can launch a generic container service with the `GenericService` class:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { GenericService } from 'podkeeper';
|
|
65
|
+
|
|
66
|
+
cosnt service = await GenericService.start({
|
|
67
|
+
imageName: string,
|
|
68
|
+
ports: number[],
|
|
69
|
+
healthcheck?: {
|
|
70
|
+
test: string[],
|
|
71
|
+
intervalMs: number,
|
|
72
|
+
retries: number,
|
|
73
|
+
startPeriodMs: number,
|
|
74
|
+
timeoutMs: number,
|
|
75
|
+
},
|
|
76
|
+
command?: string[],
|
|
77
|
+
env?: { [key: string]: string | number | boolean | undefined };
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## How It Works
|
|
82
|
+
|
|
83
|
+
Each Docker container has a primary process, known as the "entrypoint," which keeps the container running. For example, when you start a PostgreSQL container, it runs the `postgres` binary. When this binary exits, the container stops as well.
|
|
84
|
+
|
|
85
|
+
PodKeeper wraps the entrypoint in a special binary called `deadmanswitch`, which not only starts the entrypoint but also launches a WebSocket server. The client that initiated the container must connect to this WebSocket server; otherwise, the container will self-terminate after 10 seconds.
|
|
86
|
+
|
|
87
|
+
This setup creates a connection between the launched container and its owner. Whenever this WebSocket disconnects, the `deadmanswitch` program automatically stops the container.
|
|
88
|
+
|
|
89
|
+
## PodKeeper vs. TestContainers
|
|
90
|
+
|
|
91
|
+
Both PodKeeper and [TestContainers](https://testcontainers.com/) provide solutions for starting, stopping, and cleaning up Docker containers:
|
|
92
|
+
|
|
93
|
+
- **TestContainers** uses a dedicated Docker container called "Ryuk" to manage cleanup.
|
|
94
|
+
- **PodKeeper** relies on a [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) mechanism for cleanup.
|
|
95
|
+
|
|
96
|
+
While TestContainers is a mature, industry-proven tool, PodKeeper is an experimental alternative that explores a different approach.
|
|
97
|
+
|
|
98
|
+
There are also some notable differences in API design philosophy:
|
|
99
|
+
|
|
100
|
+
- **Process Behavior**: PodKeeper services prevent the Node.js process from exiting, while TestContainers services do not.
|
|
101
|
+
- **Container Pulling**: PodKeeper does not implicitly pull containers, requiring them to be available beforehand, whereas TestContainers lazily pulls containers as needed when launching a service.
|
|
102
|
+
- **Healthchecks**: The services that PodKeeper ships out-of-the-box are pre-configured to use proper healthchecks.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# DeadManSwitch
|
|
2
|
+
|
|
3
|
+
DeadManSwitch is a lightweight Go program that runs a specified command with arguments and simultaneously starts a WebSocket signaling server.
|
|
4
|
+
|
|
5
|
+
## Program Behavior
|
|
6
|
+
|
|
7
|
+
DeadManSwitch operates with the following behavior:
|
|
8
|
+
|
|
9
|
+
1. If the launched command exits, DeadManSwitch exits as well.
|
|
10
|
+
2. If no client connects to the WebSocket signaling server within `DEADMANSWITCH_TIMEOUT` seconds (default is 10 seconds), the process terminates.
|
|
11
|
+
3. If a client connects but then disconnects, the process terminates.
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
You can configure DeadManSwitch using the following environment variables:
|
|
16
|
+
|
|
17
|
+
- **`DEADMANSWITCH_TIMEOUT`**: Sets the timeout (in seconds) to wait for clients to connect to the WebSocket signaling server. Default is 10 seconds.
|
|
18
|
+
- **`DEADMANSWITCH_PORT`**: Specifies the port to run the server on.
|
|
19
|
+
- **`DEADMANSWITCH_SUFFIX`**: Defines a suffix for the WebSocket URL endpoint.
|
|
20
|
+
|
|
21
|
+
## Usage Example
|
|
22
|
+
|
|
23
|
+
The following command launches `sleep 100` and accepts WebSocket connections on `ws://localhost:54321/foobar`:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
DEADMANSWITCH_TIMEOUT=10 \
|
|
27
|
+
DEADMANSWITCH_PORT=54321 \
|
|
28
|
+
DEADMANSWITCH_SUFFIX=foobar \
|
|
29
|
+
deadmanswitch sleep 100
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Compilation
|
|
33
|
+
|
|
34
|
+
To compile, run `./build.sh`
|
|
Binary file
|
|
Binary file
|
package/lib/dockerApi.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
const DOCKER_API_VERSION = "1.41";
|
|
3
|
+
async function listContainers() {
|
|
4
|
+
const containers = await getJSON("/containers/json") ?? [];
|
|
5
|
+
return containers.map((container) => ({
|
|
6
|
+
containerId: container.Id,
|
|
7
|
+
imageId: container.ImageID,
|
|
8
|
+
state: container.State,
|
|
9
|
+
// Note: container names are usually prefixed with '/'.
|
|
10
|
+
// See https://github.com/moby/moby/issues/6705
|
|
11
|
+
names: (container.Names ?? []).map((name) => name.startsWith("/") ? name.substring(1) : name),
|
|
12
|
+
portBindings: container.Ports?.map((portInfo) => ({
|
|
13
|
+
ip: portInfo.IP,
|
|
14
|
+
hostPort: portInfo.PublicPort,
|
|
15
|
+
containerPort: portInfo.PrivatePort
|
|
16
|
+
})) ?? [],
|
|
17
|
+
labels: container.Labels ?? {}
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
async function isContainerHealthy(containerId) {
|
|
21
|
+
const container = await getJSON(`/containers/${containerId}/json`);
|
|
22
|
+
return container?.State?.Health?.Status === "healthy";
|
|
23
|
+
}
|
|
24
|
+
async function launchContainer(options) {
|
|
25
|
+
const ExposedPorts = {};
|
|
26
|
+
const PortBindings = {};
|
|
27
|
+
for (const port of options.ports ?? []) {
|
|
28
|
+
ExposedPorts[`${port.container}/tcp`] = {};
|
|
29
|
+
PortBindings[`${port.container}/tcp`] = [{ HostPort: port.host + "", HostIp: "127.0.0.1" }];
|
|
30
|
+
}
|
|
31
|
+
const container = await postJSON(`/containers/create` + (options.name ? "?name=" + options.name : ""), {
|
|
32
|
+
Cmd: options.command,
|
|
33
|
+
WorkingDir: options.workingDir,
|
|
34
|
+
Labels: options.labels ?? {},
|
|
35
|
+
AttachStdin: false,
|
|
36
|
+
AttachStdout: false,
|
|
37
|
+
AttachStderr: false,
|
|
38
|
+
Image: options.imageId,
|
|
39
|
+
ExposedPorts,
|
|
40
|
+
Entrypoint: options.entrypoint,
|
|
41
|
+
Healthcheck: options.healthcheck ? {
|
|
42
|
+
Test: options.healthcheck.test,
|
|
43
|
+
Interval: options.healthcheck.intervalMs * 1e6,
|
|
44
|
+
// must be in nano seconds
|
|
45
|
+
Timeout: options.healthcheck.timeoutMs * 1e6,
|
|
46
|
+
// must be in nano seconds
|
|
47
|
+
Retries: options.healthcheck.retries,
|
|
48
|
+
StartPeriod: options.healthcheck.startPeriodMs * 1e6
|
|
49
|
+
// must be in nano seconds
|
|
50
|
+
} : void 0,
|
|
51
|
+
Env: dockerProtocolEnv(options.env),
|
|
52
|
+
HostConfig: {
|
|
53
|
+
Binds: options.binds?.map((bind) => `${bind.hostPath}:${bind.containerPath}`),
|
|
54
|
+
Init: true,
|
|
55
|
+
AutoRemove: options.autoRemove,
|
|
56
|
+
ShmSize: 2 * 1024 * 1024 * 1024,
|
|
57
|
+
PortBindings
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
await postJSON(`/containers/${container.Id}/start`);
|
|
61
|
+
if (options.waitUntil)
|
|
62
|
+
await postJSON(`/containers/${container.Id}/wait?condition=${options.waitUntil}`);
|
|
63
|
+
return container.Id;
|
|
64
|
+
}
|
|
65
|
+
async function stopContainer(options) {
|
|
66
|
+
await Promise.all([
|
|
67
|
+
// Make sure to wait for the container to be removed.
|
|
68
|
+
postJSON(`/containers/${options.containerId}/wait?condition=${options.waitUntil ?? "not-running"}`),
|
|
69
|
+
postJSON(`/containers/${options.containerId}/kill`)
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
72
|
+
async function removeContainer(containerId) {
|
|
73
|
+
await Promise.all([
|
|
74
|
+
// Make sure to wait for the container to be removed.
|
|
75
|
+
postJSON(`/containers/${containerId}/wait?condition=removed`),
|
|
76
|
+
callDockerAPI("delete", `/containers/${containerId}`)
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
async function getContainerLogs(containerId) {
|
|
80
|
+
const rawLogs = await callDockerAPI("get", `/containers/${containerId}/logs?stdout=true&stderr=true`).catch((e) => "");
|
|
81
|
+
if (!rawLogs)
|
|
82
|
+
return [];
|
|
83
|
+
return rawLogs.split("\n").map((line) => {
|
|
84
|
+
if ([0, 1, 2].includes(line.charCodeAt(0)))
|
|
85
|
+
return line.substring(8);
|
|
86
|
+
return line;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function dockerProtocolEnv(env) {
|
|
90
|
+
const result = [];
|
|
91
|
+
for (const [key, value] of Object.entries(env ?? {}))
|
|
92
|
+
result.push(`${key}=${value}`);
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
async function commitContainer(options) {
|
|
96
|
+
await postJSON(`/commit?container=${options.containerId}&repo=${options.repo}&tag=${options.tag}`, {
|
|
97
|
+
Entrypoint: options.entrypoint,
|
|
98
|
+
WorkingDir: options.workingDir,
|
|
99
|
+
Env: dockerProtocolEnv(options.env)
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
async function inspectImage(imageId) {
|
|
103
|
+
return await getJSON(`/images/${imageId}/json`);
|
|
104
|
+
}
|
|
105
|
+
async function listImages() {
|
|
106
|
+
const rawImages = await getJSON("/images/json") ?? [];
|
|
107
|
+
return rawImages.map((rawImage) => ({
|
|
108
|
+
imageId: rawImage.Id,
|
|
109
|
+
names: rawImage.RepoTags ?? []
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
async function removeImage(imageId) {
|
|
113
|
+
await callDockerAPI("delete", `/images/${imageId}`);
|
|
114
|
+
}
|
|
115
|
+
async function checkEngineRunning() {
|
|
116
|
+
try {
|
|
117
|
+
await callDockerAPI("get", "/info");
|
|
118
|
+
return true;
|
|
119
|
+
} catch (e) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function getJSON(url) {
|
|
124
|
+
const result = await callDockerAPI("get", url);
|
|
125
|
+
if (!result)
|
|
126
|
+
return result;
|
|
127
|
+
return JSON.parse(result);
|
|
128
|
+
}
|
|
129
|
+
async function postJSON(url, json = void 0) {
|
|
130
|
+
const result = await callDockerAPI("post", url, json ? JSON.stringify(json) : void 0);
|
|
131
|
+
if (!result)
|
|
132
|
+
return result;
|
|
133
|
+
return JSON.parse(result);
|
|
134
|
+
}
|
|
135
|
+
function callDockerAPI(method, url, body = void 0) {
|
|
136
|
+
const dockerSocket = process.platform === "win32" ? "\\\\.\\pipe\\docker_engine" : "/var/run/docker.sock";
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const request = http.request({
|
|
139
|
+
socketPath: dockerSocket,
|
|
140
|
+
path: `/v${DOCKER_API_VERSION}${url}`,
|
|
141
|
+
timeout: 3e4,
|
|
142
|
+
method
|
|
143
|
+
}, (response) => {
|
|
144
|
+
let body2 = "";
|
|
145
|
+
response.on("data", function(chunk) {
|
|
146
|
+
body2 += chunk;
|
|
147
|
+
});
|
|
148
|
+
response.on("end", function() {
|
|
149
|
+
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300)
|
|
150
|
+
reject(new Error(`${method} ${url} FAILED with statusCode ${response.statusCode} and body
|
|
151
|
+
${body2}`));
|
|
152
|
+
else
|
|
153
|
+
resolve(body2);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
request.on("error", function(e) {
|
|
157
|
+
reject(e);
|
|
158
|
+
});
|
|
159
|
+
if (body) {
|
|
160
|
+
request.setHeader("Content-Type", "application/json");
|
|
161
|
+
request.setHeader("Content-Length", body.length);
|
|
162
|
+
request.write(body);
|
|
163
|
+
} else {
|
|
164
|
+
request.setHeader("Content-Type", "text/plain");
|
|
165
|
+
}
|
|
166
|
+
request.end();
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
export {
|
|
170
|
+
checkEngineRunning,
|
|
171
|
+
commitContainer,
|
|
172
|
+
getContainerLogs,
|
|
173
|
+
inspectImage,
|
|
174
|
+
isContainerHealthy,
|
|
175
|
+
launchContainer,
|
|
176
|
+
listContainers,
|
|
177
|
+
listImages,
|
|
178
|
+
removeContainer,
|
|
179
|
+
removeImage,
|
|
180
|
+
stopContainer
|
|
181
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { setTimeout } from "timers/promises";
|
|
2
|
+
import { WebSocket } from "ws";
|
|
3
|
+
import * as dockerApi from "./dockerApi.js";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import url from "url";
|
|
6
|
+
const __filename = url.fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
async function connectWebSocket(address, deadline) {
|
|
9
|
+
while (Date.now() < deadline) {
|
|
10
|
+
const socket = new WebSocket(address);
|
|
11
|
+
const result = await new Promise((resolve, reject) => {
|
|
12
|
+
socket.on("open", () => resolve(true));
|
|
13
|
+
socket.on("error", () => reject(false));
|
|
14
|
+
});
|
|
15
|
+
if (result)
|
|
16
|
+
return socket;
|
|
17
|
+
await setTimeout(100, void 0);
|
|
18
|
+
}
|
|
19
|
+
return void 0;
|
|
20
|
+
}
|
|
21
|
+
class GenericService {
|
|
22
|
+
constructor(_containerId, _bindings, _ws) {
|
|
23
|
+
this._containerId = _containerId;
|
|
24
|
+
this._bindings = _bindings;
|
|
25
|
+
this._ws = _ws;
|
|
26
|
+
}
|
|
27
|
+
static async start(options) {
|
|
28
|
+
const images = await dockerApi.listImages();
|
|
29
|
+
const image = images.find((image2) => image2.names.includes(options.imageName));
|
|
30
|
+
if (!image)
|
|
31
|
+
throw new Error(`ERROR: no image named "${options.imageName}" - run 'docker pull ${options.imageName}'`);
|
|
32
|
+
const metadata = await dockerApi.inspectImage(image.imageId);
|
|
33
|
+
const imageArch = metadata.Architecture;
|
|
34
|
+
const entrypoint = ["/deadmanswitch"];
|
|
35
|
+
const command = [metadata.Config.Entrypoint, options.command ?? metadata.Config.Cmd].flat();
|
|
36
|
+
const deadmanswitchName = imageArch === "arm64" ? "deadmanswitch_linux_aarch64" : "deadmanswitch_linux_x86_64";
|
|
37
|
+
const usedPorts = new Set(options.ports);
|
|
38
|
+
let switchPort = 54321;
|
|
39
|
+
while (usedPorts.has(switchPort))
|
|
40
|
+
++switchPort;
|
|
41
|
+
const containerId = await dockerApi.launchContainer({
|
|
42
|
+
imageId: image.imageId,
|
|
43
|
+
autoRemove: true,
|
|
44
|
+
binds: [
|
|
45
|
+
{ containerPath: "/deadmanswitch", hostPath: path.join(__dirname, "..", "deadmanswitch", "bin", deadmanswitchName) }
|
|
46
|
+
],
|
|
47
|
+
ports: [
|
|
48
|
+
...options.ports.map((port) => ({ container: port, host: 0 })),
|
|
49
|
+
{ container: switchPort, host: 0 }
|
|
50
|
+
],
|
|
51
|
+
entrypoint,
|
|
52
|
+
command,
|
|
53
|
+
healthcheck: options.healthcheck,
|
|
54
|
+
env: options.env
|
|
55
|
+
});
|
|
56
|
+
const deadline = Date.now() + 1e4;
|
|
57
|
+
const container = (await dockerApi.listContainers()).find((container2) => container2.containerId === containerId);
|
|
58
|
+
if (!container)
|
|
59
|
+
throw new Error("ERROR: failed to launch container!");
|
|
60
|
+
const switchBinding = container.portBindings.find((binding) => binding.containerPort === switchPort);
|
|
61
|
+
if (!switchBinding || !switchBinding.hostPort) {
|
|
62
|
+
await dockerApi.stopContainer({ containerId: container.containerId });
|
|
63
|
+
throw new Error("Failed to expose service to host");
|
|
64
|
+
}
|
|
65
|
+
while (!await dockerApi.isContainerHealthy(container.containerId))
|
|
66
|
+
await setTimeout(100, void 0);
|
|
67
|
+
const ws = await connectWebSocket(`ws://localhost:${switchBinding.hostPort}/`, deadline);
|
|
68
|
+
if (!ws)
|
|
69
|
+
throw new Error("Failed to connect to launched container");
|
|
70
|
+
const service = new GenericService(containerId, container.portBindings, ws);
|
|
71
|
+
return service;
|
|
72
|
+
}
|
|
73
|
+
mappedPort(containerPort) {
|
|
74
|
+
const binding = this._bindings.find((binding2) => binding2.containerPort === containerPort);
|
|
75
|
+
return binding?.hostPort;
|
|
76
|
+
}
|
|
77
|
+
async stop() {
|
|
78
|
+
this._ws.close();
|
|
79
|
+
await dockerApi.stopContainer({ containerId: this._containerId });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export {
|
|
83
|
+
GenericService
|
|
84
|
+
};
|
package/lib/index.js
ADDED
package/lib/minio.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import ms from "ms";
|
|
2
|
+
import { GenericService } from "./genericService.js";
|
|
3
|
+
class Minio {
|
|
4
|
+
constructor(_service, _accessKeyId, _secretAccessKey) {
|
|
5
|
+
this._service = _service;
|
|
6
|
+
this._accessKeyId = _accessKeyId;
|
|
7
|
+
this._secretAccessKey = _secretAccessKey;
|
|
8
|
+
}
|
|
9
|
+
static async start({ accessKeyId = "root", secretAccessKey = "password" } = {}) {
|
|
10
|
+
const service = await GenericService.start({
|
|
11
|
+
imageName: "quay.io/minio/minio:latest",
|
|
12
|
+
ports: [9e3, 9090],
|
|
13
|
+
healthcheck: {
|
|
14
|
+
test: ["CMD", `mc`, `ready`, `local`],
|
|
15
|
+
intervalMs: ms("100ms"),
|
|
16
|
+
retries: 10,
|
|
17
|
+
startPeriodMs: ms("30s"),
|
|
18
|
+
timeoutMs: ms("5s")
|
|
19
|
+
},
|
|
20
|
+
env: {
|
|
21
|
+
"MINIO_ROOT_USER": accessKeyId,
|
|
22
|
+
"MINIO_ROOT_PASSWORD": secretAccessKey
|
|
23
|
+
},
|
|
24
|
+
command: [
|
|
25
|
+
"server",
|
|
26
|
+
"/data",
|
|
27
|
+
"--console-address",
|
|
28
|
+
":9090"
|
|
29
|
+
]
|
|
30
|
+
});
|
|
31
|
+
return new Minio(service, accessKeyId, secretAccessKey);
|
|
32
|
+
}
|
|
33
|
+
accessKeyId() {
|
|
34
|
+
return this._accessKeyId;
|
|
35
|
+
}
|
|
36
|
+
secretAccessKey() {
|
|
37
|
+
return this._secretAccessKey;
|
|
38
|
+
}
|
|
39
|
+
apiEndpoint() {
|
|
40
|
+
return "http://localhost:" + this._service.mappedPort(9e3);
|
|
41
|
+
}
|
|
42
|
+
webuiEndpoint() {
|
|
43
|
+
return "http://localhost:" + this._service.mappedPort(9090);
|
|
44
|
+
}
|
|
45
|
+
async stop() {
|
|
46
|
+
await this._service.stop();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export {
|
|
50
|
+
Minio
|
|
51
|
+
};
|
package/lib/mysql.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import ms from "ms";
|
|
2
|
+
import { GenericService } from "./genericService.js";
|
|
3
|
+
const MYSQL_IMAGE_NAME = "mysql:latest";
|
|
4
|
+
const MYSQL_PORT = 3306;
|
|
5
|
+
class MySQL {
|
|
6
|
+
constructor(_service, _rootPassword, _db) {
|
|
7
|
+
this._service = _service;
|
|
8
|
+
this._rootPassword = _rootPassword;
|
|
9
|
+
this._db = _db;
|
|
10
|
+
}
|
|
11
|
+
static async start({ db = "mydatabase", rootPassword = "rootpassword" } = {}) {
|
|
12
|
+
const service = await GenericService.start({
|
|
13
|
+
imageName: "mysql:latest",
|
|
14
|
+
ports: [MYSQL_PORT],
|
|
15
|
+
healthcheck: {
|
|
16
|
+
test: ["CMD-SHELL", `mysqladmin ping --host 127.0.0.1 -u root --password=${rootPassword}`],
|
|
17
|
+
intervalMs: ms("100ms"),
|
|
18
|
+
retries: 10,
|
|
19
|
+
startPeriodMs: 0,
|
|
20
|
+
timeoutMs: ms("5s")
|
|
21
|
+
},
|
|
22
|
+
command: ["mysqld", "--innodb-force-recovery=0", "--skip-innodb-doublewrite"],
|
|
23
|
+
env: {
|
|
24
|
+
"MYSQL_ROOT_PASSWORD": rootPassword,
|
|
25
|
+
"MYSQL_DATABASE": db,
|
|
26
|
+
"MYSQL_INITDB_SKIP_TZINFO": true
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
return new MySQL(service, rootPassword, db);
|
|
30
|
+
}
|
|
31
|
+
databaseUrl() {
|
|
32
|
+
return `mysql://root:${this._rootPassword}@localhost:${this._service.mappedPort(MYSQL_PORT)}/${this._db}`;
|
|
33
|
+
}
|
|
34
|
+
connectOptions() {
|
|
35
|
+
return {
|
|
36
|
+
host: "localhost",
|
|
37
|
+
port: this._service.mappedPort(MYSQL_PORT),
|
|
38
|
+
database: this._db,
|
|
39
|
+
user: "root",
|
|
40
|
+
password: this._rootPassword
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
async stop() {
|
|
44
|
+
await this._service.stop();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export {
|
|
48
|
+
MySQL
|
|
49
|
+
};
|
package/lib/postgres.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import ms from "ms";
|
|
2
|
+
import { GenericService } from "./genericService.js";
|
|
3
|
+
const IMAGE_NAME = "postgres:latest";
|
|
4
|
+
const POSTGRES_PORT = 5432;
|
|
5
|
+
class Postgres {
|
|
6
|
+
constructor(_service, _user, _password, _db) {
|
|
7
|
+
this._service = _service;
|
|
8
|
+
this._user = _user;
|
|
9
|
+
this._password = _password;
|
|
10
|
+
this._db = _db;
|
|
11
|
+
}
|
|
12
|
+
static async start({ user = "user", password = "password", db = "postgres" } = {}) {
|
|
13
|
+
const service = await GenericService.start({
|
|
14
|
+
imageName: "postgres:latest",
|
|
15
|
+
ports: [POSTGRES_PORT],
|
|
16
|
+
healthcheck: {
|
|
17
|
+
test: ["CMD-SHELL", "pg_isready"],
|
|
18
|
+
intervalMs: ms("1s"),
|
|
19
|
+
retries: 10,
|
|
20
|
+
startPeriodMs: 0,
|
|
21
|
+
timeoutMs: ms("5s")
|
|
22
|
+
},
|
|
23
|
+
env: {
|
|
24
|
+
"POSTGRES_USER": user,
|
|
25
|
+
"POSTGRES_PASSWORD": password,
|
|
26
|
+
"POSTGRES_DB": db,
|
|
27
|
+
// Duplicate env variables for the healthchecks.
|
|
28
|
+
"PGUSER": user,
|
|
29
|
+
"PGPASSWORD": password,
|
|
30
|
+
"PGDATABASE": db
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return new Postgres(service, user, password, db);
|
|
34
|
+
}
|
|
35
|
+
databaseUrl() {
|
|
36
|
+
return `postgres://${this._user}:${this._password}@localhost:${this._service.mappedPort(POSTGRES_PORT)}/${this._db}`;
|
|
37
|
+
}
|
|
38
|
+
connectOptions() {
|
|
39
|
+
return {
|
|
40
|
+
host: "localhost",
|
|
41
|
+
port: this._service.mappedPort(POSTGRES_PORT),
|
|
42
|
+
database: this._db,
|
|
43
|
+
user: this._user,
|
|
44
|
+
password: this._password
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
async stop() {
|
|
48
|
+
await this._service.stop();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export {
|
|
52
|
+
Postgres
|
|
53
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "podkeeper",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.3.0",
|
|
5
|
+
"description": "",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./types/index.d.ts",
|
|
9
|
+
"import": "./lib/index.js",
|
|
10
|
+
"require": "./lib/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"main": "index.js",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/flakiness/podkeeper.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/flakiness/podkeeper",
|
|
22
|
+
"keywords": [],
|
|
23
|
+
"author": "",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@degulabs/build": "^0.0.1",
|
|
27
|
+
"@playwright/test": "^1.48.2",
|
|
28
|
+
"@types/ms": "^0.7.34",
|
|
29
|
+
"@types/node": "^22.8.5",
|
|
30
|
+
"@types/ws": "^8.5.12",
|
|
31
|
+
"esbuild": "^0.24.0",
|
|
32
|
+
"typescript": "^5.6.3"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"ms": "^2.1.3",
|
|
36
|
+
"ws": "^8.18.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export interface DockerImage {
|
|
2
|
+
imageId: string;
|
|
3
|
+
names: string[];
|
|
4
|
+
}
|
|
5
|
+
export interface PortBinding {
|
|
6
|
+
ip: string;
|
|
7
|
+
hostPort: number;
|
|
8
|
+
containerPort: number;
|
|
9
|
+
}
|
|
10
|
+
export interface DockerContainer {
|
|
11
|
+
containerId: string;
|
|
12
|
+
labels: Record<string, string>;
|
|
13
|
+
imageId: string;
|
|
14
|
+
state: 'created' | 'restarting' | 'running' | 'removing' | 'paused' | 'exited' | 'dead';
|
|
15
|
+
names: string[];
|
|
16
|
+
portBindings: PortBinding[];
|
|
17
|
+
}
|
|
18
|
+
export declare function listContainers(): Promise<DockerContainer[]>;
|
|
19
|
+
export declare function isContainerHealthy(containerId: string): Promise<boolean>;
|
|
20
|
+
interface LaunchContainerOptions {
|
|
21
|
+
imageId: string;
|
|
22
|
+
autoRemove: boolean;
|
|
23
|
+
command?: string[];
|
|
24
|
+
entrypoint?: string[];
|
|
25
|
+
labels?: Record<string, string>;
|
|
26
|
+
ports?: {
|
|
27
|
+
container: number;
|
|
28
|
+
host: number;
|
|
29
|
+
}[];
|
|
30
|
+
name?: string;
|
|
31
|
+
healthcheck?: {
|
|
32
|
+
test: string[];
|
|
33
|
+
intervalMs: number;
|
|
34
|
+
timeoutMs: number;
|
|
35
|
+
retries: number;
|
|
36
|
+
startPeriodMs: number;
|
|
37
|
+
};
|
|
38
|
+
binds?: {
|
|
39
|
+
hostPath: string;
|
|
40
|
+
containerPath: string;
|
|
41
|
+
}[];
|
|
42
|
+
workingDir?: string;
|
|
43
|
+
waitUntil?: 'not-running' | 'next-exit' | 'removed';
|
|
44
|
+
env?: {
|
|
45
|
+
[key: string]: string | number | boolean | undefined;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export declare function launchContainer(options: LaunchContainerOptions): Promise<string>;
|
|
49
|
+
interface StopContainerOptions {
|
|
50
|
+
containerId: string;
|
|
51
|
+
waitUntil?: 'not-running' | 'next-exit' | 'removed';
|
|
52
|
+
}
|
|
53
|
+
export declare function stopContainer(options: StopContainerOptions): Promise<void>;
|
|
54
|
+
export declare function removeContainer(containerId: string): Promise<void>;
|
|
55
|
+
export declare function getContainerLogs(containerId: string): Promise<string[]>;
|
|
56
|
+
interface CommitContainerOptions {
|
|
57
|
+
containerId: string;
|
|
58
|
+
repo: string;
|
|
59
|
+
tag: string;
|
|
60
|
+
entrypoint?: string[];
|
|
61
|
+
workingDir?: string;
|
|
62
|
+
env?: {
|
|
63
|
+
[key: string]: string | number | boolean | undefined;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export declare function commitContainer(options: CommitContainerOptions): Promise<void>;
|
|
67
|
+
export declare function inspectImage(imageId: string): Promise<any>;
|
|
68
|
+
export declare function listImages(): Promise<DockerImage[]>;
|
|
69
|
+
export declare function removeImage(imageId: string): Promise<void>;
|
|
70
|
+
export declare function checkEngineRunning(): Promise<boolean>;
|
|
71
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { WebSocket } from 'ws';
|
|
2
|
+
import * as dockerApi from './dockerApi.js';
|
|
3
|
+
export declare class GenericService {
|
|
4
|
+
private _containerId;
|
|
5
|
+
private _bindings;
|
|
6
|
+
private _ws;
|
|
7
|
+
static start(options: {
|
|
8
|
+
imageName: string;
|
|
9
|
+
ports: number[];
|
|
10
|
+
healthcheck?: {
|
|
11
|
+
test: string[];
|
|
12
|
+
intervalMs: number;
|
|
13
|
+
retries: number;
|
|
14
|
+
startPeriodMs: number;
|
|
15
|
+
timeoutMs: number;
|
|
16
|
+
};
|
|
17
|
+
command?: string[];
|
|
18
|
+
env?: {
|
|
19
|
+
[key: string]: string | number | boolean | undefined;
|
|
20
|
+
};
|
|
21
|
+
}): Promise<GenericService>;
|
|
22
|
+
constructor(_containerId: string, _bindings: dockerApi.PortBinding[], _ws: WebSocket);
|
|
23
|
+
mappedPort(containerPort: number): number | undefined;
|
|
24
|
+
stop(): Promise<void>;
|
|
25
|
+
}
|
package/types/index.d.ts
ADDED
package/types/minio.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { GenericService } from './genericService.js';
|
|
2
|
+
export declare class Minio {
|
|
3
|
+
private _service;
|
|
4
|
+
private _accessKeyId;
|
|
5
|
+
private _secretAccessKey;
|
|
6
|
+
static start({ accessKeyId, secretAccessKey }?: {
|
|
7
|
+
accessKeyId?: string | undefined;
|
|
8
|
+
secretAccessKey?: string | undefined;
|
|
9
|
+
}): Promise<Minio>;
|
|
10
|
+
constructor(_service: GenericService, _accessKeyId: string, _secretAccessKey: string);
|
|
11
|
+
accessKeyId(): string;
|
|
12
|
+
secretAccessKey(): string;
|
|
13
|
+
apiEndpoint(): string;
|
|
14
|
+
webuiEndpoint(): string;
|
|
15
|
+
stop(): Promise<void>;
|
|
16
|
+
}
|
package/types/mysql.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { GenericService } from './genericService.js';
|
|
2
|
+
export declare class MySQL {
|
|
3
|
+
private _service;
|
|
4
|
+
private _rootPassword;
|
|
5
|
+
private _db;
|
|
6
|
+
static start({ db, rootPassword }?: {
|
|
7
|
+
db?: string | undefined;
|
|
8
|
+
rootPassword?: string | undefined;
|
|
9
|
+
}): Promise<MySQL>;
|
|
10
|
+
constructor(_service: GenericService, _rootPassword: string, _db: string);
|
|
11
|
+
databaseUrl(): string;
|
|
12
|
+
connectOptions(): {
|
|
13
|
+
host: string;
|
|
14
|
+
port: number;
|
|
15
|
+
database: string;
|
|
16
|
+
user: string;
|
|
17
|
+
password: string;
|
|
18
|
+
};
|
|
19
|
+
stop(): Promise<void>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { GenericService } from './genericService.js';
|
|
2
|
+
export declare class Postgres {
|
|
3
|
+
private _service;
|
|
4
|
+
private _user;
|
|
5
|
+
private _password;
|
|
6
|
+
private _db;
|
|
7
|
+
static start({ user, password, db }?: {
|
|
8
|
+
user?: string | undefined;
|
|
9
|
+
password?: string | undefined;
|
|
10
|
+
db?: string | undefined;
|
|
11
|
+
}): Promise<Postgres>;
|
|
12
|
+
constructor(_service: GenericService, _user: string, _password: string, _db: string);
|
|
13
|
+
databaseUrl(): string;
|
|
14
|
+
connectOptions(): {
|
|
15
|
+
host: string;
|
|
16
|
+
port: number;
|
|
17
|
+
database: string;
|
|
18
|
+
user: string;
|
|
19
|
+
password: string;
|
|
20
|
+
};
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
}
|