@docker-harpoon/core 0.1.4 → 0.1.5
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/__tests__/test-setup.d.ts.map +1 -1
- package/dist/__tests__/test-setup.js +8 -1
- package/dist/api/promise.d.ts +1 -1
- package/dist/api/promise.d.ts.map +1 -1
- package/dist/api/promise.js +28 -18
- package/dist/bindings/index.d.ts +2 -2
- package/dist/bindings/index.d.ts.map +1 -1
- package/dist/bindings/index.js +1 -1
- package/dist/bindings/types.d.ts.map +1 -1
- package/dist/bindings/types.js +1 -3
- package/dist/build-strategies/types.d.ts.map +1 -1
- package/dist/config-patchers/index.d.ts.map +1 -1
- package/dist/config-patchers/types.d.ts.map +1 -1
- package/dist/dockerfile-transformers/core.d.ts.map +1 -1
- package/dist/dockerfile-transformers/core.js +2 -5
- package/dist/dockerfile-transformers/index.d.ts.map +1 -1
- package/dist/dockerfile-transformers/types.d.ts.map +1 -1
- package/dist/errors.d.ts +6 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -3
- package/dist/helpers/database.d.ts.map +1 -1
- package/dist/helpers/database.js +1 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/resources/container.d.ts +7 -2
- package/dist/resources/container.d.ts.map +1 -1
- package/dist/resources/container.js +222 -31
- package/dist/resources/image.d.ts.map +1 -1
- package/dist/resources/image.js +14 -35
- package/dist/resources/network.d.ts.map +1 -1
- package/dist/resources/network.js +23 -9
- package/dist/resources/schemas.d.ts +177 -18
- package/dist/resources/schemas.d.ts.map +1 -1
- package/dist/resources/schemas.js +1 -1
- package/dist/services/CircuitBreaker.d.ts +83 -0
- package/dist/services/CircuitBreaker.d.ts.map +1 -0
- package/dist/services/CircuitBreaker.js +164 -0
- package/dist/services/ContainerPool.d.ts +82 -0
- package/dist/services/ContainerPool.d.ts.map +1 -0
- package/dist/services/ContainerPool.js +186 -0
- package/dist/services/DockerBatcher.d.ts +74 -0
- package/dist/services/DockerBatcher.d.ts.map +1 -0
- package/dist/services/DockerBatcher.js +107 -0
- package/dist/services/DockerClient.d.ts +125 -0
- package/dist/services/DockerClient.d.ts.map +1 -0
- package/dist/services/DockerClient.js +220 -0
- package/dist/services/DockerErrors.d.ts +145 -0
- package/dist/services/DockerErrors.d.ts.map +1 -0
- package/dist/services/DockerErrors.js +224 -0
- package/dist/services/DockerRateLimiter.d.ts +80 -0
- package/dist/services/DockerRateLimiter.d.ts.map +1 -0
- package/dist/services/DockerRateLimiter.js +93 -0
- package/dist/services/EventBus.d.ts +126 -0
- package/dist/services/EventBus.d.ts.map +1 -0
- package/dist/services/EventBus.js +111 -0
- package/dist/services/Harpoon.d.ts +151 -0
- package/dist/services/Harpoon.d.ts.map +1 -0
- package/dist/services/Harpoon.js +148 -0
- package/dist/services/HarpoonConfig.d.ts +60 -0
- package/dist/services/HarpoonConfig.d.ts.map +1 -0
- package/dist/services/HarpoonConfig.js +67 -0
- package/dist/services/HarpoonLogger.d.ts +36 -0
- package/dist/services/HarpoonLogger.d.ts.map +1 -0
- package/dist/services/HarpoonLogger.js +94 -0
- package/dist/services/ReadinessCoordinator.d.ts +128 -0
- package/dist/services/ReadinessCoordinator.d.ts.map +1 -0
- package/dist/services/ReadinessCoordinator.js +170 -0
- package/dist/services/ResourceTracker.d.ts +74 -0
- package/dist/services/ResourceTracker.d.ts.map +1 -0
- package/dist/services/ResourceTracker.js +145 -0
- package/dist/services/index.d.ts +29 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +47 -0
- package/dist/testing/helpers.d.ts +114 -0
- package/dist/testing/helpers.d.ts.map +1 -0
- package/dist/testing/helpers.js +140 -0
- package/dist/testing/index.d.ts +29 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +47 -0
- package/dist/testing/mocks.d.ts +66 -0
- package/dist/testing/mocks.d.ts.map +1 -0
- package/dist/testing/mocks.js +224 -0
- package/dist/utils/process.d.ts +24 -0
- package/dist/utils/process.d.ts.map +1 -0
- package/dist/utils/process.js +49 -0
- package/package.json +12 -8
|
@@ -6,17 +6,28 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Supports bindings for environment variable injection and lifecycle hooks.
|
|
8
8
|
*/
|
|
9
|
-
import { Effect, Schedule, Exit } from 'effect';
|
|
9
|
+
import { Effect, Schedule, Exit, Stream } from 'effect';
|
|
10
10
|
import { toHarpoonError, ContainerError } from '../errors';
|
|
11
11
|
import { mergeBindingsEnv } from '../bindings/types';
|
|
12
12
|
import { containerStatsSchema, execOptionsSchema, execResultSchema, logOptionsSchema, } from './schemas';
|
|
13
13
|
const CLEANUP_TIMEOUT_MS = 30_000; // 30 seconds for database graceful shutdown
|
|
14
|
+
const makeContainerLogger = (containerName) => ({
|
|
15
|
+
debug: (message, meta) => {
|
|
16
|
+
if (process.env.HARPOON_LOG_LEVEL === 'debug') {
|
|
17
|
+
console.log(`[Container:${containerName}] ${message}`, meta ?? '');
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
info: (message, _meta) => console.log(`[Container:${containerName}] ${message}`),
|
|
21
|
+
warn: (message, _meta) => console.warn(`[Container:${containerName}] ${message}`),
|
|
22
|
+
error: (message, meta) => console.error(`[Container:${containerName}] ${message}`, meta ?? ''),
|
|
23
|
+
});
|
|
14
24
|
const safeRemoveContainer = async (docker, containerId, containerName) => {
|
|
15
25
|
const container = docker.getContainer(containerId);
|
|
26
|
+
const log = makeContainerLogger(containerName);
|
|
16
27
|
try {
|
|
17
28
|
const data = await container.inspect();
|
|
18
29
|
if (data.State.Running) {
|
|
19
|
-
|
|
30
|
+
log.info('Stopping container');
|
|
20
31
|
try {
|
|
21
32
|
await container.kill({ signal: 'SIGTERM' });
|
|
22
33
|
await new Promise((resolve) => {
|
|
@@ -46,7 +57,7 @@ const safeRemoveContainer = async (docker, containerId, containerName) => {
|
|
|
46
57
|
setTimeout(checkStopped, 100);
|
|
47
58
|
};
|
|
48
59
|
const timeout = setTimeout(() => {
|
|
49
|
-
|
|
60
|
+
log.warn('Container did not stop gracefully, forcing');
|
|
50
61
|
container.kill({ signal: 'SIGKILL' }).catch(() => { });
|
|
51
62
|
safeResolve();
|
|
52
63
|
}, 5000);
|
|
@@ -55,33 +66,34 @@ const safeRemoveContainer = async (docker, containerId, containerName) => {
|
|
|
55
66
|
}
|
|
56
67
|
catch (e) {
|
|
57
68
|
if (!e.message?.includes('is not running')) {
|
|
58
|
-
|
|
69
|
+
log.warn(`Error stopping: ${e.message}`);
|
|
59
70
|
}
|
|
60
71
|
}
|
|
61
72
|
}
|
|
62
73
|
await container.remove({ force: true });
|
|
63
|
-
|
|
74
|
+
log.info('Container removed');
|
|
64
75
|
}
|
|
65
76
|
catch (e) {
|
|
66
77
|
if (e.statusCode === 404) {
|
|
67
|
-
|
|
78
|
+
log.debug('Container already removed');
|
|
68
79
|
}
|
|
69
80
|
else {
|
|
70
|
-
|
|
81
|
+
log.error(`Error removing container: ${e.message}`);
|
|
71
82
|
}
|
|
72
83
|
}
|
|
73
84
|
};
|
|
74
85
|
export const Container = (resourceId, config, docker) => Effect.acquireRelease(Effect.tryPromise({
|
|
75
86
|
try: async () => {
|
|
87
|
+
const log = makeContainerLogger(resourceId);
|
|
76
88
|
try {
|
|
77
89
|
const existingContainer = docker.getContainer(resourceId);
|
|
78
90
|
await existingContainer.inspect();
|
|
79
|
-
|
|
91
|
+
log.info('Found existing container, removing...');
|
|
80
92
|
await safeRemoveContainer(docker, resourceId, resourceId);
|
|
81
93
|
}
|
|
82
94
|
catch (e) {
|
|
83
95
|
if (e.statusCode !== 404) {
|
|
84
|
-
|
|
96
|
+
log.warn(`Warning checking for existing container: ${e.message}`);
|
|
85
97
|
}
|
|
86
98
|
}
|
|
87
99
|
if (config.ports?.length) {
|
|
@@ -95,12 +107,12 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
|
|
|
95
107
|
if (!usesConflictingPort)
|
|
96
108
|
continue;
|
|
97
109
|
const name = c.Names?.[0]?.replace(/^\//, '') ?? c.Id.substring(0, 12);
|
|
98
|
-
|
|
110
|
+
log.info(`Found container "${name}" using port(s) ${externalPorts.join(', ')}, removing...`);
|
|
99
111
|
await safeRemoveContainer(docker, c.Id, name);
|
|
100
112
|
}
|
|
101
113
|
}
|
|
102
114
|
catch (e) {
|
|
103
|
-
|
|
115
|
+
log.warn(`Warning checking for port conflicts: ${e.message}`);
|
|
104
116
|
}
|
|
105
117
|
}
|
|
106
118
|
const PortBindings = {};
|
|
@@ -113,7 +125,7 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
|
|
|
113
125
|
try {
|
|
114
126
|
const image = docker.getImage(config.image);
|
|
115
127
|
await image.inspect();
|
|
116
|
-
|
|
128
|
+
log.debug(`Verified image exists: ${config.image}`);
|
|
117
129
|
}
|
|
118
130
|
catch (e) {
|
|
119
131
|
if (e.statusCode === 404) {
|
|
@@ -127,9 +139,7 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
|
|
|
127
139
|
throw new Error(`Failed to verify image "${config.image}": ${e.message}`);
|
|
128
140
|
}
|
|
129
141
|
// Merge env vars from bindings
|
|
130
|
-
const bindingsEnv = config.bindings
|
|
131
|
-
? mergeBindingsEnv(config.bindings)
|
|
132
|
-
: {};
|
|
142
|
+
const bindingsEnv = config.bindings ? mergeBindingsEnv(config.bindings) : {};
|
|
133
143
|
const finalEnv = {
|
|
134
144
|
...bindingsEnv,
|
|
135
145
|
...config.env, // User env takes precedence
|
|
@@ -142,7 +152,7 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
|
|
|
142
152
|
NetworkMode: config.networks?.[0]?.name,
|
|
143
153
|
AutoRemove: false,
|
|
144
154
|
PortBindings,
|
|
145
|
-
Binds: config.volumes?.map(v => `${v.hostPath}:${v.containerPath}${v.readonly ? ':ro' : ''}`),
|
|
155
|
+
Binds: config.volumes?.map((v) => `${v.hostPath}:${v.containerPath}${v.readonly ? ':ro' : ''}`),
|
|
146
156
|
CapAdd: config.capAdd,
|
|
147
157
|
CapDrop: config.capDrop,
|
|
148
158
|
Privileged: config.privileged,
|
|
@@ -234,8 +244,7 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
|
|
|
234
244
|
getIp: (networkName) => Effect.tryPromise({
|
|
235
245
|
try: async () => {
|
|
236
246
|
const data = await container.inspect();
|
|
237
|
-
return
|
|
238
|
-
'localhost');
|
|
247
|
+
return data.NetworkSettings.Networks[networkName]?.IPAddress ?? 'localhost';
|
|
239
248
|
},
|
|
240
249
|
catch: (e) => new Error(`getIp failed: ${e}`),
|
|
241
250
|
}),
|
|
@@ -249,12 +258,9 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
|
|
|
249
258
|
// Calculate CPU percentage
|
|
250
259
|
const cpuDelta = statsData.cpu_stats.cpu_usage.total_usage -
|
|
251
260
|
statsData.precpu_stats.cpu_usage.total_usage;
|
|
252
|
-
const systemDelta = statsData.cpu_stats.system_cpu_usage -
|
|
253
|
-
statsData.precpu_stats.system_cpu_usage;
|
|
261
|
+
const systemDelta = statsData.cpu_stats.system_cpu_usage - statsData.precpu_stats.system_cpu_usage;
|
|
254
262
|
const cpuCores = statsData.cpu_stats.online_cpus || 1;
|
|
255
|
-
const cpuPercent = systemDelta > 0
|
|
256
|
-
? (cpuDelta / systemDelta) * cpuCores * 100
|
|
257
|
-
: 0;
|
|
263
|
+
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * cpuCores * 100 : 0;
|
|
258
264
|
// Calculate memory stats
|
|
259
265
|
const memUsage = statsData.memory_stats?.usage ?? 0;
|
|
260
266
|
const memLimit = statsData.memory_stats?.limit ?? 0;
|
|
@@ -299,6 +305,67 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
|
|
|
299
305
|
},
|
|
300
306
|
catch: (e) => new Error(`stats failed: ${e}`),
|
|
301
307
|
}),
|
|
308
|
+
/**
|
|
309
|
+
* Stream container stats as an Effect Stream.
|
|
310
|
+
* Polls every 1 second using Stream.repeatEffectWithSchedule.
|
|
311
|
+
*/
|
|
312
|
+
statsStream: () => {
|
|
313
|
+
const getStats = Effect.tryPromise({
|
|
314
|
+
try: async () => {
|
|
315
|
+
const statsData = await container.stats({ stream: false });
|
|
316
|
+
// Calculate CPU percentage
|
|
317
|
+
const cpuDelta = statsData.cpu_stats.cpu_usage.total_usage -
|
|
318
|
+
statsData.precpu_stats.cpu_usage.total_usage;
|
|
319
|
+
const systemDelta = statsData.cpu_stats.system_cpu_usage - statsData.precpu_stats.system_cpu_usage;
|
|
320
|
+
const cpuCores = statsData.cpu_stats.online_cpus || 1;
|
|
321
|
+
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * cpuCores * 100 : 0;
|
|
322
|
+
// Calculate memory stats
|
|
323
|
+
const memUsage = statsData.memory_stats?.usage ?? 0;
|
|
324
|
+
const memLimit = statsData.memory_stats?.limit ?? 0;
|
|
325
|
+
const memPercent = memLimit > 0 ? (memUsage / memLimit) * 100 : 0;
|
|
326
|
+
// Calculate network stats (sum all interfaces)
|
|
327
|
+
const networks = statsData.networks ?? {};
|
|
328
|
+
let rxBytes = 0;
|
|
329
|
+
let txBytes = 0;
|
|
330
|
+
let rxPackets = 0;
|
|
331
|
+
let txPackets = 0;
|
|
332
|
+
for (const iface of Object.values(networks)) {
|
|
333
|
+
rxBytes += iface.rx_bytes ?? 0;
|
|
334
|
+
txBytes += iface.tx_bytes ?? 0;
|
|
335
|
+
rxPackets += iface.rx_packets ?? 0;
|
|
336
|
+
txPackets += iface.tx_packets ?? 0;
|
|
337
|
+
}
|
|
338
|
+
const result = {
|
|
339
|
+
cpu: {
|
|
340
|
+
usage: statsData.cpu_stats.cpu_usage.total_usage,
|
|
341
|
+
system: statsData.cpu_stats.system_cpu_usage,
|
|
342
|
+
percent: Math.round(cpuPercent * 100) / 100,
|
|
343
|
+
cores: cpuCores,
|
|
344
|
+
},
|
|
345
|
+
memory: {
|
|
346
|
+
usage: memUsage,
|
|
347
|
+
limit: memLimit,
|
|
348
|
+
percent: Math.round(memPercent * 100) / 100,
|
|
349
|
+
usageMB: Math.round((memUsage / 1024 / 1024) * 100) / 100,
|
|
350
|
+
limitMB: Math.round((memLimit / 1024 / 1024) * 100) / 100,
|
|
351
|
+
},
|
|
352
|
+
network: {
|
|
353
|
+
rxBytes,
|
|
354
|
+
txBytes,
|
|
355
|
+
rxPackets,
|
|
356
|
+
txPackets,
|
|
357
|
+
},
|
|
358
|
+
timestamp: new Date().toISOString(),
|
|
359
|
+
containerId: container.id,
|
|
360
|
+
};
|
|
361
|
+
return containerStatsSchema.parse(result);
|
|
362
|
+
},
|
|
363
|
+
catch: (e) => toHarpoonError(new Error(`statsStream failed: ${e}`), resourceId, ContainerError),
|
|
364
|
+
});
|
|
365
|
+
// Poll every 1 second
|
|
366
|
+
const schedule = Schedule.spaced('1 seconds');
|
|
367
|
+
return Stream.repeatEffectWithSchedule(getStats, schedule);
|
|
368
|
+
},
|
|
302
369
|
/**
|
|
303
370
|
* Execute a command inside the container.
|
|
304
371
|
* Returns stdout, stderr, and exit code.
|
|
@@ -401,7 +468,10 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
|
|
|
401
468
|
const size = buffer.readUInt32BE(offset + 4);
|
|
402
469
|
if (offset + 8 + size > buffer.length) {
|
|
403
470
|
// Incomplete payload
|
|
404
|
-
const remaining = buffer
|
|
471
|
+
const remaining = buffer
|
|
472
|
+
.slice(offset + 8)
|
|
473
|
+
.toString('utf-8')
|
|
474
|
+
.trim();
|
|
405
475
|
if (remaining) {
|
|
406
476
|
yield {
|
|
407
477
|
stream: streamType === 2 ? 'stderr' : 'stdout',
|
|
@@ -472,6 +542,124 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
|
|
|
472
542
|
},
|
|
473
543
|
catch: (e) => new Error(`streamLogs failed: ${e}`),
|
|
474
544
|
}),
|
|
545
|
+
/**
|
|
546
|
+
* Stream container logs as an Effect Stream.
|
|
547
|
+
* Internal API for Effect-based consumers.
|
|
548
|
+
* Uses Stream.asyncScoped for follow mode to ensure proper cleanup.
|
|
549
|
+
*/
|
|
550
|
+
logsStream: (options) => {
|
|
551
|
+
const opts = logOptionsSchema.parse(options ?? {});
|
|
552
|
+
const stdout = opts.stdout ?? true;
|
|
553
|
+
const stderr = opts.stderr ?? true;
|
|
554
|
+
const timestamps = opts.timestamps ?? false;
|
|
555
|
+
const follow = opts.follow ?? false;
|
|
556
|
+
// Helper to parse Docker log format from a buffer
|
|
557
|
+
const parseLogBuffer = (buffer) => {
|
|
558
|
+
const lines = [];
|
|
559
|
+
let bufferOffset = 0;
|
|
560
|
+
while (bufferOffset < buffer.length) {
|
|
561
|
+
if (bufferOffset + 8 > buffer.length) {
|
|
562
|
+
const remaining = buffer.slice(bufferOffset).toString('utf-8').trim();
|
|
563
|
+
if (remaining) {
|
|
564
|
+
lines.push({ stream: 'stdout', message: remaining });
|
|
565
|
+
}
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
const streamType = buffer.readUInt8(bufferOffset);
|
|
569
|
+
const size = buffer.readUInt32BE(bufferOffset + 4);
|
|
570
|
+
if (bufferOffset + 8 + size > buffer.length) {
|
|
571
|
+
const remaining = buffer
|
|
572
|
+
.slice(bufferOffset + 8)
|
|
573
|
+
.toString('utf-8')
|
|
574
|
+
.trim();
|
|
575
|
+
if (remaining) {
|
|
576
|
+
lines.push({
|
|
577
|
+
stream: streamType === 2 ? 'stderr' : 'stdout',
|
|
578
|
+
message: remaining,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
const payload = buffer.slice(bufferOffset + 8, bufferOffset + 8 + size);
|
|
584
|
+
const message = payload.toString('utf-8');
|
|
585
|
+
let timestamp;
|
|
586
|
+
let content = message;
|
|
587
|
+
if (timestamps) {
|
|
588
|
+
const match = message.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)$/s);
|
|
589
|
+
if (match) {
|
|
590
|
+
timestamp = match[1];
|
|
591
|
+
content = match[2] ?? '';
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
lines.push({
|
|
595
|
+
stream: streamType === 2 ? 'stderr' : 'stdout',
|
|
596
|
+
timestamp,
|
|
597
|
+
message: content.trim(),
|
|
598
|
+
});
|
|
599
|
+
bufferOffset += 8 + size;
|
|
600
|
+
}
|
|
601
|
+
return lines;
|
|
602
|
+
};
|
|
603
|
+
if (follow) {
|
|
604
|
+
// Follow mode: use Stream.asyncScoped for proper resource cleanup
|
|
605
|
+
return Stream.asyncScoped((emit) => Effect.gen(function* () {
|
|
606
|
+
const readable = yield* Effect.tryPromise({
|
|
607
|
+
try: () => container.logs({
|
|
608
|
+
follow: true,
|
|
609
|
+
stdout,
|
|
610
|
+
stderr,
|
|
611
|
+
since: opts.since,
|
|
612
|
+
until: opts.until,
|
|
613
|
+
tail: opts.tail,
|
|
614
|
+
timestamps,
|
|
615
|
+
}),
|
|
616
|
+
catch: (e) => toHarpoonError(new Error(`logsStream failed: ${e}`), resourceId, ContainerError),
|
|
617
|
+
});
|
|
618
|
+
// Set up event handlers
|
|
619
|
+
const onData = (chunk) => {
|
|
620
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
621
|
+
const lines = parseLogBuffer(buffer);
|
|
622
|
+
for (const line of lines) {
|
|
623
|
+
emit.single(line);
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
const onError = (err) => {
|
|
627
|
+
emit.fail(toHarpoonError(err, resourceId, ContainerError));
|
|
628
|
+
};
|
|
629
|
+
const onEnd = () => {
|
|
630
|
+
emit.end();
|
|
631
|
+
};
|
|
632
|
+
readable.on('data', onData);
|
|
633
|
+
readable.on('error', onError);
|
|
634
|
+
readable.on('end', onEnd);
|
|
635
|
+
// Register cleanup on scope close
|
|
636
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => {
|
|
637
|
+
readable.removeListener('data', onData);
|
|
638
|
+
readable.removeListener('error', onError);
|
|
639
|
+
readable.removeListener('end', onEnd);
|
|
640
|
+
if (readable.destroy) {
|
|
641
|
+
readable.destroy();
|
|
642
|
+
}
|
|
643
|
+
}));
|
|
644
|
+
}));
|
|
645
|
+
}
|
|
646
|
+
// Non-follow mode: emit all lines from buffer
|
|
647
|
+
return Stream.fromEffect(Effect.tryPromise({
|
|
648
|
+
try: async () => {
|
|
649
|
+
const buffer = await container.logs({
|
|
650
|
+
follow: false,
|
|
651
|
+
stdout,
|
|
652
|
+
stderr,
|
|
653
|
+
since: opts.since,
|
|
654
|
+
until: opts.until,
|
|
655
|
+
tail: opts.tail,
|
|
656
|
+
timestamps,
|
|
657
|
+
});
|
|
658
|
+
return parseLogBuffer(buffer);
|
|
659
|
+
},
|
|
660
|
+
catch: (e) => toHarpoonError(new Error(`logsStream failed: ${e}`), resourceId, ContainerError),
|
|
661
|
+
})).pipe(Stream.flatMap((lines) => Stream.fromIterable(lines)));
|
|
662
|
+
},
|
|
475
663
|
};
|
|
476
664
|
// Call onStart hooks for bindings
|
|
477
665
|
// This is done in Effect context for proper error handling
|
|
@@ -479,19 +667,20 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
|
|
|
479
667
|
},
|
|
480
668
|
catch: (e) => new Error(`Container "${resourceId}" creation failed: ${e}`),
|
|
481
669
|
}).pipe(Effect.mapError((e) => toHarpoonError(e, resourceId, ContainerError)),
|
|
482
|
-
// Run onStart hooks after container creation
|
|
670
|
+
// Run onStart hooks after container creation (in parallel for performance)
|
|
483
671
|
Effect.tap((resource) => Effect.gen(function* () {
|
|
484
672
|
if (config.bindings) {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
673
|
+
const onStartHooks = Object.values(config.bindings)
|
|
674
|
+
.filter((binding) => binding.onStart !== undefined)
|
|
675
|
+
.map((binding) => binding.onStart(resource));
|
|
676
|
+
if (onStartHooks.length > 0) {
|
|
677
|
+
yield* Effect.all(onStartHooks, { concurrency: 4 });
|
|
489
678
|
}
|
|
490
679
|
}
|
|
491
680
|
}))), (resource, exit) => Effect.gen(function* () {
|
|
492
681
|
const isInterrupted = Exit.isInterrupted(exit);
|
|
493
682
|
if (isInterrupted) {
|
|
494
|
-
|
|
683
|
+
yield* Effect.logDebug('Interrupted - force cleaning container').pipe(Effect.annotateLogs({ service: 'Container', containerId: resource.id }));
|
|
495
684
|
}
|
|
496
685
|
yield* Effect.tryPromise({
|
|
497
686
|
try: () => Promise.race([
|
|
@@ -499,7 +688,9 @@ Effect.tap((resource) => Effect.gen(function* () {
|
|
|
499
688
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Cleanup timeout')), CLEANUP_TIMEOUT_MS)),
|
|
500
689
|
]),
|
|
501
690
|
catch: (e) => {
|
|
502
|
-
|
|
691
|
+
// Log error but don't fail - cleanup should be best effort
|
|
692
|
+
const log = makeContainerLogger(resource.name);
|
|
693
|
+
log.error(`Failed to clean up container`, { error: e });
|
|
503
694
|
return null;
|
|
504
695
|
},
|
|
505
696
|
});
|
|
@@ -1 +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;
|
|
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;AAMhC,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,kFAgGmD,CAAC"}
|
package/dist/resources/image.js
CHANGED
|
@@ -8,6 +8,7 @@ import { Effect } from 'effect';
|
|
|
8
8
|
import * as fs from 'fs/promises';
|
|
9
9
|
import { toHarpoonError, ImageError } from '../errors';
|
|
10
10
|
import { getBuildStrategy } from '../build-strategies';
|
|
11
|
+
import { execCommand } from '../utils/process';
|
|
11
12
|
/**
|
|
12
13
|
* Build a Docker image using the configured strategy.
|
|
13
14
|
*
|
|
@@ -45,34 +46,18 @@ export const Image = (tag, config) => Effect.gen(function* () {
|
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
args.push(buildContext.contextPath);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
}
|
|
49
|
+
// Execute docker build using cross-runtime utility
|
|
50
|
+
const buildResult = await execCommand('docker', args.slice(1), { captureStderr: true });
|
|
51
|
+
// Log errors from stderr if present
|
|
52
|
+
if (buildResult.stderr) {
|
|
53
|
+
for (const line of buildResult.stderr.split('\n')) {
|
|
54
|
+
if (line.toLowerCase().includes('error')) {
|
|
55
|
+
console.error(`[${tag}] ${line.trim()}`);
|
|
68
56
|
}
|
|
69
57
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
};
|
|
74
|
-
await errorReader();
|
|
75
|
-
const exitCode = await proc.exited;
|
|
58
|
+
}
|
|
59
|
+
const exitCode = buildResult.exitCode;
|
|
60
|
+
const stderrOutput = buildResult.stderr ? [buildResult.stderr] : [];
|
|
76
61
|
// Cleanup temp directory if needed
|
|
77
62
|
if (buildContext.requiresCleanup && buildContext.tempDir) {
|
|
78
63
|
await fs.rm(buildContext.tempDir, { recursive: true, force: true });
|
|
@@ -84,16 +69,10 @@ export const Image = (tag, config) => Effect.gen(function* () {
|
|
|
84
69
|
// Verify image was created
|
|
85
70
|
let verified = false;
|
|
86
71
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
stderr: 'pipe',
|
|
72
|
+
const verifyResult = await execCommand('docker', ['images', '-q', tag], {
|
|
73
|
+
captureStdout: true,
|
|
90
74
|
});
|
|
91
|
-
|
|
92
|
-
if (verifyProc.stdout) {
|
|
93
|
-
verifyOutput = await Bun.readableStreamToText(verifyProc.stdout);
|
|
94
|
-
}
|
|
95
|
-
const verifyExitCode = await verifyProc.exited;
|
|
96
|
-
if (verifyExitCode === 0 && verifyOutput.trim()) {
|
|
75
|
+
if (verifyResult.exitCode === 0 && verifyResult.stdout.trim()) {
|
|
97
76
|
verified = true;
|
|
98
77
|
break;
|
|
99
78
|
}
|
|
@@ -1 +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;
|
|
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;AAmDD;;GAEG;AACH,eAAO,MAAM,OAAO,mHA2EjB,CAAC"}
|
|
@@ -8,24 +8,35 @@ import { Effect, Exit } from 'effect';
|
|
|
8
8
|
import { toHarpoonError, NetworkError } from '../errors';
|
|
9
9
|
// Constants
|
|
10
10
|
const CLEANUP_TIMEOUT_MS = 5_000; // 5 seconds max for network cleanup
|
|
11
|
+
const makeNetworkLogger = (networkName) => ({
|
|
12
|
+
debug: (message) => {
|
|
13
|
+
if (process.env.HARPOON_LOG_LEVEL === 'debug') {
|
|
14
|
+
console.log(`[Network:${networkName}] ${message}`);
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
info: (message) => console.log(`[Network:${networkName}] ${message}`),
|
|
18
|
+
warn: (message) => console.warn(`[Network:${networkName}] ${message}`),
|
|
19
|
+
error: (message) => console.error(`[Network:${networkName}] ${message}`),
|
|
20
|
+
});
|
|
11
21
|
/**
|
|
12
22
|
* Safely removes a network, handling all possible states.
|
|
13
23
|
*/
|
|
14
24
|
const safeRemoveNetwork = async (docker, networkId, networkName) => {
|
|
25
|
+
const log = makeNetworkLogger(networkName);
|
|
15
26
|
try {
|
|
16
27
|
const network = docker.getNetwork(networkId);
|
|
17
28
|
await network.remove();
|
|
18
|
-
|
|
29
|
+
log.info('Network removed');
|
|
19
30
|
}
|
|
20
31
|
catch (e) {
|
|
21
32
|
if (e.statusCode === 404) {
|
|
22
|
-
|
|
33
|
+
log.debug('Network already removed');
|
|
23
34
|
}
|
|
24
35
|
else if (e.message?.includes('has active endpoints')) {
|
|
25
|
-
|
|
36
|
+
log.warn('Network has active endpoints, skipping removal');
|
|
26
37
|
}
|
|
27
38
|
else {
|
|
28
|
-
|
|
39
|
+
log.error(`Error removing network: ${e.message}`);
|
|
29
40
|
}
|
|
30
41
|
}
|
|
31
42
|
};
|
|
@@ -41,21 +52,22 @@ Effect.tryPromise({
|
|
|
41
52
|
filters: { name: [config.name] },
|
|
42
53
|
});
|
|
43
54
|
const existing = networks.find((n) => n.Name === config.name);
|
|
55
|
+
const log = makeNetworkLogger(config.name);
|
|
44
56
|
if (existing) {
|
|
45
57
|
// Try to remove existing network for clean state
|
|
46
58
|
try {
|
|
47
59
|
const net = docker.getNetwork(existing.Id);
|
|
48
60
|
await net.remove();
|
|
49
|
-
|
|
61
|
+
log.info('Removed existing network');
|
|
50
62
|
}
|
|
51
63
|
catch (e) {
|
|
52
64
|
if (e.message?.includes('has active endpoints')) {
|
|
53
65
|
// Network in use - try to reuse it
|
|
54
|
-
|
|
66
|
+
log.warn('Network in use, reusing existing network');
|
|
55
67
|
return { id: existing.Id, name: config.name };
|
|
56
68
|
}
|
|
57
69
|
// Other errors - try to proceed with creation anyway
|
|
58
|
-
|
|
70
|
+
log.warn(`Could not remove: ${e.message}`);
|
|
59
71
|
}
|
|
60
72
|
}
|
|
61
73
|
// Create new network
|
|
@@ -71,7 +83,7 @@ Effect.tryPromise({
|
|
|
71
83
|
(resource, exit) => Effect.gen(function* () {
|
|
72
84
|
const isInterrupted = Exit.isInterrupted(exit);
|
|
73
85
|
if (isInterrupted) {
|
|
74
|
-
|
|
86
|
+
yield* Effect.logDebug('Interrupted - cleaning up network').pipe(Effect.annotateLogs({ service: 'Network', networkId: resource.id }));
|
|
75
87
|
}
|
|
76
88
|
yield* Effect.tryPromise({
|
|
77
89
|
try: () => Promise.race([
|
|
@@ -79,7 +91,9 @@ Effect.tryPromise({
|
|
|
79
91
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Network cleanup timeout')), CLEANUP_TIMEOUT_MS)),
|
|
80
92
|
]),
|
|
81
93
|
catch: (e) => {
|
|
82
|
-
|
|
94
|
+
// Log error but don't fail - cleanup should be best effort
|
|
95
|
+
const log = makeNetworkLogger(resource.name);
|
|
96
|
+
log.error(`Failed to clean up network: ${e}`);
|
|
83
97
|
return null;
|
|
84
98
|
},
|
|
85
99
|
});
|