@docker-harpoon/core 0.1.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/index.d.ts +7 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +10 -0
- package/dist/api/promise.d.ts +179 -0
- package/dist/api/promise.d.ts.map +1 -0
- package/dist/api/promise.js +282 -0
- package/dist/bindings/index.d.ts +8 -0
- package/dist/bindings/index.d.ts.map +1 -0
- package/dist/bindings/index.js +6 -0
- package/dist/bindings/types.d.ts +116 -0
- package/dist/bindings/types.d.ts.map +1 -0
- package/dist/bindings/types.js +46 -0
- package/dist/build-strategies/index.d.ts +30 -0
- package/dist/build-strategies/index.d.ts.map +1 -0
- package/dist/build-strategies/index.js +47 -0
- package/dist/build-strategies/standard.d.ts +16 -0
- package/dist/build-strategies/standard.d.ts.map +1 -0
- package/dist/build-strategies/standard.js +39 -0
- package/dist/build-strategies/types.d.ts +81 -0
- package/dist/build-strategies/types.d.ts.map +1 -0
- package/dist/build-strategies/types.js +25 -0
- package/dist/config-patchers/index.d.ts +33 -0
- package/dist/config-patchers/index.d.ts.map +1 -0
- package/dist/config-patchers/index.js +75 -0
- package/dist/config-patchers/types.d.ts +89 -0
- package/dist/config-patchers/types.d.ts.map +1 -0
- package/dist/config-patchers/types.js +25 -0
- package/dist/dockerfile-transformers/core.d.ts +59 -0
- package/dist/dockerfile-transformers/core.d.ts.map +1 -0
- package/dist/dockerfile-transformers/core.js +271 -0
- package/dist/dockerfile-transformers/index.d.ts +42 -0
- package/dist/dockerfile-transformers/index.d.ts.map +1 -0
- package/dist/dockerfile-transformers/index.js +67 -0
- package/dist/dockerfile-transformers/types.d.ts +116 -0
- package/dist/dockerfile-transformers/types.d.ts.map +1 -0
- package/dist/dockerfile-transformers/types.js +29 -0
- package/dist/errors.d.ts +75 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +119 -0
- package/dist/helpers/database.d.ts +54 -0
- package/dist/helpers/database.d.ts.map +1 -0
- package/dist/helpers/database.js +108 -0
- package/dist/helpers/index.d.ts +8 -0
- package/dist/helpers/index.d.ts.map +1 -0
- package/dist/helpers/index.js +6 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/resources/container.d.ts +42 -0
- package/dist/resources/container.d.ts.map +1 -0
- package/dist/resources/container.js +256 -0
- package/dist/resources/image.d.ts +37 -0
- package/dist/resources/image.d.ts.map +1 -0
- package/dist/resources/image.js +113 -0
- package/dist/resources/index.d.ts +12 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +8 -0
- package/dist/resources/network.d.ts +21 -0
- package/dist/resources/network.d.ts.map +1 -0
- package/dist/resources/network.js +86 -0
- package/package.json +28 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container Resource Module
|
|
3
|
+
*
|
|
4
|
+
* Manages Docker container lifecycle using Effect's acquireRelease pattern.
|
|
5
|
+
* Ensures containers are always cleaned up, even during interruption.
|
|
6
|
+
*
|
|
7
|
+
* Supports bindings for environment variable injection and lifecycle hooks.
|
|
8
|
+
*/
|
|
9
|
+
import { Effect, Schedule, Exit } from 'effect';
|
|
10
|
+
import { toHarpoonError, ContainerError } from '../errors';
|
|
11
|
+
import { mergeBindingsEnv } from '../bindings/types';
|
|
12
|
+
const CLEANUP_TIMEOUT_MS = 10_000;
|
|
13
|
+
const safeRemoveContainer = async (docker, containerId, containerName) => {
|
|
14
|
+
const container = docker.getContainer(containerId);
|
|
15
|
+
try {
|
|
16
|
+
const data = await container.inspect();
|
|
17
|
+
if (data.State.Running) {
|
|
18
|
+
console.log(`[Cleanup] Stopping container: ${containerName}`);
|
|
19
|
+
try {
|
|
20
|
+
await container.kill({ signal: 'SIGTERM' });
|
|
21
|
+
await new Promise((resolve) => {
|
|
22
|
+
const checkStopped = async () => {
|
|
23
|
+
try {
|
|
24
|
+
const state = await container.inspect();
|
|
25
|
+
if (!state.State.Running) {
|
|
26
|
+
resolve();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
resolve();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
setTimeout(checkStopped, 100);
|
|
35
|
+
};
|
|
36
|
+
const timeout = setTimeout(() => {
|
|
37
|
+
console.warn(`[Cleanup] Container ${containerName} did not stop gracefully, forcing`);
|
|
38
|
+
container.kill({ signal: 'SIGKILL' }).catch(() => { });
|
|
39
|
+
resolve();
|
|
40
|
+
}, 5000);
|
|
41
|
+
checkStopped().finally(() => clearTimeout(timeout));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
if (!e.message?.includes('is not running')) {
|
|
46
|
+
console.warn(`[Cleanup] Error stopping ${containerName}: ${e.message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
await container.remove({ force: true });
|
|
51
|
+
console.log(`[Cleanup] Container removed: ${containerName}`);
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
if (e.statusCode === 404) {
|
|
55
|
+
console.log(`[Cleanup] Container ${containerName} already removed`);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
console.error(`[Cleanup] Error removing container ${containerName}:`, e.message);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
export const Container = (resourceId, config, docker) => Effect.acquireRelease(Effect.tryPromise({
|
|
63
|
+
try: async () => {
|
|
64
|
+
try {
|
|
65
|
+
const existingContainer = docker.getContainer(resourceId);
|
|
66
|
+
await existingContainer.inspect();
|
|
67
|
+
console.log(`[Container] Found existing container ${resourceId}, removing...`);
|
|
68
|
+
await safeRemoveContainer(docker, resourceId, resourceId);
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
if (e.statusCode !== 404) {
|
|
72
|
+
console.warn(`[Container] Warning checking for existing container: ${e.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (config.ports?.length) {
|
|
76
|
+
const externalPorts = config.ports.map(({ external }) => external);
|
|
77
|
+
try {
|
|
78
|
+
const containers = await docker.listContainers({ all: true });
|
|
79
|
+
for (const c of containers) {
|
|
80
|
+
if (c.Names?.some((name) => name.includes(resourceId)))
|
|
81
|
+
continue;
|
|
82
|
+
const usesConflictingPort = (c.Ports || []).some((p) => p.PublicPort && externalPorts.includes(p.PublicPort));
|
|
83
|
+
if (!usesConflictingPort)
|
|
84
|
+
continue;
|
|
85
|
+
const name = c.Names?.[0]?.replace(/^\//, '') ?? c.Id.substring(0, 12);
|
|
86
|
+
console.log(`[Container] Found container "${name}" using port(s) ${externalPorts.join(', ')}, removing...`);
|
|
87
|
+
await safeRemoveContainer(docker, c.Id, name);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
console.warn(`[Container] Warning checking for port conflicts: ${e.message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const PortBindings = {};
|
|
95
|
+
const ExposedPorts = {};
|
|
96
|
+
for (const port of config.ports ?? []) {
|
|
97
|
+
const key = `${port.internal}/tcp`;
|
|
98
|
+
ExposedPorts[key] = {};
|
|
99
|
+
PortBindings[key] = [{ HostPort: String(port.external) }];
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const image = docker.getImage(config.image);
|
|
103
|
+
await image.inspect();
|
|
104
|
+
console.log(`[Container] Verified image exists: ${config.image}`);
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
if (e.statusCode === 404) {
|
|
108
|
+
const images = await docker.listImages();
|
|
109
|
+
const imageName = config.image.split(':')[0] || config.image;
|
|
110
|
+
const imageTags = images
|
|
111
|
+
.flatMap((img) => img.RepoTags || [])
|
|
112
|
+
.filter((tag) => tag && tag.includes(imageName));
|
|
113
|
+
throw new Error(`Image "${config.image}" not found. Available images with similar tags: ${imageTags.join(', ') || 'none'}`);
|
|
114
|
+
}
|
|
115
|
+
throw new Error(`Failed to verify image "${config.image}": ${e.message}`);
|
|
116
|
+
}
|
|
117
|
+
// Merge env vars from bindings
|
|
118
|
+
const bindingsEnv = config.bindings
|
|
119
|
+
? mergeBindingsEnv(config.bindings)
|
|
120
|
+
: {};
|
|
121
|
+
const finalEnv = {
|
|
122
|
+
...bindingsEnv,
|
|
123
|
+
...config.env, // User env takes precedence
|
|
124
|
+
};
|
|
125
|
+
const container = await docker.createContainer({
|
|
126
|
+
Image: config.image,
|
|
127
|
+
name: resourceId,
|
|
128
|
+
Env: Object.entries(finalEnv).map(([k, v]) => `${k}=${v}`),
|
|
129
|
+
HostConfig: {
|
|
130
|
+
NetworkMode: config.networks?.[0]?.name,
|
|
131
|
+
AutoRemove: false,
|
|
132
|
+
PortBindings,
|
|
133
|
+
},
|
|
134
|
+
ExposedPorts,
|
|
135
|
+
Cmd: config.cmd,
|
|
136
|
+
StopSignal: 'SIGTERM',
|
|
137
|
+
StopTimeout: 10,
|
|
138
|
+
});
|
|
139
|
+
await container.start();
|
|
140
|
+
const resource = {
|
|
141
|
+
id: container.id,
|
|
142
|
+
name: resourceId,
|
|
143
|
+
waitForLog: (pattern, timeoutMs = 30000) => Effect.gen(function* () {
|
|
144
|
+
const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
|
|
145
|
+
const checkLogs = Effect.tryPromise({
|
|
146
|
+
try: async () => {
|
|
147
|
+
const buffer = await container.logs({
|
|
148
|
+
stdout: true,
|
|
149
|
+
stderr: true,
|
|
150
|
+
tail: 1000,
|
|
151
|
+
});
|
|
152
|
+
const logContent = buffer.toString('utf-8');
|
|
153
|
+
if (regex.test(logContent)) {
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
throw new Error('Log pattern not found');
|
|
157
|
+
},
|
|
158
|
+
catch: (e) => new Error(`Log check failed: ${e}`),
|
|
159
|
+
});
|
|
160
|
+
yield* checkLogs.pipe(Effect.retry({
|
|
161
|
+
schedule: Schedule.spaced('1 seconds').pipe(Schedule.intersect(Schedule.duration(`${timeoutMs} millis`))),
|
|
162
|
+
}));
|
|
163
|
+
}).pipe(Effect.mapError((e) => new Error(`waitForLog failed after ${timeoutMs}ms: ${e.message}`))),
|
|
164
|
+
stopGracefully: (signal = 'SIGTERM', timeoutMs = 10000) => Effect.gen(function* () {
|
|
165
|
+
const startTime = Date.now();
|
|
166
|
+
let signalCount = 0;
|
|
167
|
+
let graceful = false;
|
|
168
|
+
// Call onStop hooks for bindings
|
|
169
|
+
if (config.bindings) {
|
|
170
|
+
for (const binding of Object.values(config.bindings)) {
|
|
171
|
+
if (binding.onStop) {
|
|
172
|
+
yield* binding.onStop(resource);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
yield* Effect.tryPromise({
|
|
177
|
+
try: () => {
|
|
178
|
+
signalCount++;
|
|
179
|
+
return container.kill({ signal });
|
|
180
|
+
},
|
|
181
|
+
catch: (e) => {
|
|
182
|
+
if (e.message?.includes('is not running')) {
|
|
183
|
+
graceful = true;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
throw new Error(`Failed to send ${signal}: ${e.message}`);
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
const check = Effect.suspend(() => Effect.tryPromise({
|
|
190
|
+
try: () => container.inspect(),
|
|
191
|
+
catch: () => null,
|
|
192
|
+
}).pipe(Effect.flatMap((data) => {
|
|
193
|
+
if (!data || !data.State.Running) {
|
|
194
|
+
graceful = true;
|
|
195
|
+
return Effect.void;
|
|
196
|
+
}
|
|
197
|
+
return Effect.fail(new Error('Container still running'));
|
|
198
|
+
})));
|
|
199
|
+
try {
|
|
200
|
+
yield* check.pipe(Effect.retry({
|
|
201
|
+
schedule: Schedule.spaced('500 millis').pipe(Schedule.intersect(Schedule.duration(`${timeoutMs} millis`))),
|
|
202
|
+
}));
|
|
203
|
+
graceful = true;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
graceful = false;
|
|
207
|
+
}
|
|
208
|
+
const timeTakenMs = Date.now() - startTime;
|
|
209
|
+
return {
|
|
210
|
+
signalCount,
|
|
211
|
+
graceful,
|
|
212
|
+
timeTakenMs,
|
|
213
|
+
signal,
|
|
214
|
+
timeoutMs,
|
|
215
|
+
};
|
|
216
|
+
}).pipe(Effect.mapError((e) => new Error(`stopGracefully failed: ${e}`))),
|
|
217
|
+
getIp: (networkName) => Effect.tryPromise({
|
|
218
|
+
try: async () => {
|
|
219
|
+
const data = await container.inspect();
|
|
220
|
+
return (data.NetworkSettings.Networks[networkName]?.IPAddress ??
|
|
221
|
+
'localhost');
|
|
222
|
+
},
|
|
223
|
+
catch: (e) => new Error(`getIp failed: ${e}`),
|
|
224
|
+
}),
|
|
225
|
+
};
|
|
226
|
+
// Call onStart hooks for bindings
|
|
227
|
+
// This is done in Effect context for proper error handling
|
|
228
|
+
return resource;
|
|
229
|
+
},
|
|
230
|
+
catch: (e) => new Error(`Container "${resourceId}" creation failed: ${e}`),
|
|
231
|
+
}).pipe(Effect.mapError((e) => toHarpoonError(e, resourceId, ContainerError)),
|
|
232
|
+
// Run onStart hooks after container creation
|
|
233
|
+
Effect.tap((resource) => Effect.gen(function* () {
|
|
234
|
+
if (config.bindings) {
|
|
235
|
+
for (const binding of Object.values(config.bindings)) {
|
|
236
|
+
if (binding.onStart) {
|
|
237
|
+
yield* binding.onStart(resource);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}))), (resource, exit) => Effect.gen(function* () {
|
|
242
|
+
const isInterrupted = Exit.isInterrupted(exit);
|
|
243
|
+
if (isInterrupted) {
|
|
244
|
+
console.log(`[Cleanup] Interrupted - force cleaning container: ${resource.name}`);
|
|
245
|
+
}
|
|
246
|
+
yield* Effect.tryPromise({
|
|
247
|
+
try: () => Promise.race([
|
|
248
|
+
safeRemoveContainer(docker, resource.id, resource.name),
|
|
249
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Cleanup timeout')), CLEANUP_TIMEOUT_MS)),
|
|
250
|
+
]),
|
|
251
|
+
catch: (e) => {
|
|
252
|
+
console.error(`[Cleanup] Failed to clean up container ${resource.name}:`, e);
|
|
253
|
+
return null;
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}).pipe(Effect.catchAll(() => Effect.void), Effect.uninterruptible));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker Image Resource
|
|
3
|
+
*
|
|
4
|
+
* Builds Docker images using pluggable build strategies.
|
|
5
|
+
* The actual build context preparation is delegated to strategy implementations.
|
|
6
|
+
*/
|
|
7
|
+
import { Effect } from 'effect';
|
|
8
|
+
export interface ImageConfig {
|
|
9
|
+
/** Path to the build context directory */
|
|
10
|
+
context: string;
|
|
11
|
+
/** Path to Dockerfile relative to context */
|
|
12
|
+
dockerfile?: string;
|
|
13
|
+
/** Docker build arguments */
|
|
14
|
+
buildArgs?: Record<string, string>;
|
|
15
|
+
/** Build strategy to use (default: 'standard') */
|
|
16
|
+
buildStrategy?: string;
|
|
17
|
+
/** App name for monorepo strategies */
|
|
18
|
+
monorepoAppName?: string;
|
|
19
|
+
/** Source directory for shared monorepo folders */
|
|
20
|
+
monorepoSource?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Result of building a Docker image.
|
|
24
|
+
*/
|
|
25
|
+
export interface ImageResource {
|
|
26
|
+
readonly tag: string;
|
|
27
|
+
readonly ref: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build a Docker image using the configured strategy.
|
|
31
|
+
*
|
|
32
|
+
* @param tag Image tag (e.g., "my-app:latest")
|
|
33
|
+
* @param config Image build configuration
|
|
34
|
+
* @returns Effect yielding the built image resource
|
|
35
|
+
*/
|
|
36
|
+
export declare const Image: (tag: string, config: ImageConfig) => Effect.Effect<ImageResource, Error>;
|
|
37
|
+
//# sourceMappingURL=image.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"image.d.ts","sourceRoot":"","sources":["../../src/resources/image.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAKhC,MAAM,WAAW,WAAW;IAC1B,0CAA0C;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,6BAA6B;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,kDAAkD;IAClD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,uCAAuC;IACvC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,mDAAmD;IACnD,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,KAAK,GAChB,KAAK,MAAM,EACX,QAAQ,WAAW,KAClB,MAAM,CAAC,MAAM,CAAC,aAAa,EAAE,KAAK,CAyHgC,CAAC"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker Image Resource
|
|
3
|
+
*
|
|
4
|
+
* Builds Docker images using pluggable build strategies.
|
|
5
|
+
* The actual build context preparation is delegated to strategy implementations.
|
|
6
|
+
*/
|
|
7
|
+
import { Effect } from 'effect';
|
|
8
|
+
import * as fs from 'fs/promises';
|
|
9
|
+
import { toHarpoonError, ImageError } from '../errors';
|
|
10
|
+
import { getBuildStrategy } from '../build-strategies';
|
|
11
|
+
/**
|
|
12
|
+
* Build a Docker image using the configured strategy.
|
|
13
|
+
*
|
|
14
|
+
* @param tag Image tag (e.g., "my-app:latest")
|
|
15
|
+
* @param config Image build configuration
|
|
16
|
+
* @returns Effect yielding the built image resource
|
|
17
|
+
*/
|
|
18
|
+
export const Image = (tag, config) => Effect.gen(function* () {
|
|
19
|
+
// Determine which strategy to use
|
|
20
|
+
const strategyName = config.buildStrategy ?? 'standard';
|
|
21
|
+
const strategy = getBuildStrategy(strategyName);
|
|
22
|
+
if (!strategy) {
|
|
23
|
+
yield* Effect.fail(new Error(`Unknown build strategy: ${strategyName}`));
|
|
24
|
+
// TypeScript needs this for type narrowing
|
|
25
|
+
throw new Error('unreachable');
|
|
26
|
+
}
|
|
27
|
+
// Prepare build context using strategy
|
|
28
|
+
const input = {
|
|
29
|
+
tag,
|
|
30
|
+
context: config.context,
|
|
31
|
+
...(config.dockerfile && { dockerfile: config.dockerfile }),
|
|
32
|
+
...(config.buildArgs && { buildArgs: config.buildArgs }),
|
|
33
|
+
...(config.monorepoAppName && { monorepoAppName: config.monorepoAppName }),
|
|
34
|
+
...(config.monorepoSource && { monorepoSource: config.monorepoSource }),
|
|
35
|
+
};
|
|
36
|
+
const buildContext = yield* strategy.prepare(input);
|
|
37
|
+
// Execute docker build
|
|
38
|
+
const result = yield* Effect.tryPromise({
|
|
39
|
+
try: async () => {
|
|
40
|
+
const args = ['docker', 'build', '-t', tag];
|
|
41
|
+
args.push('-f', buildContext.dockerfilePath);
|
|
42
|
+
if (config.buildArgs) {
|
|
43
|
+
for (const [key, value] of Object.entries(config.buildArgs)) {
|
|
44
|
+
args.push('--build-arg', `${key}=${value}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
args.push(buildContext.contextPath);
|
|
48
|
+
const proc = Bun.spawn(args, {
|
|
49
|
+
stdout: 'ignore',
|
|
50
|
+
stderr: 'pipe',
|
|
51
|
+
});
|
|
52
|
+
const stderrOutput = [];
|
|
53
|
+
const errorReader = async () => {
|
|
54
|
+
if (!proc.stderr)
|
|
55
|
+
return;
|
|
56
|
+
const reader = proc.stderr.getReader();
|
|
57
|
+
const decoder = new TextDecoder();
|
|
58
|
+
try {
|
|
59
|
+
while (true) {
|
|
60
|
+
const { done, value } = await reader.read();
|
|
61
|
+
if (done)
|
|
62
|
+
break;
|
|
63
|
+
const output = decoder.decode(value);
|
|
64
|
+
stderrOutput.push(output);
|
|
65
|
+
if (output.toLowerCase().includes('error')) {
|
|
66
|
+
console.error(`[${tag}] ${output.trim()}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
console.error(`[${tag}] Error reading stderr:`, e);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
await errorReader();
|
|
75
|
+
const exitCode = await proc.exited;
|
|
76
|
+
// Cleanup temp directory if needed
|
|
77
|
+
if (buildContext.requiresCleanup && buildContext.tempDir) {
|
|
78
|
+
await fs.rm(buildContext.tempDir, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
if (exitCode !== 0) {
|
|
81
|
+
const errorOutput = stderrOutput.join('');
|
|
82
|
+
throw new Error(`Build exited with code ${exitCode}${errorOutput ? `\n${errorOutput}` : ''}`);
|
|
83
|
+
}
|
|
84
|
+
// Verify image was created
|
|
85
|
+
let verified = false;
|
|
86
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
87
|
+
const verifyProc = Bun.spawn(['docker', 'images', '-q', tag], {
|
|
88
|
+
stdout: 'pipe',
|
|
89
|
+
stderr: 'pipe',
|
|
90
|
+
});
|
|
91
|
+
let verifyOutput = '';
|
|
92
|
+
if (verifyProc.stdout) {
|
|
93
|
+
verifyOutput = await Bun.readableStreamToText(verifyProc.stdout);
|
|
94
|
+
}
|
|
95
|
+
const verifyExitCode = await verifyProc.exited;
|
|
96
|
+
if (verifyExitCode === 0 && verifyOutput.trim()) {
|
|
97
|
+
verified = true;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
if (attempt < 4) {
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!verified) {
|
|
105
|
+
const errorOutput = stderrOutput.join('');
|
|
106
|
+
throw new Error(`Build completed but image ${tag} not found after verification attempts${errorOutput ? `\nBuild output: ${errorOutput}` : ''}`);
|
|
107
|
+
}
|
|
108
|
+
return { tag, ref: tag };
|
|
109
|
+
},
|
|
110
|
+
catch: (e) => new Error(`Image "${tag}" build failed: ${e}`),
|
|
111
|
+
});
|
|
112
|
+
return result;
|
|
113
|
+
}).pipe(Effect.mapError((e) => toHarpoonError(e, tag, ImageError)));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resources Module
|
|
3
|
+
*
|
|
4
|
+
* Exports Docker resource constructors and types.
|
|
5
|
+
*/
|
|
6
|
+
export { Network } from './network';
|
|
7
|
+
export type { NetworkConfig, NetworkResource } from './network';
|
|
8
|
+
export { Container } from './container';
|
|
9
|
+
export type { ContainerConfig, ContainerResource, PortMapping, ShutdownMetadata, } from './container';
|
|
10
|
+
export { Image } from './image';
|
|
11
|
+
export type { ImageConfig, ImageResource } from './image';
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/resources/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAEhE,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,YAAY,EACV,eAAe,EACf,iBAAiB,EACjB,WAAW,EACX,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAChC,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network Resource Module
|
|
3
|
+
*
|
|
4
|
+
* Manages Docker network lifecycle using Effect's acquireRelease pattern.
|
|
5
|
+
* Ensures networks are always cleaned up, even during interruption.
|
|
6
|
+
*/
|
|
7
|
+
import { Effect, Scope } from 'effect';
|
|
8
|
+
import Docker from 'dockerode';
|
|
9
|
+
export interface NetworkConfig {
|
|
10
|
+
name: string;
|
|
11
|
+
driver?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface NetworkResource {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly name: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Creates a Docker network resource with automatic lifecycle management.
|
|
19
|
+
*/
|
|
20
|
+
export declare const Network: (resourceId: string, config: NetworkConfig, docker: Docker) => Effect.Effect<NetworkResource, Error, Scope.Scope>;
|
|
21
|
+
//# sourceMappingURL=network.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"network.d.ts","sourceRoot":"","sources":["../../src/resources/network.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,MAAM,EAAE,KAAK,EAAQ,MAAM,QAAQ,CAAC;AAC7C,OAAO,MAAM,MAAM,WAAW,CAAC;AAG/B,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAiCD;;GAEG;AACH,eAAO,MAAM,OAAO,GAClB,YAAY,MAAM,EAClB,QAAQ,aAAa,EACrB,QAAQ,MAAM,KACb,MAAM,CAAC,MAAM,CAAC,eAAe,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,CA+EjD,CAAC"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network Resource Module
|
|
3
|
+
*
|
|
4
|
+
* Manages Docker network lifecycle using Effect's acquireRelease pattern.
|
|
5
|
+
* Ensures networks are always cleaned up, even during interruption.
|
|
6
|
+
*/
|
|
7
|
+
import { Effect, Exit } from 'effect';
|
|
8
|
+
import { toHarpoonError, NetworkError } from '../errors';
|
|
9
|
+
// Constants
|
|
10
|
+
const CLEANUP_TIMEOUT_MS = 5_000; // 5 seconds max for network cleanup
|
|
11
|
+
/**
|
|
12
|
+
* Safely removes a network, handling all possible states.
|
|
13
|
+
*/
|
|
14
|
+
const safeRemoveNetwork = async (docker, networkId, networkName) => {
|
|
15
|
+
try {
|
|
16
|
+
const network = docker.getNetwork(networkId);
|
|
17
|
+
await network.remove();
|
|
18
|
+
console.log(`[Cleanup] Network removed: ${networkName}`);
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
if (e.statusCode === 404) {
|
|
22
|
+
console.log(`[Cleanup] Network ${networkName} already removed`);
|
|
23
|
+
}
|
|
24
|
+
else if (e.message?.includes('has active endpoints')) {
|
|
25
|
+
console.warn(`[Cleanup] Network ${networkName} has active endpoints, skipping removal`);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
console.error(`[Cleanup] Error removing network ${networkName}:`, e.message);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Creates a Docker network resource with automatic lifecycle management.
|
|
34
|
+
*/
|
|
35
|
+
export const Network = (resourceId, config, docker) => Effect.acquireRelease(
|
|
36
|
+
// ACQUIRE: Create the network
|
|
37
|
+
Effect.tryPromise({
|
|
38
|
+
try: async () => {
|
|
39
|
+
// Check if network already exists
|
|
40
|
+
const networks = await docker.listNetworks({
|
|
41
|
+
filters: { name: [config.name] },
|
|
42
|
+
});
|
|
43
|
+
const existing = networks.find((n) => n.Name === config.name);
|
|
44
|
+
if (existing) {
|
|
45
|
+
// Try to remove existing network for clean state
|
|
46
|
+
try {
|
|
47
|
+
const net = docker.getNetwork(existing.Id);
|
|
48
|
+
await net.remove();
|
|
49
|
+
console.log(`[Network] Removed existing network: ${config.name}`);
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
if (e.message?.includes('has active endpoints')) {
|
|
53
|
+
// Network in use - try to reuse it
|
|
54
|
+
console.warn(`[Network] ${config.name} in use, reusing existing network`);
|
|
55
|
+
return { id: existing.Id, name: config.name };
|
|
56
|
+
}
|
|
57
|
+
// Other errors - try to proceed with creation anyway
|
|
58
|
+
console.warn(`[Network] Could not remove ${config.name}: ${e.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Create new network
|
|
62
|
+
const network = await docker.createNetwork({
|
|
63
|
+
Name: config.name,
|
|
64
|
+
Driver: config.driver ?? 'bridge',
|
|
65
|
+
});
|
|
66
|
+
return { id: network.id, name: config.name };
|
|
67
|
+
},
|
|
68
|
+
catch: (e) => new Error(`Network "${resourceId}" creation failed: ${e}`),
|
|
69
|
+
}).pipe(Effect.mapError((e) => toHarpoonError(e, resourceId, NetworkError))),
|
|
70
|
+
// RELEASE: Cleanup function
|
|
71
|
+
(resource, exit) => Effect.gen(function* () {
|
|
72
|
+
const isInterrupted = Exit.isInterrupted(exit);
|
|
73
|
+
if (isInterrupted) {
|
|
74
|
+
console.log(`[Cleanup] Interrupted - cleaning up network: ${resource.name}`);
|
|
75
|
+
}
|
|
76
|
+
yield* Effect.tryPromise({
|
|
77
|
+
try: () => Promise.race([
|
|
78
|
+
safeRemoveNetwork(docker, resource.id, resource.name),
|
|
79
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Network cleanup timeout')), CLEANUP_TIMEOUT_MS)),
|
|
80
|
+
]),
|
|
81
|
+
catch: (e) => {
|
|
82
|
+
console.error(`[Cleanup] Failed to clean up network ${resource.name}:`, e);
|
|
83
|
+
return null;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}).pipe(Effect.catchAll(() => Effect.void), Effect.uninterruptible));
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@docker-harpoon/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Core Docker resource primitives and binding interface",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": ["dist"],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"typecheck": "tsc --noEmit"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"dockerode": "^4.0.9",
|
|
22
|
+
"effect": "^3.19.14",
|
|
23
|
+
"zod": "^4.3.5"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"bun": ">=1.0.0"
|
|
27
|
+
}
|
|
28
|
+
}
|