@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.
Files changed (61) hide show
  1. package/dist/api/index.d.ts +7 -0
  2. package/dist/api/index.d.ts.map +1 -0
  3. package/dist/api/index.js +10 -0
  4. package/dist/api/promise.d.ts +179 -0
  5. package/dist/api/promise.d.ts.map +1 -0
  6. package/dist/api/promise.js +282 -0
  7. package/dist/bindings/index.d.ts +8 -0
  8. package/dist/bindings/index.d.ts.map +1 -0
  9. package/dist/bindings/index.js +6 -0
  10. package/dist/bindings/types.d.ts +116 -0
  11. package/dist/bindings/types.d.ts.map +1 -0
  12. package/dist/bindings/types.js +46 -0
  13. package/dist/build-strategies/index.d.ts +30 -0
  14. package/dist/build-strategies/index.d.ts.map +1 -0
  15. package/dist/build-strategies/index.js +47 -0
  16. package/dist/build-strategies/standard.d.ts +16 -0
  17. package/dist/build-strategies/standard.d.ts.map +1 -0
  18. package/dist/build-strategies/standard.js +39 -0
  19. package/dist/build-strategies/types.d.ts +81 -0
  20. package/dist/build-strategies/types.d.ts.map +1 -0
  21. package/dist/build-strategies/types.js +25 -0
  22. package/dist/config-patchers/index.d.ts +33 -0
  23. package/dist/config-patchers/index.d.ts.map +1 -0
  24. package/dist/config-patchers/index.js +75 -0
  25. package/dist/config-patchers/types.d.ts +89 -0
  26. package/dist/config-patchers/types.d.ts.map +1 -0
  27. package/dist/config-patchers/types.js +25 -0
  28. package/dist/dockerfile-transformers/core.d.ts +59 -0
  29. package/dist/dockerfile-transformers/core.d.ts.map +1 -0
  30. package/dist/dockerfile-transformers/core.js +271 -0
  31. package/dist/dockerfile-transformers/index.d.ts +42 -0
  32. package/dist/dockerfile-transformers/index.d.ts.map +1 -0
  33. package/dist/dockerfile-transformers/index.js +67 -0
  34. package/dist/dockerfile-transformers/types.d.ts +116 -0
  35. package/dist/dockerfile-transformers/types.d.ts.map +1 -0
  36. package/dist/dockerfile-transformers/types.js +29 -0
  37. package/dist/errors.d.ts +75 -0
  38. package/dist/errors.d.ts.map +1 -0
  39. package/dist/errors.js +119 -0
  40. package/dist/helpers/database.d.ts +54 -0
  41. package/dist/helpers/database.d.ts.map +1 -0
  42. package/dist/helpers/database.js +108 -0
  43. package/dist/helpers/index.d.ts +8 -0
  44. package/dist/helpers/index.d.ts.map +1 -0
  45. package/dist/helpers/index.js +6 -0
  46. package/dist/index.d.ts +41 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +44 -0
  49. package/dist/resources/container.d.ts +42 -0
  50. package/dist/resources/container.d.ts.map +1 -0
  51. package/dist/resources/container.js +256 -0
  52. package/dist/resources/image.d.ts +37 -0
  53. package/dist/resources/image.d.ts.map +1 -0
  54. package/dist/resources/image.js +113 -0
  55. package/dist/resources/index.d.ts +12 -0
  56. package/dist/resources/index.d.ts.map +1 -0
  57. package/dist/resources/index.js +8 -0
  58. package/dist/resources/network.d.ts +21 -0
  59. package/dist/resources/network.d.ts.map +1 -0
  60. package/dist/resources/network.js +86 -0
  61. 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,8 @@
1
+ /**
2
+ * Resources Module
3
+ *
4
+ * Exports Docker resource constructors and types.
5
+ */
6
+ export { Network } from './network';
7
+ export { Container } from './container';
8
+ export { Image } from './image';
@@ -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
+ }