@docker-harpoon/core 0.1.3 → 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__/bindings.test.js +9 -5
- package/dist/__tests__/container.test.js +30 -9
- package/dist/__tests__/database.test.js +0 -14
- package/dist/__tests__/docker-infra.template.d.ts +2 -0
- package/dist/__tests__/docker-infra.template.d.ts.map +1 -0
- package/dist/__tests__/docker-infra.template.js +174 -0
- package/dist/__tests__/test-setup.d.ts +9 -0
- package/dist/__tests__/test-setup.d.ts.map +1 -0
- package/dist/__tests__/test-setup.js +27 -0
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/promise.d.ts +13 -3
- package/dist/api/promise.d.ts.map +1 -1
- package/dist/api/promise.js +33 -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 +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/resources/container.d.ts +25 -2
- package/dist/resources/container.d.ts.map +1 -1
- package/dist/resources/container.js +301 -77
- package/dist/resources/image.d.ts.map +1 -1
- package/dist/resources/image.js +14 -35
- package/dist/resources/index.d.ts +1 -1
- package/dist/resources/index.d.ts.map +1 -1
- package/dist/resources/network.d.ts.map +1 -1
- package/dist/resources/network.js +23 -9
- package/dist/resources/schemas.d.ts +178 -18
- package/dist/resources/schemas.d.ts.map +1 -1
- package/dist/resources/schemas.js +2 -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,9 +152,14 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
|
|
|
142
152
|
NetworkMode: config.networks?.[0]?.name,
|
|
143
153
|
AutoRemove: false,
|
|
144
154
|
PortBindings,
|
|
155
|
+
Binds: config.volumes?.map((v) => `${v.hostPath}:${v.containerPath}${v.readonly ? ':ro' : ''}`),
|
|
156
|
+
CapAdd: config.capAdd,
|
|
157
|
+
CapDrop: config.capDrop,
|
|
158
|
+
Privileged: config.privileged,
|
|
145
159
|
},
|
|
146
160
|
ExposedPorts,
|
|
147
161
|
Cmd: config.cmd,
|
|
162
|
+
User: config.user,
|
|
148
163
|
StopSignal: 'SIGTERM',
|
|
149
164
|
StopTimeout: 10,
|
|
150
165
|
});
|
|
@@ -229,8 +244,7 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
|
|
|
229
244
|
getIp: (networkName) => Effect.tryPromise({
|
|
230
245
|
try: async () => {
|
|
231
246
|
const data = await container.inspect();
|
|
232
|
-
return
|
|
233
|
-
'localhost');
|
|
247
|
+
return data.NetworkSettings.Networks[networkName]?.IPAddress ?? 'localhost';
|
|
234
248
|
},
|
|
235
249
|
catch: (e) => new Error(`getIp failed: ${e}`),
|
|
236
250
|
}),
|
|
@@ -244,12 +258,9 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
|
|
|
244
258
|
// Calculate CPU percentage
|
|
245
259
|
const cpuDelta = statsData.cpu_stats.cpu_usage.total_usage -
|
|
246
260
|
statsData.precpu_stats.cpu_usage.total_usage;
|
|
247
|
-
const systemDelta = statsData.cpu_stats.system_cpu_usage -
|
|
248
|
-
statsData.precpu_stats.system_cpu_usage;
|
|
261
|
+
const systemDelta = statsData.cpu_stats.system_cpu_usage - statsData.precpu_stats.system_cpu_usage;
|
|
249
262
|
const cpuCores = statsData.cpu_stats.online_cpus || 1;
|
|
250
|
-
const cpuPercent = systemDelta > 0
|
|
251
|
-
? (cpuDelta / systemDelta) * cpuCores * 100
|
|
252
|
-
: 0;
|
|
263
|
+
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * cpuCores * 100 : 0;
|
|
253
264
|
// Calculate memory stats
|
|
254
265
|
const memUsage = statsData.memory_stats?.usage ?? 0;
|
|
255
266
|
const memLimit = statsData.memory_stats?.limit ?? 0;
|
|
@@ -294,6 +305,67 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
|
|
|
294
305
|
},
|
|
295
306
|
catch: (e) => new Error(`stats failed: ${e}`),
|
|
296
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
|
+
},
|
|
297
369
|
/**
|
|
298
370
|
* Execute a command inside the container.
|
|
299
371
|
* Returns stdout, stderr, and exit code.
|
|
@@ -376,69 +448,218 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
|
|
|
376
448
|
const stdout = opts.stdout ?? true;
|
|
377
449
|
const stderr = opts.stderr ?? true;
|
|
378
450
|
const timestamps = opts.timestamps ?? false;
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
// Create a wrapper to make the stream async iterable
|
|
389
|
-
const readable = stream;
|
|
390
|
-
// Convert NodeJS.ReadableStream to AsyncIterable<LogLine>
|
|
391
|
-
async function* logIterator() {
|
|
392
|
-
const chunks = [];
|
|
393
|
-
// Use Promise-based iteration for ReadableStream
|
|
394
|
-
for await (const chunk of readable) {
|
|
395
|
-
// Parse Docker log format (8-byte header + payload)
|
|
396
|
-
let offset = 0;
|
|
397
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
398
|
-
while (offset < buffer.length) {
|
|
399
|
-
if (offset + 8 > buffer.length) {
|
|
400
|
-
// Incomplete header, yield rest as stdout
|
|
451
|
+
const follow = opts.follow ?? false;
|
|
452
|
+
// Helper to parse Docker log format from a buffer
|
|
453
|
+
function* parseLogBuffer(buffer) {
|
|
454
|
+
let offset = 0;
|
|
455
|
+
while (offset < buffer.length) {
|
|
456
|
+
if (offset + 8 > buffer.length) {
|
|
457
|
+
// Incomplete header, yield rest as stdout
|
|
458
|
+
const remaining = buffer.slice(offset).toString('utf-8').trim();
|
|
459
|
+
if (remaining) {
|
|
401
460
|
yield {
|
|
402
461
|
stream: 'stdout',
|
|
403
|
-
message:
|
|
462
|
+
message: remaining,
|
|
404
463
|
};
|
|
405
|
-
break;
|
|
406
464
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
const streamType = buffer.readUInt8(offset);
|
|
468
|
+
const size = buffer.readUInt32BE(offset + 4);
|
|
469
|
+
if (offset + 8 + size > buffer.length) {
|
|
470
|
+
// Incomplete payload
|
|
471
|
+
const remaining = buffer
|
|
472
|
+
.slice(offset + 8)
|
|
473
|
+
.toString('utf-8')
|
|
474
|
+
.trim();
|
|
475
|
+
if (remaining) {
|
|
411
476
|
yield {
|
|
412
477
|
stream: streamType === 2 ? 'stderr' : 'stdout',
|
|
413
|
-
message:
|
|
478
|
+
message: remaining,
|
|
414
479
|
};
|
|
415
|
-
break;
|
|
416
480
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
const payload = buffer.slice(offset + 8, offset + 8 + size);
|
|
484
|
+
const message = payload.toString('utf-8');
|
|
485
|
+
// Parse timestamp if present (format: "2024-01-14T21:00:00.000000000Z message")
|
|
486
|
+
let timestamp;
|
|
487
|
+
let content = message;
|
|
488
|
+
if (timestamps) {
|
|
489
|
+
const match = message.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)$/s);
|
|
490
|
+
if (match) {
|
|
491
|
+
timestamp = match[1];
|
|
492
|
+
content = match[2] ?? '';
|
|
428
493
|
}
|
|
429
|
-
yield {
|
|
430
|
-
stream: streamType === 2 ? 'stderr' : 'stdout',
|
|
431
|
-
timestamp,
|
|
432
|
-
message: content.trim(),
|
|
433
|
-
};
|
|
434
|
-
offset += 8 + size;
|
|
435
494
|
}
|
|
495
|
+
yield {
|
|
496
|
+
stream: streamType === 2 ? 'stderr' : 'stdout',
|
|
497
|
+
timestamp,
|
|
498
|
+
message: content.trim(),
|
|
499
|
+
};
|
|
500
|
+
offset += 8 + size;
|
|
436
501
|
}
|
|
437
502
|
}
|
|
438
|
-
return
|
|
503
|
+
// Dockerode has different return types based on follow option:
|
|
504
|
+
// - follow: false (or undefined) returns Buffer
|
|
505
|
+
// - follow: true returns NodeJS.ReadableStream
|
|
506
|
+
// We branch explicitly so TypeScript can infer correct types
|
|
507
|
+
if (follow) {
|
|
508
|
+
const readable = await container.logs({
|
|
509
|
+
follow: true,
|
|
510
|
+
stdout,
|
|
511
|
+
stderr,
|
|
512
|
+
since: opts.since,
|
|
513
|
+
until: opts.until,
|
|
514
|
+
tail: opts.tail,
|
|
515
|
+
timestamps,
|
|
516
|
+
});
|
|
517
|
+
async function* streamIterator() {
|
|
518
|
+
for await (const chunk of readable) {
|
|
519
|
+
if (!Buffer.isBuffer(chunk) && typeof chunk !== 'string') {
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
523
|
+
yield* parseLogBuffer(buffer);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return streamIterator();
|
|
527
|
+
}
|
|
528
|
+
// Non-follow mode: Docker returns Buffer directly
|
|
529
|
+
const buffer = await container.logs({
|
|
530
|
+
follow: false,
|
|
531
|
+
stdout,
|
|
532
|
+
stderr,
|
|
533
|
+
since: opts.since,
|
|
534
|
+
until: opts.until,
|
|
535
|
+
tail: opts.tail,
|
|
536
|
+
timestamps,
|
|
537
|
+
});
|
|
538
|
+
async function* bufferIterator() {
|
|
539
|
+
yield* parseLogBuffer(buffer);
|
|
540
|
+
}
|
|
541
|
+
return bufferIterator();
|
|
439
542
|
},
|
|
440
543
|
catch: (e) => new Error(`streamLogs failed: ${e}`),
|
|
441
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
|
+
},
|
|
442
663
|
};
|
|
443
664
|
// Call onStart hooks for bindings
|
|
444
665
|
// This is done in Effect context for proper error handling
|
|
@@ -446,19 +667,20 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
|
|
|
446
667
|
},
|
|
447
668
|
catch: (e) => new Error(`Container "${resourceId}" creation failed: ${e}`),
|
|
448
669
|
}).pipe(Effect.mapError((e) => toHarpoonError(e, resourceId, ContainerError)),
|
|
449
|
-
// Run onStart hooks after container creation
|
|
670
|
+
// Run onStart hooks after container creation (in parallel for performance)
|
|
450
671
|
Effect.tap((resource) => Effect.gen(function* () {
|
|
451
672
|
if (config.bindings) {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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 });
|
|
456
678
|
}
|
|
457
679
|
}
|
|
458
680
|
}))), (resource, exit) => Effect.gen(function* () {
|
|
459
681
|
const isInterrupted = Exit.isInterrupted(exit);
|
|
460
682
|
if (isInterrupted) {
|
|
461
|
-
|
|
683
|
+
yield* Effect.logDebug('Interrupted - force cleaning container').pipe(Effect.annotateLogs({ service: 'Container', containerId: resource.id }));
|
|
462
684
|
}
|
|
463
685
|
yield* Effect.tryPromise({
|
|
464
686
|
try: () => Promise.race([
|
|
@@ -466,7 +688,9 @@ Effect.tap((resource) => Effect.gen(function* () {
|
|
|
466
688
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Cleanup timeout')), CLEANUP_TIMEOUT_MS)),
|
|
467
689
|
]),
|
|
468
690
|
catch: (e) => {
|
|
469
|
-
|
|
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 });
|
|
470
694
|
return null;
|
|
471
695
|
},
|
|
472
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
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
export { Network } from './network';
|
|
7
7
|
export type { NetworkConfig, NetworkResource } from './network';
|
|
8
8
|
export { Container } from './container';
|
|
9
|
-
export type { ContainerConfig, ContainerResource, PortMapping, ShutdownMetadata, } from './container';
|
|
9
|
+
export type { ContainerConfig, ContainerResource, PortMapping, ShutdownMetadata, VolumeMapping, } from './container';
|
|
10
10
|
export { containerStatsSchema, execOptionsSchema, execResultSchema, logOptionsSchema, logLineSchema, cpuStatsSchema, memoryStatsSchema, networkStatsSchema, statsOptionsSchema, } from './schemas';
|
|
11
11
|
export type { ContainerStats, CpuStats, MemoryStats, NetworkStats, ExecOptions, ExecResult, LogOptions, LogLine, StatsOptions, } from './schemas';
|
|
12
12
|
export { Image } from './image';
|
|
@@ -1 +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,
|
|
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,EAChB,aAAa,GACd,MAAM,aAAa,CAAC;AAGrB,OAAO,EACL,oBAAoB,EACpB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,kBAAkB,GACnB,MAAM,WAAW,CAAC;AACnB,YAAY,EACV,cAAc,EACd,QAAQ,EACR,WAAW,EACX,YAAY,EACZ,WAAW,EACX,UAAU,EACV,UAAU,EACV,OAAO,EACP,YAAY,GACb,MAAM,WAAW,CAAC;AAEnB,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAChC,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC"}
|
|
@@ -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"}
|