@docker-harpoon/core 0.1.5 → 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/dist/api/promise.d.ts.map +1 -1
- package/dist/api/promise.js +26 -104
- package/dist/docker-resolver.d.ts +36 -0
- package/dist/docker-resolver.d.ts.map +1 -0
- package/dist/docker-resolver.js +214 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/services/DockerClient.d.ts.map +1 -1
- package/dist/services/DockerClient.js +15 -29
- package/dist/services/HarpoonConfig.d.ts +2 -2
- package/dist/services/HarpoonConfig.js +1 -1
- package/package.json +1 -1
- package/dist/__tests__/bindings.test.d.ts +0 -10
- package/dist/__tests__/bindings.test.d.ts.map +0 -1
- package/dist/__tests__/bindings.test.js +0 -128
- package/dist/__tests__/container.test.d.ts +0 -12
- package/dist/__tests__/container.test.d.ts.map +0 -1
- package/dist/__tests__/container.test.js +0 -136
- package/dist/__tests__/database.test.d.ts +0 -10
- package/dist/__tests__/database.test.d.ts.map +0 -1
- package/dist/__tests__/database.test.js +0 -78
- package/dist/__tests__/docker-infra.template.d.ts +0 -2
- package/dist/__tests__/docker-infra.template.d.ts.map +0 -1
- package/dist/__tests__/docker-infra.template.js +0 -174
- package/dist/__tests__/network.test.d.ts +0 -10
- package/dist/__tests__/network.test.d.ts.map +0 -1
- package/dist/__tests__/network.test.js +0 -44
- package/dist/__tests__/test-setup.d.ts +0 -9
- package/dist/__tests__/test-setup.d.ts.map +0 -1
- package/dist/__tests__/test-setup.js +0 -27
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"promise.d.ts","sourceRoot":"","sources":["../../src/api/promise.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;
|
|
1
|
+
{"version":3,"file":"promise.d.ts","sourceRoot":"","sources":["../../src/api/promise.ts"],"names":[],"mappings":"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"}
|
package/dist/api/promise.js
CHANGED
|
@@ -7,44 +7,15 @@
|
|
|
7
7
|
* Inspired by Harpoon and Pulumi's IAC patterns.
|
|
8
8
|
*/
|
|
9
9
|
import { Effect, Scope, Exit } from 'effect';
|
|
10
|
-
import { homedir, platform } from 'os';
|
|
11
|
-
import { existsSync } from 'fs';
|
|
12
|
-
import { spawn } from 'child_process';
|
|
13
|
-
import Docker from 'dockerode';
|
|
14
10
|
// Import Effect-based resources (internal only)
|
|
15
11
|
import { Network as EffectNetwork, Container as EffectContainer, Image as EffectImage, } from '../resources';
|
|
16
12
|
import { database as effectDatabase, } from '../helpers';
|
|
13
|
+
import { createDockerClient, DockerResolutionError, formatDockerConnectionFailure, resolveDockerConnection, } from '../docker-resolver';
|
|
17
14
|
// Binding type is used as-is from internal types
|
|
18
15
|
// Pre-built bindings like PrismaBinding handle Effect internally
|
|
19
16
|
// Users creating simple bindings can use createEnvBinding()
|
|
20
17
|
// ============ Docker Client Management ============
|
|
21
18
|
let dockerClient = null;
|
|
22
|
-
/**
|
|
23
|
-
* Detect Docker socket path for the current platform.
|
|
24
|
-
*/
|
|
25
|
-
function detectDockerSocket() {
|
|
26
|
-
const candidates = ['/var/run/docker.sock', `${homedir()}/.docker/run/docker.sock`];
|
|
27
|
-
for (const path of candidates) {
|
|
28
|
-
if (existsSync(path)) {
|
|
29
|
-
return path;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
// Windows uses named pipes, let dockerode handle it
|
|
33
|
-
return '/var/run/docker.sock';
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* Get the Docker client, auto-detecting the socket if needed.
|
|
37
|
-
*/
|
|
38
|
-
function getDocker() {
|
|
39
|
-
if (!dockerClient) {
|
|
40
|
-
const socketPath = detectDockerSocket();
|
|
41
|
-
dockerClient = new Docker({ socketPath });
|
|
42
|
-
}
|
|
43
|
-
return dockerClient;
|
|
44
|
-
}
|
|
45
|
-
// ============ Docker Auto-Start (Harpoon Style) ============
|
|
46
|
-
const DOCKER_STARTUP_TIMEOUT_MS = 60_000;
|
|
47
|
-
let dockerEnsured = false;
|
|
48
19
|
/**
|
|
49
20
|
* Simple logging helper for promise-based API.
|
|
50
21
|
*/
|
|
@@ -59,87 +30,38 @@ const harpoonLog = {
|
|
|
59
30
|
error: (message, err) => console.error(`[Harpoon] ${message}`, err ?? ''),
|
|
60
31
|
};
|
|
61
32
|
/**
|
|
62
|
-
*
|
|
33
|
+
* Get Docker client with shared endpoint discovery.
|
|
63
34
|
*/
|
|
64
|
-
function
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
harpoonLog.info('Starting Docker Desktop...');
|
|
68
|
-
const child = spawn('open', ['-a', 'Docker', '--background'], {
|
|
69
|
-
detached: true,
|
|
70
|
-
stdio: 'ignore',
|
|
71
|
-
});
|
|
72
|
-
child.unref();
|
|
35
|
+
async function getDockerAsync() {
|
|
36
|
+
if (dockerClient) {
|
|
37
|
+
return dockerClient;
|
|
73
38
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
detached: true,
|
|
78
|
-
stdio: 'ignore',
|
|
79
|
-
});
|
|
80
|
-
child.unref();
|
|
39
|
+
let resolution;
|
|
40
|
+
try {
|
|
41
|
+
resolution = resolveDockerConnection();
|
|
81
42
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
if (existsSync(socketPath)) {
|
|
93
|
-
const docker = new Docker({ socketPath });
|
|
94
|
-
await docker.ping();
|
|
95
|
-
harpoonLog.info('Docker is ready');
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error instanceof DockerResolutionError) {
|
|
45
|
+
const lines = [
|
|
46
|
+
'Failed to resolve Docker endpoint.',
|
|
47
|
+
error.message,
|
|
48
|
+
'Harpoon will not launch Docker (desktop, engine, or other runtimes) automatically. Start your configured Docker runtime, or set DOCKER_SOCKET, DOCKER_HOST, or DOCKER_CONTEXT to a local Docker endpoint.',
|
|
49
|
+
'You can also inject a Docker client with setDocker().',
|
|
50
|
+
];
|
|
51
|
+
throw new Error(lines.join('\n'));
|
|
98
52
|
}
|
|
99
|
-
|
|
100
|
-
lastError = e;
|
|
101
|
-
}
|
|
102
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
53
|
+
throw error;
|
|
103
54
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
* Ensure Docker is running, auto-starting if needed.
|
|
108
|
-
* This is the Harpoon-style declarative approach.
|
|
109
|
-
*/
|
|
110
|
-
async function ensureDockerRunning() {
|
|
111
|
-
if (dockerEnsured)
|
|
112
|
-
return;
|
|
113
|
-
const socketPath = detectDockerSocket();
|
|
114
|
-
// Check if already running
|
|
115
|
-
if (existsSync(socketPath)) {
|
|
116
|
-
try {
|
|
117
|
-
const docker = new Docker({ socketPath });
|
|
118
|
-
await docker.ping();
|
|
119
|
-
dockerEnsured = true;
|
|
120
|
-
return; // Docker is ready
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
// Socket exists but daemon not responding, wait for it
|
|
124
|
-
harpoonLog.debug('Docker socket exists but daemon not responding, waiting...');
|
|
125
|
-
}
|
|
55
|
+
const docker = createDockerClient(resolution);
|
|
56
|
+
try {
|
|
57
|
+
await docker.ping();
|
|
126
58
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
harpoonLog.info('Docker not running, attempting auto-start...');
|
|
130
|
-
startDockerDesktop();
|
|
59
|
+
catch (error) {
|
|
60
|
+
throw new Error(formatDockerConnectionFailure(resolution, error));
|
|
131
61
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
/**
|
|
137
|
-
* Get Docker client with auto-start support.
|
|
138
|
-
* Ensures Docker is running before returning client.
|
|
139
|
-
*/
|
|
140
|
-
async function getDockerAsync() {
|
|
141
|
-
await ensureDockerRunning();
|
|
142
|
-
return getDocker();
|
|
62
|
+
harpoonLog.debug(`Connected to Docker daemon via ${resolution.sourceDescription}`);
|
|
63
|
+
dockerClient = docker;
|
|
64
|
+
return dockerClient;
|
|
143
65
|
}
|
|
144
66
|
/**
|
|
145
67
|
* Set a custom Docker client.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import Docker from 'dockerode';
|
|
2
|
+
interface DockerResolutionFileSystem {
|
|
3
|
+
existsSync: (filePath: string) => boolean;
|
|
4
|
+
readFileSync: (filePath: string) => string;
|
|
5
|
+
readdirSync: (directoryPath: string) => string[];
|
|
6
|
+
}
|
|
7
|
+
export interface ResolveDockerConnectionOptions {
|
|
8
|
+
readonly explicitSocketPath?: string;
|
|
9
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
10
|
+
readonly homeDir?: string;
|
|
11
|
+
readonly platform?: NodeJS.Platform;
|
|
12
|
+
readonly fs?: DockerResolutionFileSystem;
|
|
13
|
+
}
|
|
14
|
+
export interface DockerConnectionResolution {
|
|
15
|
+
readonly dockerOptions: Docker.DockerOptions;
|
|
16
|
+
readonly endpoint: string;
|
|
17
|
+
readonly socketPath: string;
|
|
18
|
+
readonly source: string;
|
|
19
|
+
readonly sourceDescription: string;
|
|
20
|
+
readonly triedEndpoints: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
export declare class DockerResolutionError extends Error {
|
|
23
|
+
readonly source: string;
|
|
24
|
+
readonly triedEndpoints: readonly string[];
|
|
25
|
+
readonly cause: Error | undefined;
|
|
26
|
+
constructor(message: string, options: {
|
|
27
|
+
source: string;
|
|
28
|
+
triedEndpoints?: readonly string[];
|
|
29
|
+
cause?: unknown;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export declare function resolveDockerConnection(options?: ResolveDockerConnectionOptions): DockerConnectionResolution;
|
|
33
|
+
export declare function createDockerClient(options?: ResolveDockerConnectionOptions | DockerConnectionResolution): Docker;
|
|
34
|
+
export declare function formatDockerConnectionFailure(resolution: DockerConnectionResolution, cause: unknown, injectedClientHint?: string): string;
|
|
35
|
+
export {};
|
|
36
|
+
//# sourceMappingURL=docker-resolver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"docker-resolver.d.ts","sourceRoot":"","sources":["../src/docker-resolver.ts"],"names":[],"mappings":"AAGA,OAAO,MAAM,MAAM,WAAW,CAAC;AAS/B,UAAU,0BAA0B;IAClC,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;IAC1C,YAAY,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC;IAC3C,WAAW,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,MAAM,EAAE,CAAC;CAClD;AAQD,MAAM,WAAW,8BAA8B;IAC7C,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACrC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACjC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IACpC,QAAQ,CAAC,EAAE,CAAC,EAAE,0BAA0B,CAAC;CAC1C;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC,aAAa,CAAC;IAC7C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC;CAC5C;AAED,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC;IAE3C,SAAkB,KAAK,EAAE,KAAK,GAAG,SAAS,CAAC;IAE3C,YACE,OAAO,EAAE,MAAM,EACf,OAAO,EAAE;QACP,MAAM,EAAE,MAAM,CAAC;QACf,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;QACnC,KAAK,CAAC,EAAE,OAAO,CAAC;KACjB,EAWF;CACF;AAyND,wBAAgB,uBAAuB,CACrC,OAAO,GAAE,8BAAmC,GAC3C,0BAA0B,CAqE5B;AAED,wBAAgB,kBAAkB,CAChC,OAAO,GAAE,8BAA8B,GAAG,0BAA+B,GACxE,MAAM,CAIR;AAED,wBAAgB,6BAA6B,CAC3C,UAAU,EAAE,0BAA0B,EACtC,KAAK,EAAE,OAAO,EACd,kBAAkB,SAAgB,GACjC,MAAM,CAiBR"}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
2
|
+
import { homedir, platform as osPlatform } from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import Docker from 'dockerode';
|
|
5
|
+
const WINDOWS_DOCKER_PIPE = '//./pipe/docker_engine';
|
|
6
|
+
// Docker's built-in "default" context has no on-disk metadata in
|
|
7
|
+
// ~/.docker/contexts/meta/ and must be treated the same as having no context
|
|
8
|
+
// configured, falling through to the local socket candidate search.
|
|
9
|
+
const DOCKER_DEFAULT_CONTEXT = 'default';
|
|
10
|
+
const defaultFileSystem = {
|
|
11
|
+
existsSync,
|
|
12
|
+
readFileSync: (filePath) => readFileSync(filePath, 'utf-8'),
|
|
13
|
+
readdirSync: (directoryPath) => readdirSync(directoryPath),
|
|
14
|
+
};
|
|
15
|
+
export class DockerResolutionError extends Error {
|
|
16
|
+
source;
|
|
17
|
+
triedEndpoints;
|
|
18
|
+
cause;
|
|
19
|
+
constructor(message, options) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'DockerResolutionError';
|
|
22
|
+
this.source = options.source;
|
|
23
|
+
this.triedEndpoints = options.triedEndpoints ?? [];
|
|
24
|
+
this.cause = asError(options.cause);
|
|
25
|
+
if (Error.captureStackTrace) {
|
|
26
|
+
Error.captureStackTrace(this, DockerResolutionError);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const asError = (value) => {
|
|
31
|
+
if (value === undefined) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
35
|
+
};
|
|
36
|
+
const buildResolutionError = (message, source, triedEndpoints = [], cause) => new DockerResolutionError(message, {
|
|
37
|
+
source,
|
|
38
|
+
triedEndpoints,
|
|
39
|
+
cause,
|
|
40
|
+
});
|
|
41
|
+
const getLocalDockerSocketCandidates = (homeDirectory, platform) => {
|
|
42
|
+
if (platform === 'win32') {
|
|
43
|
+
return [WINDOWS_DOCKER_PIPE];
|
|
44
|
+
}
|
|
45
|
+
const candidates = ['/var/run/docker.sock'];
|
|
46
|
+
if (homeDirectory) {
|
|
47
|
+
candidates.push(`${homeDirectory}/.docker/run/docker.sock`, `${homeDirectory}/.docker/desktop/docker.sock`, `${homeDirectory}/.rd/docker.sock`, `${homeDirectory}/.colima/default/docker.sock`, `${homeDirectory}/.orbstack/run/docker.sock`);
|
|
48
|
+
}
|
|
49
|
+
return candidates;
|
|
50
|
+
};
|
|
51
|
+
const normalizePipePath = (value) => {
|
|
52
|
+
const withForwardSlashes = value.replace(/\\/g, '/');
|
|
53
|
+
if (withForwardSlashes.startsWith('//./pipe/')) {
|
|
54
|
+
return withForwardSlashes;
|
|
55
|
+
}
|
|
56
|
+
const trimmed = withForwardSlashes.replace(/^\/+/, '');
|
|
57
|
+
if (trimmed.startsWith('./pipe/')) {
|
|
58
|
+
return `//${trimmed}`;
|
|
59
|
+
}
|
|
60
|
+
if (trimmed.startsWith('pipe/')) {
|
|
61
|
+
return `//./${trimmed}`;
|
|
62
|
+
}
|
|
63
|
+
return withForwardSlashes;
|
|
64
|
+
};
|
|
65
|
+
const normalizeDockerHost = (value, sourceDescription) => {
|
|
66
|
+
if (value.startsWith('unix://')) {
|
|
67
|
+
const encoded = value.slice('unix://'.length);
|
|
68
|
+
try {
|
|
69
|
+
return decodeURIComponent(encoded);
|
|
70
|
+
}
|
|
71
|
+
catch (cause) {
|
|
72
|
+
throw buildResolutionError(`Malformed percent-encoding in ${sourceDescription} value "${value}". The path portion "${encoded}" is not valid URI-encoded text.`, sourceDescription, [value], cause);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (value.startsWith('npipe://')) {
|
|
76
|
+
return normalizePipePath(value.slice('npipe://'.length));
|
|
77
|
+
}
|
|
78
|
+
const hasUriScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value);
|
|
79
|
+
if (hasUriScheme) {
|
|
80
|
+
throw buildResolutionError(`Unsupported ${sourceDescription} value "${value}". Harpoon currently supports only local Docker endpoints via raw socket paths, unix://, or npipe://.`, sourceDescription, [value]);
|
|
81
|
+
}
|
|
82
|
+
if (value.startsWith('\\\\.\\pipe\\') || value.startsWith('//./pipe/')) {
|
|
83
|
+
return normalizePipePath(value);
|
|
84
|
+
}
|
|
85
|
+
return value;
|
|
86
|
+
};
|
|
87
|
+
const makeResolution = (socketPath, endpoint, source, sourceDescription, triedEndpoints) => ({
|
|
88
|
+
dockerOptions: { socketPath },
|
|
89
|
+
endpoint,
|
|
90
|
+
socketPath,
|
|
91
|
+
source,
|
|
92
|
+
sourceDescription,
|
|
93
|
+
triedEndpoints,
|
|
94
|
+
});
|
|
95
|
+
const resolveDirectSocket = (value, source, sourceDescription) => {
|
|
96
|
+
const socketPath = normalizeDockerHost(value, sourceDescription);
|
|
97
|
+
return makeResolution(socketPath, value, source, sourceDescription, [socketPath]);
|
|
98
|
+
};
|
|
99
|
+
const readJson = (filePath, fileSystem) => {
|
|
100
|
+
if (!fileSystem.existsSync(filePath)) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(fileSystem.readFileSync(filePath));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const readCurrentDockerContext = (homeDirectory, fileSystem) => {
|
|
111
|
+
if (!homeDirectory) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
const configPath = path.join(homeDirectory, '.docker', 'config.json');
|
|
115
|
+
const config = readJson(configPath, fileSystem);
|
|
116
|
+
if (!config) {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
const currentContext = config['currentContext'];
|
|
120
|
+
return typeof currentContext === 'string' &&
|
|
121
|
+
currentContext.length > 0 &&
|
|
122
|
+
currentContext !== DOCKER_DEFAULT_CONTEXT
|
|
123
|
+
? currentContext
|
|
124
|
+
: undefined;
|
|
125
|
+
};
|
|
126
|
+
const resolveDockerContext = (contextName, source, sourceDescription, homeDirectory, fileSystem) => {
|
|
127
|
+
if (!homeDirectory) {
|
|
128
|
+
throw buildResolutionError(`Unable to resolve ${sourceDescription}. No home directory is available for Docker context lookup.`, source);
|
|
129
|
+
}
|
|
130
|
+
const contextsDirectory = path.join(homeDirectory, '.docker', 'contexts', 'meta');
|
|
131
|
+
let entries;
|
|
132
|
+
try {
|
|
133
|
+
entries = fileSystem.readdirSync(contextsDirectory);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
throw buildResolutionError(`Unable to resolve ${sourceDescription}. Docker context metadata was not found at ${contextsDirectory}.`, source, [contextsDirectory], error);
|
|
137
|
+
}
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
const metaPath = path.join(contextsDirectory, entry, 'meta.json');
|
|
140
|
+
const meta = readJson(metaPath, fileSystem);
|
|
141
|
+
if (!meta || meta['Name'] !== contextName) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const endpoints = meta['Endpoints'];
|
|
145
|
+
const dockerEndpoint = endpoints && typeof endpoints === 'object'
|
|
146
|
+
? endpoints['docker']
|
|
147
|
+
: undefined;
|
|
148
|
+
const host = dockerEndpoint && typeof dockerEndpoint === 'object'
|
|
149
|
+
? dockerEndpoint['Host']
|
|
150
|
+
: undefined;
|
|
151
|
+
if (typeof host !== 'string' || host.length === 0) {
|
|
152
|
+
throw buildResolutionError(`Docker context "${contextName}" does not define a Docker host endpoint.`, source, [metaPath]);
|
|
153
|
+
}
|
|
154
|
+
const socketPath = normalizeDockerHost(host, sourceDescription);
|
|
155
|
+
return makeResolution(socketPath, host, source, sourceDescription, [socketPath]);
|
|
156
|
+
}
|
|
157
|
+
throw buildResolutionError(`Docker context "${contextName}" was not found in ${contextsDirectory}.`, source, [contextsDirectory]);
|
|
158
|
+
};
|
|
159
|
+
export function resolveDockerConnection(options = {}) {
|
|
160
|
+
const fileSystem = options.fs ?? defaultFileSystem;
|
|
161
|
+
const env = options.env ?? process.env;
|
|
162
|
+
const homeDirectory = options.homeDir ?? homedir();
|
|
163
|
+
const platform = options.platform ?? osPlatform();
|
|
164
|
+
if (options.explicitSocketPath) {
|
|
165
|
+
return resolveDirectSocket(options.explicitSocketPath, 'explicitSocketPath', 'explicit socket override');
|
|
166
|
+
}
|
|
167
|
+
const dockerSocket = env.DOCKER_SOCKET;
|
|
168
|
+
if (dockerSocket) {
|
|
169
|
+
return resolveDirectSocket(dockerSocket, 'DOCKER_SOCKET', 'DOCKER_SOCKET');
|
|
170
|
+
}
|
|
171
|
+
const dockerHost = env.DOCKER_HOST;
|
|
172
|
+
if (dockerHost) {
|
|
173
|
+
return resolveDirectSocket(dockerHost, 'DOCKER_HOST', 'DOCKER_HOST');
|
|
174
|
+
}
|
|
175
|
+
const dockerContext = env.DOCKER_CONTEXT;
|
|
176
|
+
if (dockerContext && dockerContext !== DOCKER_DEFAULT_CONTEXT) {
|
|
177
|
+
return resolveDockerContext(dockerContext, 'DOCKER_CONTEXT', `DOCKER_CONTEXT=${dockerContext}`, homeDirectory, fileSystem);
|
|
178
|
+
}
|
|
179
|
+
const currentContext = readCurrentDockerContext(homeDirectory, fileSystem);
|
|
180
|
+
if (currentContext) {
|
|
181
|
+
return resolveDockerContext(currentContext, 'currentContext', `Docker currentContext=${currentContext}`, homeDirectory, fileSystem);
|
|
182
|
+
}
|
|
183
|
+
const candidates = getLocalDockerSocketCandidates(homeDirectory, platform);
|
|
184
|
+
let resolvedSocketPath;
|
|
185
|
+
const triedEndpoints = [];
|
|
186
|
+
for (const candidatePath of candidates) {
|
|
187
|
+
triedEndpoints.push(candidatePath);
|
|
188
|
+
if (fileSystem.existsSync(candidatePath)) {
|
|
189
|
+
resolvedSocketPath = candidatePath;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (resolvedSocketPath === undefined) {
|
|
194
|
+
resolvedSocketPath = candidates[0];
|
|
195
|
+
}
|
|
196
|
+
return makeResolution(resolvedSocketPath, resolvedSocketPath, 'fallback', 'fallback socket search', triedEndpoints);
|
|
197
|
+
}
|
|
198
|
+
export function createDockerClient(options = {}) {
|
|
199
|
+
const resolution = 'dockerOptions' in options ? options : resolveDockerConnection(options);
|
|
200
|
+
return new Docker(resolution.dockerOptions);
|
|
201
|
+
}
|
|
202
|
+
export function formatDockerConnectionFailure(resolution, cause, injectedClientHint = 'setDocker()') {
|
|
203
|
+
const lines = ['Failed to connect to Docker daemon.'];
|
|
204
|
+
lines.push(`Source: ${resolution.sourceDescription}`);
|
|
205
|
+
lines.push(`Endpoint: ${resolution.endpoint}`);
|
|
206
|
+
lines.push(`Tried: ${resolution.triedEndpoints.join(', ')}`);
|
|
207
|
+
const error = asError(cause);
|
|
208
|
+
if (error?.message) {
|
|
209
|
+
lines.push(`Cause: ${error.message}`);
|
|
210
|
+
}
|
|
211
|
+
lines.push('Harpoon will not launch Docker (desktop, engine, or other runtimes) automatically. Start your configured Docker runtime, or set DOCKER_SOCKET, DOCKER_HOST, or DOCKER_CONTEXT to a local Docker endpoint.');
|
|
212
|
+
lines.push(`You can also inject a Docker client with ${injectedClientHint}.`);
|
|
213
|
+
return lines.join('\n');
|
|
214
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* - BuildBinding interface for Dockerfile transformations
|
|
29
29
|
*/
|
|
30
30
|
export { Network, Container, database, Image, setDocker, resetDocker, destroyAll, type NetworkConfig, type NetworkResource, type ContainerConfig, type ContainerResource, type PortMapping, type ShutdownMetadata, type DatabaseConfig, type DatabaseResource, type ImageConfig, type ImageResource, type ContainerStats, type ExecOptions, type ExecResult, type LogOptions, type LogLine, type VolumeMapping, } from './api';
|
|
31
|
+
export { createDockerClient, resolveDockerConnection, formatDockerConnectionFailure, DockerResolutionError, type ResolveDockerConnectionOptions, type DockerConnectionResolution, } from './docker-resolver';
|
|
31
32
|
export type { Binding, BuildBinding, BindingsEnv, FlatBindingsEnv } from './bindings';
|
|
32
33
|
export { isBuildBinding, mergeBindingsEnv, createEnvBinding } from './bindings';
|
|
33
34
|
export { HarpoonError, NetworkError, ImageError, ContainerError, ScopeError, BindingError, toHarpoonError, asCause, isHarpoonError, isNetworkError, isImageError, isContainerError, isScopeError, isBindingError, } from './errors';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAIH,OAAO,EAEL,OAAO,EACP,SAAS,EACT,QAAQ,EACR,KAAK,EAGL,SAAS,EACT,WAAW,EACX,UAAU,EAGV,KAAK,aAAa,EAClB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACtB,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,gBAAgB,EACrB,KAAK,WAAW,EAChB,KAAK,aAAa,EAGlB,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,OAAO,EACZ,KAAK,aAAa,GACnB,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAIH,OAAO,EAEL,OAAO,EACP,SAAS,EACT,QAAQ,EACR,KAAK,EAGL,SAAS,EACT,WAAW,EACX,UAAU,EAGV,KAAK,aAAa,EAClB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACtB,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,gBAAgB,EACrB,KAAK,WAAW,EAChB,KAAK,aAAa,EAGlB,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,OAAO,EACZ,KAAK,aAAa,GACnB,MAAM,OAAO,CAAC;AAEf,OAAO,EACL,kBAAkB,EAClB,uBAAuB,EACvB,6BAA6B,EAC7B,qBAAqB,EACrB,KAAK,8BAA8B,EACnC,KAAK,0BAA0B,GAChC,MAAM,mBAAmB,CAAC;AAI3B,YAAY,EAAE,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAEtF,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAIhF,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,cAAc,EACd,UAAU,EACV,YAAY,EACZ,cAAc,EACd,OAAO,EACP,cAAc,EACd,cAAc,EACd,YAAY,EACZ,gBAAgB,EAChB,YAAY,EACZ,cAAc,GACf,MAAM,UAAU,CAAC;AAElB,YAAY,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAIhD,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,gBAAgB,EAChB,mBAAmB,EACnB,gBAAgB,EAChB,kBAAkB,GACnB,MAAM,oBAAoB,CAAC;AAE5B,YAAY,EACV,aAAa,EACb,kBAAkB,EAClB,YAAY,EACZ,qBAAqB,GACtB,MAAM,oBAAoB,CAAC;AAI5B,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,gBAAgB,EAChB,kBAAkB,EAClB,kBAAkB,EAClB,gBAAgB,GACjB,MAAM,mBAAmB,CAAC;AAE3B,YAAY,EACV,aAAa,EACb,YAAY,EACZ,WAAW,EACX,qBAAqB,EACrB,YAAY,GACb,MAAM,mBAAmB,CAAC;AAI3B,OAAO,EACL,mBAAmB,EACnB,mBAAmB,EACnB,cAAc,EACd,gBAAgB,EAChB,sBAAsB,EACtB,qBAAqB,EACrB,8BAA8B,EAC9B,eAAe,EACf,qBAAqB,EACrB,mBAAmB,EACnB,cAAc,EACd,mBAAmB,EACnB,kBAAkB,EAClB,gBAAgB,EAChB,WAAW,EACX,MAAM,EACN,gBAAgB,GACjB,MAAM,2BAA2B,CAAC;AAEnC,YAAY,EACV,sBAAsB,EACtB,qBAAqB,EACrB,gBAAgB,EAChB,mBAAmB,EACnB,iBAAiB,EACjB,eAAe,GAChB,MAAM,2BAA2B,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -33,6 +33,7 @@ export {
|
|
|
33
33
|
Network, Container, database, Image,
|
|
34
34
|
// Docker client management
|
|
35
35
|
setDocker, resetDocker, destroyAll, } from './api';
|
|
36
|
+
export { createDockerClient, resolveDockerConnection, formatDockerConnectionFailure, DockerResolutionError, } from './docker-resolver';
|
|
36
37
|
export { isBuildBinding, mergeBindingsEnv, createEnvBinding } from './bindings';
|
|
37
38
|
// ============ Errors ============
|
|
38
39
|
export { HarpoonError, NetworkError, ImageError, ContainerError, ScopeError, BindingError, toHarpoonError, asCause, isHarpoonError, isNetworkError, isImageError, isContainerError, isScopeError, isBindingError, } from './errors';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DockerClient.d.ts","sourceRoot":"","sources":["../../src/services/DockerClient.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAS,MAAM,QAAQ,CAAC;AACvD,OAAO,MAAM,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"DockerClient.d.ts","sourceRoot":"","sources":["../../src/services/DockerClient.ts"],"names":[],"mappings":"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"}
|
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { Context, Effect, Layer } from 'effect';
|
|
8
8
|
import Docker from 'dockerode';
|
|
9
|
-
import {
|
|
10
|
-
import { homedir, platform } from 'os';
|
|
9
|
+
import { formatDockerConnectionFailure, resolveDockerConnection } from '../docker-resolver';
|
|
11
10
|
import { HarpoonConfig } from './HarpoonConfig';
|
|
12
11
|
import { DockerConnectionError, wrapAsDockerError, isTransientError, } from './DockerErrors';
|
|
13
12
|
import { DockerRateLimiter, DockerRateLimiterLive, dockerRetrySchedule } from './DockerRateLimiter';
|
|
@@ -16,28 +15,6 @@ import { DockerRateLimiter, DockerRateLimiterLive, dockerRetrySchedule } from '.
|
|
|
16
15
|
*/
|
|
17
16
|
export class DockerClient extends Context.Tag('@harpoon/DockerClient')() {
|
|
18
17
|
}
|
|
19
|
-
/**
|
|
20
|
-
* Detect Docker socket path for the current platform.
|
|
21
|
-
*/
|
|
22
|
-
function detectDockerSocket() {
|
|
23
|
-
const candidates = [
|
|
24
|
-
'/var/run/docker.sock',
|
|
25
|
-
`${homedir()}/.docker/run/docker.sock`,
|
|
26
|
-
// Colima on macOS
|
|
27
|
-
`${homedir()}/.colima/default/docker.sock`,
|
|
28
|
-
];
|
|
29
|
-
for (const path of candidates) {
|
|
30
|
-
if (existsSync(path)) {
|
|
31
|
-
return path;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
// Windows uses named pipes, let dockerode handle it
|
|
35
|
-
if (platform() === 'win32') {
|
|
36
|
-
return '//./pipe/docker_engine';
|
|
37
|
-
}
|
|
38
|
-
// Default fallback
|
|
39
|
-
return '/var/run/docker.sock';
|
|
40
|
-
}
|
|
41
18
|
/**
|
|
42
19
|
* Helper to add retry logic for transient errors.
|
|
43
20
|
*/
|
|
@@ -58,14 +35,23 @@ const withRetryOnTransient = (effect) => effect.pipe(Effect.retry({
|
|
|
58
35
|
export const DockerClientLive = Layer.scoped(DockerClient, Effect.gen(function* () {
|
|
59
36
|
const config = yield* HarpoonConfig;
|
|
60
37
|
const rateLimiter = yield* DockerRateLimiter;
|
|
61
|
-
|
|
62
|
-
|
|
38
|
+
const resolution = yield* Effect.try({
|
|
39
|
+
try: () => resolveDockerConnection({
|
|
40
|
+
...(config.dockerSocket !== undefined && {
|
|
41
|
+
explicitSocketPath: config.dockerSocket,
|
|
42
|
+
}),
|
|
43
|
+
}),
|
|
44
|
+
catch: (e) => new DockerConnectionError(e instanceof Error ? e.message : String(e), {
|
|
45
|
+
cause: e,
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
const socketPath = resolution.socketPath;
|
|
63
49
|
// Create Docker client
|
|
64
|
-
const docker = new Docker(
|
|
50
|
+
const docker = new Docker(resolution.dockerOptions);
|
|
65
51
|
// Verify connection on startup (with retry for transient errors)
|
|
66
52
|
yield* Effect.tryPromise({
|
|
67
53
|
try: () => docker.ping(),
|
|
68
|
-
catch: (e) => new DockerConnectionError(
|
|
54
|
+
catch: (e) => new DockerConnectionError(formatDockerConnectionFailure(resolution, e, 'a custom DockerClient layer'), {
|
|
69
55
|
socketPath,
|
|
70
56
|
cause: e,
|
|
71
57
|
}),
|
|
@@ -79,7 +65,7 @@ export const DockerClientLive = Layer.scoped(DockerClient, Effect.gen(function*
|
|
|
79
65
|
client: docker,
|
|
80
66
|
ping: () => rateLimiter.withPermit(Effect.tryPromise({
|
|
81
67
|
try: () => docker.ping(),
|
|
82
|
-
catch: (e) => new DockerConnectionError(
|
|
68
|
+
catch: (e) => new DockerConnectionError(formatDockerConnectionFailure(resolution, e, 'a custom DockerClient layer'), {
|
|
83
69
|
socketPath,
|
|
84
70
|
cause: e,
|
|
85
71
|
}),
|
|
@@ -24,7 +24,7 @@ export interface HarpoonConfigService {
|
|
|
24
24
|
readonly retryAttempts: number;
|
|
25
25
|
/** Delay between retry attempts */
|
|
26
26
|
readonly retryDelay: Duration.Duration;
|
|
27
|
-
/** Docker startup timeout
|
|
27
|
+
/** Legacy Docker startup timeout setting. Currently unused. */
|
|
28
28
|
readonly dockerStartupTimeout: Duration.Duration;
|
|
29
29
|
}
|
|
30
30
|
declare const HarpoonConfig_base: Context.TagClass<HarpoonConfig, "@harpoon/HarpoonConfig", HarpoonConfigService>;
|
|
@@ -44,7 +44,7 @@ export declare class HarpoonConfig extends HarpoonConfig_base {
|
|
|
44
44
|
* - HARPOON_PARALLEL_LIMIT: Max parallel operations (default: 4)
|
|
45
45
|
* - HARPOON_RETRY_ATTEMPTS: Retry attempts (default: 3)
|
|
46
46
|
* - HARPOON_RETRY_DELAY: Retry delay in milliseconds (default: 1000)
|
|
47
|
-
* - HARPOON_DOCKER_STARTUP_TIMEOUT: Docker startup timeout in milliseconds (default: 60000)
|
|
47
|
+
* - HARPOON_DOCKER_STARTUP_TIMEOUT: Legacy Docker startup timeout in milliseconds (default: 60000)
|
|
48
48
|
*/
|
|
49
49
|
export declare const HarpoonConfigLive: Layer.Layer<HarpoonConfig, import("effect/ConfigError").ConfigError, never>;
|
|
50
50
|
/**
|
|
@@ -21,7 +21,7 @@ export class HarpoonConfig extends Context.Tag('@harpoon/HarpoonConfig')() {
|
|
|
21
21
|
* - HARPOON_PARALLEL_LIMIT: Max parallel operations (default: 4)
|
|
22
22
|
* - HARPOON_RETRY_ATTEMPTS: Retry attempts (default: 3)
|
|
23
23
|
* - HARPOON_RETRY_DELAY: Retry delay in milliseconds (default: 1000)
|
|
24
|
-
* - HARPOON_DOCKER_STARTUP_TIMEOUT: Docker startup timeout in milliseconds (default: 60000)
|
|
24
|
+
* - HARPOON_DOCKER_STARTUP_TIMEOUT: Legacy Docker startup timeout in milliseconds (default: 60000)
|
|
25
25
|
*/
|
|
26
26
|
export const HarpoonConfigLive = Layer.effect(HarpoonConfig, Effect.gen(function* () {
|
|
27
27
|
// Load all configuration with defaults
|
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"bindings.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/bindings.test.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG"}
|
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bindings Integration Tests
|
|
3
|
-
*
|
|
4
|
-
* Dogfoods the binding system to verify:
|
|
5
|
-
* - createEnvBinding helper
|
|
6
|
-
* - Env var injection into containers
|
|
7
|
-
* - Binding composition
|
|
8
|
-
*/
|
|
9
|
-
import { describe, test, expect, afterEach, beforeAll, setDefaultTimeout } from 'bun:test';
|
|
10
|
-
import { Container, database, createEnvBinding, destroyAll } from '../index';
|
|
11
|
-
import { buildTestImage, TEST_IMAGE } from './test-setup';
|
|
12
|
-
setDefaultTimeout(30_000);
|
|
13
|
-
const waitUntil = (fn, timeout = 5000) => {
|
|
14
|
-
return new Promise((resolve) => {
|
|
15
|
-
const interval = setInterval(() => {
|
|
16
|
-
if (fn()) {
|
|
17
|
-
clearInterval(interval);
|
|
18
|
-
resolve(true);
|
|
19
|
-
}
|
|
20
|
-
}, 100);
|
|
21
|
-
});
|
|
22
|
-
};
|
|
23
|
-
describe('Bindings', () => {
|
|
24
|
-
beforeAll(async () => {
|
|
25
|
-
await buildTestImage();
|
|
26
|
-
});
|
|
27
|
-
afterEach(async () => {
|
|
28
|
-
await destroyAll();
|
|
29
|
-
});
|
|
30
|
-
test('createEnvBinding creates a simple env binding', () => {
|
|
31
|
-
const binding = createEnvBinding('config', {
|
|
32
|
-
NODE_ENV: 'test',
|
|
33
|
-
LOG_LEVEL: 'debug',
|
|
34
|
-
});
|
|
35
|
-
expect(binding.type).toBe('config');
|
|
36
|
-
expect(binding.getEnv()).toEqual({
|
|
37
|
-
NODE_ENV: 'test',
|
|
38
|
-
LOG_LEVEL: 'debug',
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
test('container receives env vars from bindings', async () => {
|
|
42
|
-
const configBinding = createEnvBinding('config', {
|
|
43
|
-
APP_NAME: 'test-app',
|
|
44
|
-
APP_VERSION: '1.0.0',
|
|
45
|
-
});
|
|
46
|
-
const container = await Container('test-binding-env', {
|
|
47
|
-
image: TEST_IMAGE,
|
|
48
|
-
cmd: ['sleep', '30'],
|
|
49
|
-
bindings: {
|
|
50
|
-
config: configBinding,
|
|
51
|
-
},
|
|
52
|
-
});
|
|
53
|
-
await waitUntil(() => !!container.getIp('test-binding-env'), 5000);
|
|
54
|
-
const result = await container.exec([
|
|
55
|
-
'sh',
|
|
56
|
-
'-c',
|
|
57
|
-
'echo "$APP_NAME:$APP_VERSION"',
|
|
58
|
-
]);
|
|
59
|
-
expect(result.stdout.trim()).toBe('test-app:1.0.0');
|
|
60
|
-
await container.destroy();
|
|
61
|
-
});
|
|
62
|
-
test('multiple bindings merge env vars', async () => {
|
|
63
|
-
const binding1 = createEnvBinding('env1', {
|
|
64
|
-
VAR_A: 'a',
|
|
65
|
-
VAR_B: 'b',
|
|
66
|
-
});
|
|
67
|
-
const binding2 = createEnvBinding('env2', {
|
|
68
|
-
VAR_C: 'c',
|
|
69
|
-
VAR_D: 'd',
|
|
70
|
-
});
|
|
71
|
-
const container = await Container('test-multi-binding', {
|
|
72
|
-
image: TEST_IMAGE,
|
|
73
|
-
cmd: ['sleep', '30'],
|
|
74
|
-
bindings: {
|
|
75
|
-
first: binding1,
|
|
76
|
-
second: binding2,
|
|
77
|
-
},
|
|
78
|
-
});
|
|
79
|
-
await waitUntil(() => !!container.getIp('test-multi-binding'), 5000);
|
|
80
|
-
const result = await container.exec([
|
|
81
|
-
'sh',
|
|
82
|
-
'-c',
|
|
83
|
-
'echo "$VAR_A$VAR_B$VAR_C$VAR_D"',
|
|
84
|
-
]);
|
|
85
|
-
expect(result.stdout.trim()).toBe('abcd');
|
|
86
|
-
await container.destroy();
|
|
87
|
-
});
|
|
88
|
-
test('explicit env takes precedence over bindings', async () => {
|
|
89
|
-
const binding = createEnvBinding('config', {
|
|
90
|
-
MY_VAR: 'from-binding',
|
|
91
|
-
});
|
|
92
|
-
const container = await Container('test-env-precedence', {
|
|
93
|
-
image: TEST_IMAGE,
|
|
94
|
-
cmd: ['sleep', '30'],
|
|
95
|
-
bindings: {
|
|
96
|
-
config: binding,
|
|
97
|
-
},
|
|
98
|
-
env: {
|
|
99
|
-
MY_VAR: 'from-explicit',
|
|
100
|
-
},
|
|
101
|
-
});
|
|
102
|
-
await waitUntil(() => !!container.getIp('test-env-precedence'), 5000);
|
|
103
|
-
const result = await container.exec(['sh', '-c', 'echo "$MY_VAR"']);
|
|
104
|
-
expect(result.stdout.trim()).toBe('from-explicit');
|
|
105
|
-
await container.destroy();
|
|
106
|
-
});
|
|
107
|
-
test('database can be used as binding resource', async () => {
|
|
108
|
-
const db = await database('test-db-binding', {
|
|
109
|
-
image: 'redis:7-alpine',
|
|
110
|
-
hostPort: 16390,
|
|
111
|
-
});
|
|
112
|
-
const dbBinding = createEnvBinding('database', {
|
|
113
|
-
REDIS_URL: db.connectionString,
|
|
114
|
-
});
|
|
115
|
-
const container = await Container('test-db-consumer', {
|
|
116
|
-
image: TEST_IMAGE,
|
|
117
|
-
cmd: ['sleep', '30'],
|
|
118
|
-
bindings: {
|
|
119
|
-
db: dbBinding,
|
|
120
|
-
},
|
|
121
|
-
});
|
|
122
|
-
await waitUntil(() => !!container.getIp('test-db-consumer'), 5000);
|
|
123
|
-
const result = await container.exec(['sh', '-c', 'echo "$REDIS_URL"']);
|
|
124
|
-
expect(result.stdout.trim()).toBe('redis://localhost:16390');
|
|
125
|
-
await container.destroy();
|
|
126
|
-
await db.destroy();
|
|
127
|
-
});
|
|
128
|
-
});
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Container Resource Integration Tests
|
|
3
|
-
*
|
|
4
|
-
* Dogfoods the Container resource to verify:
|
|
5
|
-
* - Creation with network binding
|
|
6
|
-
* - waitForLog functionality
|
|
7
|
-
* - exec command execution
|
|
8
|
-
* - stats retrieval
|
|
9
|
-
* - Lifecycle (stop, destroy)
|
|
10
|
-
*/
|
|
11
|
-
export {};
|
|
12
|
-
//# sourceMappingURL=container.test.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"container.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/container.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG"}
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Container Resource Integration Tests
|
|
3
|
-
*
|
|
4
|
-
* Dogfoods the Container resource to verify:
|
|
5
|
-
* - Creation with network binding
|
|
6
|
-
* - waitForLog functionality
|
|
7
|
-
* - exec command execution
|
|
8
|
-
* - stats retrieval
|
|
9
|
-
* - Lifecycle (stop, destroy)
|
|
10
|
-
*/
|
|
11
|
-
import { describe, test, expect, afterEach, beforeAll, setDefaultTimeout } from 'bun:test';
|
|
12
|
-
import { Network, Container, destroyAll } from '../index';
|
|
13
|
-
import { buildTestImage, TEST_IMAGE } from './test-setup';
|
|
14
|
-
setDefaultTimeout(30_000);
|
|
15
|
-
describe('Container', () => {
|
|
16
|
-
beforeAll(async () => {
|
|
17
|
-
await buildTestImage();
|
|
18
|
-
});
|
|
19
|
-
afterEach(async () => {
|
|
20
|
-
await destroyAll();
|
|
21
|
-
});
|
|
22
|
-
test('creates a container with basic config', async () => {
|
|
23
|
-
const container = await Container('test-container-basic', {
|
|
24
|
-
image: TEST_IMAGE,
|
|
25
|
-
cmd: ['sleep', '30'],
|
|
26
|
-
});
|
|
27
|
-
expect(container.id).toBeDefined();
|
|
28
|
-
expect(container.name).toBe('test-container-basic');
|
|
29
|
-
await container.destroy();
|
|
30
|
-
});
|
|
31
|
-
test('creates a container attached to a network', async () => {
|
|
32
|
-
const network = await Network('test-container-net');
|
|
33
|
-
const container = await Container('test-container-networked', {
|
|
34
|
-
image: TEST_IMAGE,
|
|
35
|
-
cmd: ['sleep', '30'],
|
|
36
|
-
networks: [network],
|
|
37
|
-
});
|
|
38
|
-
expect(container.id).toBeDefined();
|
|
39
|
-
const ip = await container.getIp('test-container-net');
|
|
40
|
-
expect(ip).toBeDefined();
|
|
41
|
-
await container.destroy();
|
|
42
|
-
await network.destroy();
|
|
43
|
-
});
|
|
44
|
-
test('exec runs commands inside container', async () => {
|
|
45
|
-
const container = await Container('test-container-exec', {
|
|
46
|
-
image: TEST_IMAGE,
|
|
47
|
-
cmd: ['sleep', '30'],
|
|
48
|
-
});
|
|
49
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
50
|
-
const result = await container.exec(['echo', 'hello world']);
|
|
51
|
-
expect(result.exitCode).toBe(0);
|
|
52
|
-
expect(result.stdout.trim()).toBe('hello world');
|
|
53
|
-
expect(result.durationMs).toBeGreaterThan(0);
|
|
54
|
-
await container.destroy();
|
|
55
|
-
});
|
|
56
|
-
test('stats returns container resource statistics', async () => {
|
|
57
|
-
const container = await Container('test-container-stats', {
|
|
58
|
-
image: TEST_IMAGE,
|
|
59
|
-
cmd: ['sleep', '30'],
|
|
60
|
-
});
|
|
61
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
62
|
-
const stats = await container.stats();
|
|
63
|
-
expect(stats.cpu).toBeDefined();
|
|
64
|
-
expect(stats.cpu.cores).toBeGreaterThan(0);
|
|
65
|
-
expect(stats.memory).toBeDefined();
|
|
66
|
-
expect(stats.memory.limit).toBeGreaterThan(0);
|
|
67
|
-
expect(stats.network).toBeDefined();
|
|
68
|
-
expect(stats.containerId).toBe(container.id);
|
|
69
|
-
await container.destroy();
|
|
70
|
-
});
|
|
71
|
-
test('waitForLog detects log patterns', async () => {
|
|
72
|
-
const container = await Container('test-container-logs', {
|
|
73
|
-
image: TEST_IMAGE,
|
|
74
|
-
cmd: ['sh', '-c', 'echo "Server started successfully" && sleep 30'],
|
|
75
|
-
});
|
|
76
|
-
await container.waitForLog('Server started', 5000);
|
|
77
|
-
expect(true).toBe(true);
|
|
78
|
-
await container.destroy();
|
|
79
|
-
});
|
|
80
|
-
test('stop gracefully shuts down container', async () => {
|
|
81
|
-
const container = await Container('test-container-stop', {
|
|
82
|
-
image: TEST_IMAGE,
|
|
83
|
-
cmd: ['sh', '-c', 'trap "exit 0" TERM; while true; do sleep 1; done'],
|
|
84
|
-
});
|
|
85
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
86
|
-
const metadata = await container.stop('SIGTERM', 10000);
|
|
87
|
-
expect(metadata.signal).toBe('SIGTERM');
|
|
88
|
-
expect(metadata.signalCount).toBeGreaterThan(0);
|
|
89
|
-
expect(metadata.timeTakenMs).toBeGreaterThan(0);
|
|
90
|
-
await container.destroy();
|
|
91
|
-
});
|
|
92
|
-
test('supports environment variables', async () => {
|
|
93
|
-
const container = await Container('test-container-env', {
|
|
94
|
-
image: TEST_IMAGE,
|
|
95
|
-
cmd: ['sleep', '30'],
|
|
96
|
-
env: {
|
|
97
|
-
MY_VAR: 'hello',
|
|
98
|
-
ANOTHER_VAR: 'world',
|
|
99
|
-
},
|
|
100
|
-
});
|
|
101
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
102
|
-
const result = await container.exec([
|
|
103
|
-
'sh',
|
|
104
|
-
'-c',
|
|
105
|
-
'echo $MY_VAR $ANOTHER_VAR',
|
|
106
|
-
]);
|
|
107
|
-
expect(result.stdout.trim()).toBe('hello world');
|
|
108
|
-
await container.destroy();
|
|
109
|
-
});
|
|
110
|
-
test('supports port mappings', async () => {
|
|
111
|
-
const container = await Container('test-container-ports', {
|
|
112
|
-
image: TEST_IMAGE,
|
|
113
|
-
cmd: ['sleep', '30'],
|
|
114
|
-
ports: [{ internal: 8080, external: 18080 }],
|
|
115
|
-
});
|
|
116
|
-
expect(container.id).toBeDefined();
|
|
117
|
-
await container.destroy();
|
|
118
|
-
});
|
|
119
|
-
test('streamLogs terminates without follow option', async () => {
|
|
120
|
-
const container = await Container('test-logs-terminate', {
|
|
121
|
-
image: TEST_IMAGE,
|
|
122
|
-
cmd: ['sh', '-c', 'echo "line1" && echo "line2" && echo "line3" && sleep 30'],
|
|
123
|
-
});
|
|
124
|
-
await container.waitForLog('line3', 5000);
|
|
125
|
-
const lines = [];
|
|
126
|
-
const logStream = await container.streamLogs({ tail: 3 });
|
|
127
|
-
for await (const line of logStream) {
|
|
128
|
-
lines.push(line.message);
|
|
129
|
-
}
|
|
130
|
-
// Loop should exit naturally
|
|
131
|
-
expect(lines).toContain('line1');
|
|
132
|
-
expect(lines).toContain('line2');
|
|
133
|
-
expect(lines).toContain('line3');
|
|
134
|
-
await container.destroy();
|
|
135
|
-
});
|
|
136
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"database.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/database.test.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG"}
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Database Helper Integration Tests
|
|
3
|
-
*
|
|
4
|
-
* Dogfoods the database helper to verify:
|
|
5
|
-
* - Connection string generation
|
|
6
|
-
* - Port mapping
|
|
7
|
-
* - Database-specific defaults
|
|
8
|
-
*/
|
|
9
|
-
import { describe, test, expect, afterEach, setDefaultTimeout } from 'bun:test';
|
|
10
|
-
import { Network, database, destroyAll } from '../index';
|
|
11
|
-
setDefaultTimeout(60_000);
|
|
12
|
-
describe('database', () => {
|
|
13
|
-
afterEach(async () => {
|
|
14
|
-
await destroyAll();
|
|
15
|
-
});
|
|
16
|
-
test('creates a postgres database with connection string', async () => {
|
|
17
|
-
const db = await database('test-postgres', {
|
|
18
|
-
image: 'postgres:16-alpine',
|
|
19
|
-
env: {
|
|
20
|
-
POSTGRES_PASSWORD: 'testpass',
|
|
21
|
-
POSTGRES_DB: 'testdb',
|
|
22
|
-
},
|
|
23
|
-
hostPort: 15432,
|
|
24
|
-
});
|
|
25
|
-
expect(db.id).toBeDefined();
|
|
26
|
-
expect(db.name).toBe('test-postgres');
|
|
27
|
-
expect(db.port).toBe(5432);
|
|
28
|
-
expect(db.hostPort).toBe(15432);
|
|
29
|
-
expect(db.connectionString).toBe('postgresql://postgres:testpass@localhost:15432/testdb');
|
|
30
|
-
await db.destroy();
|
|
31
|
-
});
|
|
32
|
-
test('creates a redis database with connection string', async () => {
|
|
33
|
-
const db = await database('test-redis', {
|
|
34
|
-
image: 'redis:7-alpine',
|
|
35
|
-
hostPort: 16379,
|
|
36
|
-
});
|
|
37
|
-
expect(db.port).toBe(6379);
|
|
38
|
-
expect(db.hostPort).toBe(16379);
|
|
39
|
-
expect(db.connectionString).toBe('redis://localhost:16379');
|
|
40
|
-
await db.destroy();
|
|
41
|
-
});
|
|
42
|
-
test('creates a redis database with password', async () => {
|
|
43
|
-
const db = await database('test-redis-auth', {
|
|
44
|
-
image: 'redis:7-alpine',
|
|
45
|
-
hostPort: 16380,
|
|
46
|
-
env: {
|
|
47
|
-
REDIS_PASSWORD: 'secret',
|
|
48
|
-
},
|
|
49
|
-
});
|
|
50
|
-
expect(db.connectionString).toBe('redis://:secret@localhost:16380');
|
|
51
|
-
await db.destroy();
|
|
52
|
-
});
|
|
53
|
-
test('attaches database to network', async () => {
|
|
54
|
-
const network = await Network('test-db-net');
|
|
55
|
-
const db = await database('test-db-networked', {
|
|
56
|
-
image: 'redis:7-alpine',
|
|
57
|
-
networks: [network],
|
|
58
|
-
hostPort: 16381,
|
|
59
|
-
});
|
|
60
|
-
const ip = await db.getIp('test-db-net');
|
|
61
|
-
expect(ip).toBeDefined();
|
|
62
|
-
expect(ip).not.toBe('');
|
|
63
|
-
await db.destroy();
|
|
64
|
-
await network.destroy();
|
|
65
|
-
});
|
|
66
|
-
test('database inherits container methods', async () => {
|
|
67
|
-
const db = await database('test-db-methods', {
|
|
68
|
-
image: 'redis:7-alpine',
|
|
69
|
-
hostPort: 16382,
|
|
70
|
-
});
|
|
71
|
-
await db.waitForLog('Ready to accept connections', 10000);
|
|
72
|
-
const result = await db.exec(['redis-cli', 'PING']);
|
|
73
|
-
expect(result.stdout.trim()).toBe('PONG');
|
|
74
|
-
const stats = await db.stats();
|
|
75
|
-
expect(stats.cpu).toBeDefined();
|
|
76
|
-
await db.destroy();
|
|
77
|
-
});
|
|
78
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"docker-infra.template.d.ts","sourceRoot":"","sources":["../../src/__tests__/docker-infra.template.ts"],"names":[],"mappings":""}
|
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Docker Infrastructure Test Template
|
|
3
|
-
*
|
|
4
|
-
* Copy and customize this file to test your project's Dockerfile and entrypoint.
|
|
5
|
-
* Replace {{PROJECT_NAME}} and {{PROJECT_IMAGE}} with your values.
|
|
6
|
-
*
|
|
7
|
-
* This template demonstrates Harpoon's complete API for testing Docker infrastructure:
|
|
8
|
-
* - Image building with buildArgs
|
|
9
|
-
* - Container creation with volumes, capabilities, and user overrides
|
|
10
|
-
* - Log waiting and streaming
|
|
11
|
-
* - Command execution inside containers
|
|
12
|
-
* - Network isolation testing
|
|
13
|
-
*
|
|
14
|
-
* @example
|
|
15
|
-
* ```bash
|
|
16
|
-
* # Copy and customize for your project
|
|
17
|
-
* cp docker-infra.template.ts my-project.test.ts
|
|
18
|
-
* # Edit PROJECT config and tests
|
|
19
|
-
* bun test my-project.test.ts
|
|
20
|
-
* ```
|
|
21
|
-
*/
|
|
22
|
-
import { describe, test, expect, beforeAll, afterEach, setDefaultTimeout } from 'bun:test';
|
|
23
|
-
import { Image, Container, Network, destroyAll } from '@docker-harpoon/core';
|
|
24
|
-
// ============ PROJECT CONFIGURATION ============
|
|
25
|
-
// Customize these values for your project
|
|
26
|
-
const PROJECT = {
|
|
27
|
-
name: '{{PROJECT_NAME}}',
|
|
28
|
-
image: '{{PROJECT_IMAGE}}:latest',
|
|
29
|
-
context: './',
|
|
30
|
-
dockerfile: 'Dockerfile',
|
|
31
|
-
port: 3000,
|
|
32
|
-
healthPath: '/api/status',
|
|
33
|
-
};
|
|
34
|
-
// Set a generous timeout for Docker operations
|
|
35
|
-
setDefaultTimeout(120_000);
|
|
36
|
-
describe(`${PROJECT.name} Docker Infrastructure`, () => {
|
|
37
|
-
beforeAll(async () => {
|
|
38
|
-
// Build the project image
|
|
39
|
-
await Image(PROJECT.image, {
|
|
40
|
-
context: PROJECT.context,
|
|
41
|
-
dockerfile: PROJECT.dockerfile,
|
|
42
|
-
buildArgs: { SEED_DATABASE: 'true' },
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
afterEach(async () => {
|
|
46
|
-
await destroyAll();
|
|
47
|
-
});
|
|
48
|
-
// ============ HEALTH CHECK TEST ============
|
|
49
|
-
test('health check responds 200', async () => {
|
|
50
|
-
const container = await Container('test-health', {
|
|
51
|
-
image: PROJECT.image,
|
|
52
|
-
env: { PORT: String(PROJECT.port) },
|
|
53
|
-
ports: [{ internal: PROJECT.port, external: 13000 }],
|
|
54
|
-
});
|
|
55
|
-
await container.waitForLog(/listening|started|ready/i, 30000);
|
|
56
|
-
const res = await fetch(`http://localhost:13000${PROJECT.healthPath}`);
|
|
57
|
-
expect(res.status).toBe(200);
|
|
58
|
-
});
|
|
59
|
-
// ============ VOLUME MOUNT TEST ============
|
|
60
|
-
test('db snapshot copy-on-boot pattern', async () => {
|
|
61
|
-
const container = await Container('test-snapshot', {
|
|
62
|
-
image: PROJECT.image,
|
|
63
|
-
env: {
|
|
64
|
-
DB_SOURCE: '/data/snapshot/app.db',
|
|
65
|
-
DB_PATH: '/app/data/test.db',
|
|
66
|
-
},
|
|
67
|
-
volumes: [
|
|
68
|
-
{ hostPath: '/tmp/test-snapshot.db', containerPath: '/data/snapshot/app.db', readonly: true },
|
|
69
|
-
],
|
|
70
|
-
});
|
|
71
|
-
await container.waitForLog(/snapshot copied|database ready/i, 30000);
|
|
72
|
-
});
|
|
73
|
-
// ============ FILE SYSTEM TEST ============
|
|
74
|
-
test('file upload directory setup', async () => {
|
|
75
|
-
const container = await Container('test-uploads', {
|
|
76
|
-
image: PROJECT.image,
|
|
77
|
-
env: { FILE_UPLOAD_PATH: '/app/custom-uploads' },
|
|
78
|
-
});
|
|
79
|
-
await container.waitForLog(/upload directory|ensuring/i, 10000);
|
|
80
|
-
const result = await container.exec(['ls', '-la', '/app/custom-uploads']);
|
|
81
|
-
expect(result.exitCode).toBe(0);
|
|
82
|
-
expect(result.stdout).toContain('drwx'); // Directory exists with permissions
|
|
83
|
-
});
|
|
84
|
-
// ============ NETWORK ISOLATION TEST ============
|
|
85
|
-
test('network isolation with iptables', async () => {
|
|
86
|
-
const network = await Network('isolated-net');
|
|
87
|
-
const container = await Container('test-isolation', {
|
|
88
|
-
image: PROJECT.image,
|
|
89
|
-
networks: [network],
|
|
90
|
-
env: { ENABLE_NETWORK_ISOLATION: 'true' },
|
|
91
|
-
capAdd: ['NET_ADMIN'],
|
|
92
|
-
});
|
|
93
|
-
await container.waitForLog(/network isolation configured|iptables/i, 15000);
|
|
94
|
-
});
|
|
95
|
-
// ============ PRIVILEGE DROPPING TEST ============
|
|
96
|
-
test('privilege dropping', async () => {
|
|
97
|
-
const container = await Container('test-privileges', {
|
|
98
|
-
image: PROJECT.image,
|
|
99
|
-
});
|
|
100
|
-
await container.waitForLog(/dropping privileges|started/i, 15000);
|
|
101
|
-
const result = await container.exec(['whoami']);
|
|
102
|
-
expect(result.exitCode).toBe(0);
|
|
103
|
-
expect(result.stdout.trim()).not.toBe('root');
|
|
104
|
-
});
|
|
105
|
-
// ============ ENVIRONMENT VARIABLE TEST ============
|
|
106
|
-
test('environment variable propagation', async () => {
|
|
107
|
-
const container = await Container('test-env', {
|
|
108
|
-
image: PROJECT.image,
|
|
109
|
-
env: {
|
|
110
|
-
PORT: '8080',
|
|
111
|
-
LOG_LEVEL: 'debug',
|
|
112
|
-
CUSTOM_VAR: 'test-value',
|
|
113
|
-
},
|
|
114
|
-
ports: [{ internal: 8080, external: 18080 }],
|
|
115
|
-
});
|
|
116
|
-
await container.waitForLog(/listening|started/i, 30000);
|
|
117
|
-
// Verify env vars are set
|
|
118
|
-
const envResult = await container.exec(['printenv']);
|
|
119
|
-
expect(envResult.stdout).toContain('PORT=8080');
|
|
120
|
-
expect(envResult.stdout).toContain('LOG_LEVEL=debug');
|
|
121
|
-
expect(envResult.stdout).toContain('CUSTOM_VAR=test-value');
|
|
122
|
-
});
|
|
123
|
-
// ============ USER OVERRIDE TEST ============
|
|
124
|
-
test('user override', async () => {
|
|
125
|
-
const container = await Container('test-user', {
|
|
126
|
-
image: PROJECT.image,
|
|
127
|
-
user: '1000:1000',
|
|
128
|
-
});
|
|
129
|
-
await container.waitForLog(/started|ready/i, 15000);
|
|
130
|
-
const result = await container.exec(['id']);
|
|
131
|
-
expect(result.exitCode).toBe(0);
|
|
132
|
-
expect(result.stdout).toContain('uid=1000');
|
|
133
|
-
expect(result.stdout).toContain('gid=1000');
|
|
134
|
-
});
|
|
135
|
-
// ============ CAPABILITIES TEST ============
|
|
136
|
-
test('dropped capabilities', async () => {
|
|
137
|
-
const container = await Container('test-caps', {
|
|
138
|
-
image: PROJECT.image,
|
|
139
|
-
capDrop: ['ALL'],
|
|
140
|
-
capAdd: ['NET_BIND_SERVICE'],
|
|
141
|
-
});
|
|
142
|
-
await container.waitForLog(/started|ready/i, 15000);
|
|
143
|
-
// Container should be running with minimal capabilities
|
|
144
|
-
const result = await container.exec(['cat', '/proc/1/status']);
|
|
145
|
-
expect(result.exitCode).toBe(0);
|
|
146
|
-
// CapEff should show limited capabilities
|
|
147
|
-
expect(result.stdout).toContain('CapEff');
|
|
148
|
-
});
|
|
149
|
-
// ============ GRACEFUL SHUTDOWN TEST ============
|
|
150
|
-
test('graceful shutdown', async () => {
|
|
151
|
-
const container = await Container('test-shutdown', {
|
|
152
|
-
image: PROJECT.image,
|
|
153
|
-
env: { PORT: String(PROJECT.port) },
|
|
154
|
-
});
|
|
155
|
-
await container.waitForLog(/listening|started|ready/i, 30000);
|
|
156
|
-
const metadata = await container.stop('SIGTERM', 10000);
|
|
157
|
-
expect(metadata.graceful).toBe(true);
|
|
158
|
-
expect(metadata.signal).toBe('SIGTERM');
|
|
159
|
-
});
|
|
160
|
-
// ============ RESOURCE STATS TEST ============
|
|
161
|
-
test('container stats', async () => {
|
|
162
|
-
const container = await Container('test-stats', {
|
|
163
|
-
image: PROJECT.image,
|
|
164
|
-
env: { PORT: String(PROJECT.port) },
|
|
165
|
-
});
|
|
166
|
-
await container.waitForLog(/listening|started|ready/i, 30000);
|
|
167
|
-
const stats = await container.stats();
|
|
168
|
-
expect(stats.cpu).toBeDefined();
|
|
169
|
-
expect(stats.cpu.percent).toBeGreaterThanOrEqual(0);
|
|
170
|
-
expect(stats.memory).toBeDefined();
|
|
171
|
-
expect(stats.memory.usage).toBeGreaterThan(0);
|
|
172
|
-
expect(stats.network).toBeDefined();
|
|
173
|
-
});
|
|
174
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"network.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/network.test.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG"}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Network Resource Integration Tests
|
|
3
|
-
*
|
|
4
|
-
* Dogfoods the Network resource to verify:
|
|
5
|
-
* - Creation and destruction
|
|
6
|
-
* - Property access (id, name)
|
|
7
|
-
* - Duplicate network handling
|
|
8
|
-
*/
|
|
9
|
-
import { describe, test, expect, afterEach, setDefaultTimeout } from 'bun:test';
|
|
10
|
-
import { Network, destroyAll } from '../index';
|
|
11
|
-
setDefaultTimeout(30_000);
|
|
12
|
-
describe('Network', () => {
|
|
13
|
-
afterEach(async () => {
|
|
14
|
-
await destroyAll();
|
|
15
|
-
});
|
|
16
|
-
test('creates a network and returns resource with id and name', async () => {
|
|
17
|
-
const network = await Network('test-network-basic');
|
|
18
|
-
expect(network.id).toBeDefined();
|
|
19
|
-
expect(network.id.length).toBeGreaterThan(0);
|
|
20
|
-
expect(network.name).toBe('test-network-basic');
|
|
21
|
-
await network.destroy();
|
|
22
|
-
});
|
|
23
|
-
test('allows custom driver configuration', async () => {
|
|
24
|
-
const network = await Network('test-network-custom', {
|
|
25
|
-
driver: 'bridge',
|
|
26
|
-
});
|
|
27
|
-
expect(network.name).toBe('test-network-custom');
|
|
28
|
-
expect(network.id).toBeDefined();
|
|
29
|
-
await network.destroy();
|
|
30
|
-
});
|
|
31
|
-
test('handles creating network with same name (replaces existing)', async () => {
|
|
32
|
-
const network1 = await Network('test-network-duplicate');
|
|
33
|
-
const id1 = network1.id;
|
|
34
|
-
const network2 = await Network('test-network-duplicate');
|
|
35
|
-
const id2 = network2.id;
|
|
36
|
-
expect(id2).not.toBe(id1);
|
|
37
|
-
await network2.destroy();
|
|
38
|
-
});
|
|
39
|
-
test('destroy is idempotent', async () => {
|
|
40
|
-
const network = await Network('test-network-idempotent');
|
|
41
|
-
await network.destroy();
|
|
42
|
-
expect(network.destroy()).resolves.toBeUndefined();
|
|
43
|
-
});
|
|
44
|
-
});
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared test setup for integration tests.
|
|
3
|
-
*
|
|
4
|
-
* Builds a minimal test image that can be used across all tests,
|
|
5
|
-
* ensuring tests are self-contained and don't rely on external images.
|
|
6
|
-
*/
|
|
7
|
-
export declare const TEST_IMAGE = "harpoon-test:latest";
|
|
8
|
-
export declare function buildTestImage(): Promise<void>;
|
|
9
|
-
//# sourceMappingURL=test-setup.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"test-setup.d.ts","sourceRoot":"","sources":["../../src/__tests__/test-setup.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,eAAO,MAAM,UAAU,wBAAwB,CAAC;AAWhD,wBAAsB,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC,CAapD"}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared test setup for integration tests.
|
|
3
|
-
*
|
|
4
|
-
* Builds a minimal test image that can be used across all tests,
|
|
5
|
-
* ensuring tests are self-contained and don't rely on external images.
|
|
6
|
-
*/
|
|
7
|
-
import { Image } from '../index';
|
|
8
|
-
import { join } from 'path';
|
|
9
|
-
export const TEST_IMAGE = 'harpoon-test:latest';
|
|
10
|
-
let imageBuilt = false;
|
|
11
|
-
// Get the source directory path (works in both ESM and CJS)
|
|
12
|
-
const getFixturesDir = () => {
|
|
13
|
-
// Try to resolve from source directory first
|
|
14
|
-
const srcDir = join(process.cwd(), 'src', '__tests__', 'fixtures');
|
|
15
|
-
return srcDir;
|
|
16
|
-
};
|
|
17
|
-
export async function buildTestImage() {
|
|
18
|
-
if (imageBuilt) {
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
const fixturesDir = getFixturesDir();
|
|
22
|
-
await Image(TEST_IMAGE, {
|
|
23
|
-
context: fixturesDir,
|
|
24
|
-
dockerfile: 'Dockerfile',
|
|
25
|
-
});
|
|
26
|
-
imageBuilt = true;
|
|
27
|
-
}
|