@stream44.studio/t44-docker.com 0.1.0-rc.3
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/.dco-signatures +9 -0
- package/.github/workflows/dco.yaml +12 -0
- package/.github/workflows/gordian-open-integrity.yaml +13 -0
- package/.github/workflows/test.yaml +29 -0
- package/.o/GordianOpenIntegrity-CurrentLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity-InceptionLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity.yaml +21 -0
- package/.o/stream44.studio/assets/Icon-v1.svg +1170 -0
- package/.repo-identifier +1 -0
- package/DCO.md +34 -0
- package/LICENSE.txt +23 -0
- package/README.md +73 -0
- package/caps/Cli.test.ts +116 -0
- package/caps/Cli.ts +134 -0
- package/caps/Container.test.ts +168 -0
- package/caps/Container.ts +484 -0
- package/caps/ContainerContext.test.ts +78 -0
- package/caps/ContainerContext.ts +111 -0
- package/caps/Containers.test.ts +59 -0
- package/caps/Containers.ts +47 -0
- package/caps/Hub.test.ts +107 -0
- package/caps/Hub.ts +367 -0
- package/caps/Image/tpl/Dockerfile.alpine +40 -0
- package/caps/Image/tpl/Dockerfile.distroless +45 -0
- package/caps/Image/tpl/package.json +8 -0
- package/caps/Image.test.ts +269 -0
- package/caps/Image.ts +623 -0
- package/caps/ImageContext.test.ts +130 -0
- package/caps/ImageContext.ts +126 -0
- package/caps/Project.test.ts +267 -0
- package/caps/Project.ts +304 -0
- package/lib/waitForFetch.ts +95 -0
- package/package.json +19 -0
- package/structs/Hub/WorkspaceConnectionConfig.ts +53 -0
- package/tsconfig.json +28 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { waitForFetch } from '../lib/waitForFetch'
|
|
2
|
+
|
|
3
|
+
export async function capsule({
|
|
4
|
+
encapsulate,
|
|
5
|
+
CapsulePropertyTypes,
|
|
6
|
+
makeImportStack
|
|
7
|
+
}: {
|
|
8
|
+
encapsulate: any
|
|
9
|
+
CapsulePropertyTypes: any
|
|
10
|
+
makeImportStack: any
|
|
11
|
+
}) {
|
|
12
|
+
|
|
13
|
+
return encapsulate({
|
|
14
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
15
|
+
'#@stream44.studio/encapsulate/structs/Capsule': {},
|
|
16
|
+
'#': {
|
|
17
|
+
cli: {
|
|
18
|
+
type: CapsulePropertyTypes.Mapping,
|
|
19
|
+
value: './Cli',
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
context: {
|
|
23
|
+
type: CapsulePropertyTypes.Mapping,
|
|
24
|
+
value: './ContainerContext',
|
|
25
|
+
options: { /* requires new instance */ },
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
containers: {
|
|
29
|
+
type: CapsulePropertyTypes.Mapping,
|
|
30
|
+
value: './Containers',
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// Internal state
|
|
34
|
+
_containerId: {
|
|
35
|
+
type: CapsulePropertyTypes.Literal,
|
|
36
|
+
value: undefined as string | undefined,
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
// --- Lifecycle helpers ---
|
|
40
|
+
|
|
41
|
+
ensureStopped: {
|
|
42
|
+
type: CapsulePropertyTypes.Function,
|
|
43
|
+
value: async function (this: any, containerContext?: { name?: string; verbose?: boolean }): Promise<void> {
|
|
44
|
+
const name = containerContext?.name ?? this.context.name;
|
|
45
|
+
const verbose = containerContext?.verbose ?? this.context.verbose;
|
|
46
|
+
if (!name) return;
|
|
47
|
+
try {
|
|
48
|
+
const output = await this.containers.list({
|
|
49
|
+
all: true,
|
|
50
|
+
filter: `name=${name}`,
|
|
51
|
+
format: '{{.ID}}\t{{.Names}}',
|
|
52
|
+
});
|
|
53
|
+
const lines = (output as string).split('\n').filter((line: string) => line.trim());
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
const [id, cname] = line.trim().split('\t');
|
|
56
|
+
if (cname === name || cname === `/${name}`) {
|
|
57
|
+
if (verbose) console.log(`Found existing container with name ${name} (ID: ${id}), removing...`);
|
|
58
|
+
try {
|
|
59
|
+
await this.remove({ containerId: id, force: true });
|
|
60
|
+
if (verbose) console.log(`✅ Removed existing container: ${id}`);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (verbose) console.log(`Warning: Failed to remove container ${id}: ${error}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (verbose) console.log(`Warning: Failed to check for existing containers: ${error}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
isRunning: {
|
|
73
|
+
type: CapsulePropertyTypes.Function,
|
|
74
|
+
value: async function (this: any, containerContext?: { ports?: { internal: number; external: number }[]; verbose?: boolean; retryDelayMs?: number; requestTimeoutMs?: number; timeoutMs?: number }): Promise<boolean> {
|
|
75
|
+
if (!this._containerId) return false;
|
|
76
|
+
const ports = containerContext?.ports ?? this.context.ports;
|
|
77
|
+
const verbose = containerContext?.verbose ?? this.context.verbose;
|
|
78
|
+
let hostPort: number | undefined;
|
|
79
|
+
if (ports.length > 0) {
|
|
80
|
+
hostPort = ports[0].external;
|
|
81
|
+
}
|
|
82
|
+
if (!hostPort) throw new Error('Cannot verify container health: no ports in containerContext');
|
|
83
|
+
return await waitForFetch({
|
|
84
|
+
url: `http://localhost:${hostPort}`,
|
|
85
|
+
status: true,
|
|
86
|
+
retryDelayMs: containerContext?.retryDelayMs ?? 1000,
|
|
87
|
+
requestTimeoutMs: containerContext?.requestTimeoutMs ?? 2000,
|
|
88
|
+
timeoutMs: containerContext?.timeoutMs ?? 30000,
|
|
89
|
+
verbose,
|
|
90
|
+
}) as boolean;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
list: {
|
|
95
|
+
type: CapsulePropertyTypes.Function,
|
|
96
|
+
value: async function (this: any, containerContext?: { image?: string }): Promise<any[]> {
|
|
97
|
+
const image = containerContext?.image ?? this.context.image;
|
|
98
|
+
const output = await this.containers.list({
|
|
99
|
+
all: true,
|
|
100
|
+
filter: `ancestor=${image}`,
|
|
101
|
+
format: '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}',
|
|
102
|
+
});
|
|
103
|
+
const containers: any[] = [];
|
|
104
|
+
for (const line of (output as string).split('\n')) {
|
|
105
|
+
const trimmed = line.trim();
|
|
106
|
+
if (!trimmed) continue;
|
|
107
|
+
const [id, name, img, status, ports] = trimmed.split('\t');
|
|
108
|
+
if (!id || !name) continue;
|
|
109
|
+
containers.push({ id, name, image: img || '', status: status || '', ports: ports || '' });
|
|
110
|
+
}
|
|
111
|
+
return containers;
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
cleanup: {
|
|
116
|
+
type: CapsulePropertyTypes.Function,
|
|
117
|
+
value: async function (this: any, containerContext?: { containerId?: string; force?: boolean; verbose?: boolean }): Promise<void> {
|
|
118
|
+
const containerId = containerContext?.containerId ?? this._containerId;
|
|
119
|
+
const verbose = containerContext?.verbose ?? this.context.verbose;
|
|
120
|
+
if (!containerId) return;
|
|
121
|
+
try { await this.stop({ containerId }); } catch (error) {
|
|
122
|
+
if (verbose) console.log(`Warning: Failed to stop container: ${error}`);
|
|
123
|
+
}
|
|
124
|
+
try { await this.remove({ containerId, force: containerContext?.force ?? true }); } catch (error) {
|
|
125
|
+
if (verbose) console.log(`Warning: Failed to remove container: ${error}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
getContainerId: {
|
|
131
|
+
type: CapsulePropertyTypes.Function,
|
|
132
|
+
value: function (this: any): string | undefined { return this._containerId; }
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
// --- Docker CLI methods ---
|
|
136
|
+
// Each accepts an optional containerContext (plain object from context.derive()).
|
|
137
|
+
// When omitted, this.context is used as the source of config.
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Run a Docker container.
|
|
141
|
+
* Pass a derived context object to override config without mutating this.context.
|
|
142
|
+
*/
|
|
143
|
+
run: {
|
|
144
|
+
type: CapsulePropertyTypes.Function,
|
|
145
|
+
value: async function (this: any, containerContext?: {
|
|
146
|
+
image?: string;
|
|
147
|
+
name?: string;
|
|
148
|
+
detach?: boolean;
|
|
149
|
+
ports?: { internal: number; external: number }[];
|
|
150
|
+
volumes?: string[];
|
|
151
|
+
env?: Record<string, string>;
|
|
152
|
+
removeOnExit?: boolean;
|
|
153
|
+
interactive?: boolean;
|
|
154
|
+
tty?: boolean;
|
|
155
|
+
workdir?: string;
|
|
156
|
+
network?: string;
|
|
157
|
+
platform?: string;
|
|
158
|
+
waitFor?: string;
|
|
159
|
+
waitTimeout?: number;
|
|
160
|
+
showOutput?: boolean;
|
|
161
|
+
forceColor?: boolean;
|
|
162
|
+
verbose?: boolean;
|
|
163
|
+
command?: string;
|
|
164
|
+
}): Promise<string> {
|
|
165
|
+
const ctx = containerContext ?? this.context.derive();
|
|
166
|
+
const {
|
|
167
|
+
image, name, detach = true, ports = [], volumes = [], env = {},
|
|
168
|
+
removeOnExit: remove = false, interactive = false, tty = false,
|
|
169
|
+
workdir, network, platform, waitFor, waitTimeout = 30000,
|
|
170
|
+
showOutput = false, forceColor = true, verbose = false,
|
|
171
|
+
command,
|
|
172
|
+
} = ctx;
|
|
173
|
+
const self = this;
|
|
174
|
+
|
|
175
|
+
let containerProc: ReturnType<typeof Bun.spawn> | null = null;
|
|
176
|
+
let logsProc: ReturnType<typeof Bun.spawn> | null = null;
|
|
177
|
+
let signalReceived = false;
|
|
178
|
+
|
|
179
|
+
const signalHandler = (signal: string) => {
|
|
180
|
+
if (verbose) console.error(`[run] Received ${signal}, killing spawned processes...`);
|
|
181
|
+
signalReceived = true;
|
|
182
|
+
if (containerProc && containerProc.exitCode === null) containerProc.kill();
|
|
183
|
+
if (logsProc && logsProc.exitCode === null) logsProc.kill();
|
|
184
|
+
};
|
|
185
|
+
const sigintHandler = () => signalHandler('SIGINT');
|
|
186
|
+
const sigtermHandler = () => signalHandler('SIGTERM');
|
|
187
|
+
process.on('SIGINT', sigintHandler);
|
|
188
|
+
process.on('SIGTERM', sigtermHandler);
|
|
189
|
+
const cleanup = () => {
|
|
190
|
+
process.off('SIGINT', sigintHandler);
|
|
191
|
+
process.off('SIGTERM', sigtermHandler);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const args = ['run'];
|
|
196
|
+
if (detach) args.push('-d');
|
|
197
|
+
if (remove) args.push('--rm');
|
|
198
|
+
if (interactive) args.push('-i');
|
|
199
|
+
if (tty) args.push('-t');
|
|
200
|
+
if (name) args.push('--name', name);
|
|
201
|
+
if (workdir) args.push('-w', workdir);
|
|
202
|
+
if (network) args.push('--network', network);
|
|
203
|
+
if (platform) args.push('--platform', platform);
|
|
204
|
+
for (const port of ports) args.push('-p', `${port.external}:${port.internal}`);
|
|
205
|
+
for (const volume of volumes) args.push('-v', volume);
|
|
206
|
+
const finalEnv = { ...env };
|
|
207
|
+
if (forceColor && !finalEnv.FORCE_COLOR) finalEnv.FORCE_COLOR = '1';
|
|
208
|
+
for (const [key, val] of Object.entries(finalEnv)) args.push('-e', `${key}=${val}`);
|
|
209
|
+
args.push(image);
|
|
210
|
+
|
|
211
|
+
if (command) {
|
|
212
|
+
const commandParts: string[] = [];
|
|
213
|
+
let current = '';
|
|
214
|
+
let inQuotes = false;
|
|
215
|
+
let quoteChar = '';
|
|
216
|
+
let escaped = false;
|
|
217
|
+
for (let i = 0; i < command.length; i++) {
|
|
218
|
+
const char = command[i];
|
|
219
|
+
if (escaped) { current += char; escaped = false; continue; }
|
|
220
|
+
if (char === '\\') { escaped = true; current += char; continue; }
|
|
221
|
+
if ((char === '"' || char === "'") && !inQuotes) { inQuotes = true; quoteChar = char; }
|
|
222
|
+
else if (char === quoteChar && inQuotes) { inQuotes = false; quoteChar = ''; }
|
|
223
|
+
else if (char === ' ' && !inQuotes) { if (current) { commandParts.push(current); current = ''; } }
|
|
224
|
+
else { current += char; }
|
|
225
|
+
}
|
|
226
|
+
if (current) commandParts.push(current);
|
|
227
|
+
args.push(...commandParts);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (verbose) console.log(`[run] Full command: docker ${args.join(' ')}`);
|
|
231
|
+
|
|
232
|
+
if (waitFor) {
|
|
233
|
+
containerProc = Bun.spawn(['docker', ...args], { stdout: 'pipe', stderr: 'pipe' });
|
|
234
|
+
const proc = containerProc;
|
|
235
|
+
const result = await new Response(proc.stdout as any).text();
|
|
236
|
+
await proc.exited;
|
|
237
|
+
if (proc.exitCode !== 0) {
|
|
238
|
+
const error = await new Response(proc.stderr as any).text();
|
|
239
|
+
throw new Error(`Failed to start container: ${error}`);
|
|
240
|
+
}
|
|
241
|
+
const containerId = result.trim();
|
|
242
|
+
if (signalReceived) { cleanup(); return containerId; }
|
|
243
|
+
|
|
244
|
+
logsProc = Bun.spawn(['docker', 'logs', '--tail', '100000', '-f', containerId], { stdout: 'pipe', stderr: 'pipe' });
|
|
245
|
+
const waitPattern = new RegExp(waitFor);
|
|
246
|
+
const timeoutMs = waitTimeout;
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const decoder = new TextDecoder();
|
|
250
|
+
let patternFoundFlag = false;
|
|
251
|
+
let resolvePattern: (() => void) | null = null;
|
|
252
|
+
const patternPromise = new Promise<void>((resolve) => { resolvePattern = resolve; });
|
|
253
|
+
|
|
254
|
+
const processStream = async (stream: ReadableStream<Uint8Array>, streamName: string, continueAfterPattern: boolean = false) => {
|
|
255
|
+
const reader = stream.getReader();
|
|
256
|
+
let buffer = '';
|
|
257
|
+
try {
|
|
258
|
+
while (true) {
|
|
259
|
+
const { done, value } = await reader.read();
|
|
260
|
+
if (done) {
|
|
261
|
+
if (buffer && (showOutput || verbose)) Bun.write(Bun.stdout, `[container:${streamName}] ${buffer}\n`);
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
buffer += decoder.decode(value, { stream: true });
|
|
265
|
+
const lines = buffer.split('\n');
|
|
266
|
+
buffer = lines.pop() || '';
|
|
267
|
+
for (const line of lines) {
|
|
268
|
+
if (showOutput || verbose) Bun.write(Bun.stdout, `[container:${streamName}] ${line}\n`);
|
|
269
|
+
if (!patternFoundFlag && waitPattern.test(line)) {
|
|
270
|
+
patternFoundFlag = true;
|
|
271
|
+
if (resolvePattern) resolvePattern();
|
|
272
|
+
if (!continueAfterPattern) return;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
} finally { reader.releaseLock(); }
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const continueAfterPattern = showOutput || verbose;
|
|
280
|
+
const streamProcessing = Promise.all([
|
|
281
|
+
processStream(logsProc.stdout as any, 'stdout', continueAfterPattern),
|
|
282
|
+
processStream(logsProc.stderr as any, 'stderr', continueAfterPattern),
|
|
283
|
+
]);
|
|
284
|
+
const timeout = new Promise<void>((_, reject) => {
|
|
285
|
+
setTimeout(() => reject(new Error(`Timeout waiting for pattern: ${waitFor}`)), timeoutMs);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await Promise.race([
|
|
289
|
+
patternPromise,
|
|
290
|
+
streamProcessing.then(() => {
|
|
291
|
+
if (!patternFoundFlag && !signalReceived) {
|
|
292
|
+
throw new Error(`Container exited without matching pattern: ${waitFor}`);
|
|
293
|
+
}
|
|
294
|
+
}),
|
|
295
|
+
timeout,
|
|
296
|
+
]);
|
|
297
|
+
|
|
298
|
+
if (signalReceived) { if (logsProc) logsProc.kill(); cleanup(); self._containerId = containerId; return containerId; }
|
|
299
|
+
|
|
300
|
+
if (continueAfterPattern) {
|
|
301
|
+
streamProcessing.catch((err) => console.error(`[run] Error in background log monitoring:`, err));
|
|
302
|
+
} else {
|
|
303
|
+
if (logsProc) logsProc.kill();
|
|
304
|
+
}
|
|
305
|
+
cleanup();
|
|
306
|
+
self._containerId = containerId;
|
|
307
|
+
return containerId;
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (logsProc) logsProc.kill();
|
|
310
|
+
cleanup();
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const result = await this.cli.exec(args);
|
|
316
|
+
cleanup();
|
|
317
|
+
self._containerId = result;
|
|
318
|
+
return result;
|
|
319
|
+
} catch (err) {
|
|
320
|
+
cleanup();
|
|
321
|
+
throw err;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
stop: {
|
|
327
|
+
type: CapsulePropertyTypes.Function,
|
|
328
|
+
value: async function (this: any, containerContext?: { containerId: string; timeout?: number }): Promise<string> {
|
|
329
|
+
const containerId = containerContext?.containerId ?? this._containerId;
|
|
330
|
+
if (!containerId) throw new Error('No containerId: container has not been started');
|
|
331
|
+
const timeout = containerContext?.timeout;
|
|
332
|
+
const logsProc = Bun.spawn(['docker', 'logs', '-f', containerId], { stdout: 'pipe', stderr: 'pipe' });
|
|
333
|
+
const capturedLogs: string[] = [];
|
|
334
|
+
const decoder = new TextDecoder();
|
|
335
|
+
const collectLogs = async (stream: ReadableStream<Uint8Array>, streamName: string) => {
|
|
336
|
+
const reader = stream.getReader();
|
|
337
|
+
let buffer = '';
|
|
338
|
+
try {
|
|
339
|
+
while (true) {
|
|
340
|
+
const { done, value } = await reader.read();
|
|
341
|
+
if (done) break;
|
|
342
|
+
buffer += decoder.decode(value, { stream: true });
|
|
343
|
+
const lines = buffer.split('\n');
|
|
344
|
+
buffer = lines.pop() || '';
|
|
345
|
+
for (const line of lines) {
|
|
346
|
+
capturedLogs.push(`[${streamName}] ${line}`);
|
|
347
|
+
if (this.context.verbose) Bun.write(Bun.stdout, `[stop:${streamName}] ${line}\n`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (buffer) {
|
|
351
|
+
capturedLogs.push(`[${streamName}] ${buffer}`);
|
|
352
|
+
if (this.context.verbose) Bun.write(Bun.stdout, `[stop:${streamName}] ${buffer}\n`);
|
|
353
|
+
}
|
|
354
|
+
} finally { reader.releaseLock(); }
|
|
355
|
+
};
|
|
356
|
+
const logsCollection = Promise.all([
|
|
357
|
+
collectLogs(logsProc.stdout as any, 'stdout'),
|
|
358
|
+
collectLogs(logsProc.stderr as any, 'stderr'),
|
|
359
|
+
]);
|
|
360
|
+
try {
|
|
361
|
+
const args = ['stop'];
|
|
362
|
+
if (timeout !== undefined) args.push('-t', timeout.toString());
|
|
363
|
+
args.push(containerId);
|
|
364
|
+
return await this.cli.exec(args);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
367
|
+
const logsContext = capturedLogs.length > 0
|
|
368
|
+
? `\n\nCaptured logs:\n${capturedLogs.join('\n')}`
|
|
369
|
+
: '\n\nNo logs captured';
|
|
370
|
+
throw new Error(`Failed to stop container ${containerId}: ${errorMessage}${logsContext}`);
|
|
371
|
+
} finally {
|
|
372
|
+
logsProc.kill();
|
|
373
|
+
await Promise.race([logsCollection, new Promise(resolve => setTimeout(resolve, 1000))]);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
remove: {
|
|
379
|
+
type: CapsulePropertyTypes.Function,
|
|
380
|
+
value: async function (this: any, containerContext?: { containerId: string; force?: boolean; volumes?: boolean }): Promise<string> {
|
|
381
|
+
const containerId = containerContext?.containerId ?? this._containerId;
|
|
382
|
+
if (!containerId) throw new Error('No containerId: container has not been started');
|
|
383
|
+
const args = ['rm'];
|
|
384
|
+
if (containerContext?.force) args.push('-f');
|
|
385
|
+
if (containerContext?.volumes) args.push('-v');
|
|
386
|
+
args.push(containerId);
|
|
387
|
+
return await this.cli.exec(args);
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
start: {
|
|
392
|
+
type: CapsulePropertyTypes.Function,
|
|
393
|
+
value: async function (this: any, containerContext: { containerId: string }): Promise<string> {
|
|
394
|
+
return await this.cli.exec(['start', containerContext.containerId]);
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
waitForSignalInLogs: {
|
|
399
|
+
type: CapsulePropertyTypes.Function,
|
|
400
|
+
value: async function (this: any, containerContext: {
|
|
401
|
+
containerId: string; signal: string; timeout?: number; lastInstanceEndSignal?: string;
|
|
402
|
+
}): Promise<void> {
|
|
403
|
+
const { containerId, signal, timeout = 30000, lastInstanceEndSignal } = containerContext;
|
|
404
|
+
const logsProc = Bun.spawn(['docker', 'logs', '--tail', 'all', '-f', containerId], { stdout: 'pipe', stderr: 'pipe' });
|
|
405
|
+
const decoder = new TextDecoder();
|
|
406
|
+
let signalFound = false;
|
|
407
|
+
let lastInstanceEnded = lastInstanceEndSignal ? false : true;
|
|
408
|
+
let pendingSignalTimeout: Timer | null = null;
|
|
409
|
+
let resolveSignal: (() => void) | null = null;
|
|
410
|
+
const signalPromise = new Promise<void>((resolve) => { resolveSignal = resolve; });
|
|
411
|
+
|
|
412
|
+
const processStream = async (stream: ReadableStream<Uint8Array>, streamName: string) => {
|
|
413
|
+
const reader = stream.getReader();
|
|
414
|
+
let buffer = '';
|
|
415
|
+
try {
|
|
416
|
+
while (true) {
|
|
417
|
+
if (signalFound) break;
|
|
418
|
+
const { done, value } = await reader.read();
|
|
419
|
+
if (done) break;
|
|
420
|
+
buffer += decoder.decode(value, { stream: true });
|
|
421
|
+
const lines = buffer.split('\n');
|
|
422
|
+
buffer = lines.pop() || '';
|
|
423
|
+
for (const line of lines) {
|
|
424
|
+
if (this.context.verbose) console.log(`[waitForSignalInLogs:${streamName}] ${line}`);
|
|
425
|
+
if (lastInstanceEndSignal && line.indexOf(lastInstanceEndSignal) !== -1) {
|
|
426
|
+
if (pendingSignalTimeout) { clearTimeout(pendingSignalTimeout); pendingSignalTimeout = null; }
|
|
427
|
+
lastInstanceEnded = true;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (!signalFound && line.indexOf(signal) !== -1) {
|
|
431
|
+
if (lastInstanceEndSignal && !lastInstanceEnded) {
|
|
432
|
+
signalFound = true;
|
|
433
|
+
if (resolveSignal) resolveSignal();
|
|
434
|
+
break;
|
|
435
|
+
} else if (lastInstanceEndSignal && lastInstanceEnded) {
|
|
436
|
+
if (pendingSignalTimeout) clearTimeout(pendingSignalTimeout);
|
|
437
|
+
pendingSignalTimeout = setTimeout(() => {
|
|
438
|
+
signalFound = true;
|
|
439
|
+
if (resolveSignal) resolveSignal();
|
|
440
|
+
}, 100);
|
|
441
|
+
} else {
|
|
442
|
+
signalFound = true;
|
|
443
|
+
if (resolveSignal) resolveSignal();
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (signalFound) break;
|
|
449
|
+
}
|
|
450
|
+
if (buffer && !signalFound && buffer.indexOf(signal) !== -1) {
|
|
451
|
+
signalFound = true;
|
|
452
|
+
if (resolveSignal) resolveSignal();
|
|
453
|
+
}
|
|
454
|
+
} finally { reader.releaseLock(); }
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const streamProcessing = Promise.all([
|
|
458
|
+
processStream(logsProc.stdout as any, 'stdout'),
|
|
459
|
+
processStream(logsProc.stderr as any, 'stderr'),
|
|
460
|
+
]);
|
|
461
|
+
const timeoutPromise = new Promise<void>((_, reject) => {
|
|
462
|
+
setTimeout(() => reject(new Error(`Timeout waiting for signal "${signal}" in container ${containerId} logs (${timeout}ms)`)), timeout);
|
|
463
|
+
});
|
|
464
|
+
try {
|
|
465
|
+
await Promise.race([
|
|
466
|
+
signalPromise,
|
|
467
|
+
streamProcessing.then(() => {
|
|
468
|
+
if (!signalFound) throw new Error(`Container logs ended without finding signal: "${signal}"`);
|
|
469
|
+
}),
|
|
470
|
+
timeoutPromise,
|
|
471
|
+
]);
|
|
472
|
+
} finally {
|
|
473
|
+
logsProc.kill();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}, {
|
|
480
|
+
importMeta: import.meta,
|
|
481
|
+
importStack: makeImportStack(),
|
|
482
|
+
capsuleName: '@stream44.studio/t44-docker.com/caps/Container',
|
|
483
|
+
})
|
|
484
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env bun test --timeout 30000
|
|
2
|
+
|
|
3
|
+
import * as bunTest from 'bun:test'
|
|
4
|
+
import { run } from 't44/standalone-rt'
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
test: { describe, it, expect },
|
|
8
|
+
context,
|
|
9
|
+
} = await run(async ({ encapsulate, CapsulePropertyTypes, makeImportStack }: any) => {
|
|
10
|
+
const spine = await encapsulate({
|
|
11
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
12
|
+
'#@stream44.studio/encapsulate/structs/Capsule': {},
|
|
13
|
+
'#': {
|
|
14
|
+
test: {
|
|
15
|
+
type: CapsulePropertyTypes.Mapping,
|
|
16
|
+
value: 't44/caps/ProjectTest',
|
|
17
|
+
options: { '#': { bunTest, env: {} } }
|
|
18
|
+
},
|
|
19
|
+
context: {
|
|
20
|
+
type: CapsulePropertyTypes.Mapping,
|
|
21
|
+
value: './ContainerContext',
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}, {
|
|
26
|
+
importMeta: import.meta,
|
|
27
|
+
importStack: makeImportStack(),
|
|
28
|
+
capsuleName: '@stream44.studio/t44-docker.com/caps/ContainerContext.test'
|
|
29
|
+
})
|
|
30
|
+
return { spine }
|
|
31
|
+
}, async ({ spine, apis }: any) => {
|
|
32
|
+
return apis[spine.capsuleSourceLineRef]
|
|
33
|
+
}, { importMeta: import.meta })
|
|
34
|
+
|
|
35
|
+
describe('ContainerContext Capsule', () => {
|
|
36
|
+
|
|
37
|
+
describe('default values', () => {
|
|
38
|
+
it('should have correct defaults', () => {
|
|
39
|
+
expect(context.verbose).toBe(true);
|
|
40
|
+
expect(context.showOutput).toBe(false);
|
|
41
|
+
expect(context.image).toBe('');
|
|
42
|
+
expect(context.name).toBeUndefined();
|
|
43
|
+
expect(context.ports).toEqual([]);
|
|
44
|
+
expect(context.volumes).toEqual([]);
|
|
45
|
+
expect(context.env).toEqual({});
|
|
46
|
+
expect(context.network).toBeUndefined();
|
|
47
|
+
expect(context.workdir).toBeUndefined();
|
|
48
|
+
expect(context.waitFor).toBeUndefined();
|
|
49
|
+
expect(context.waitTimeout).toBe(30000);
|
|
50
|
+
expect(context.detach).toBe(true);
|
|
51
|
+
expect(context.removeOnExit).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('property assignment', () => {
|
|
56
|
+
it('should allow setting image and name', () => {
|
|
57
|
+
context.image = 'my-org/my-repo:alpine-amd64';
|
|
58
|
+
context.name = 'my-container';
|
|
59
|
+
expect(context.image).toBe('my-org/my-repo:alpine-amd64');
|
|
60
|
+
expect(context.name).toBe('my-container');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should allow setting ports and env', () => {
|
|
64
|
+
context.ports = ['8080:3000', '9090:9090'];
|
|
65
|
+
context.env = { NODE_ENV: 'test', DEBUG: 'true' };
|
|
66
|
+
expect(context.ports).toEqual(['8080:3000', '9090:9090']);
|
|
67
|
+
expect(context.env.NODE_ENV).toBe('test');
|
|
68
|
+
expect(context.env.DEBUG).toBe('true');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should allow setting waitFor and waitTimeout', () => {
|
|
72
|
+
context.waitFor = 'READY';
|
|
73
|
+
context.waitTimeout = 60000;
|
|
74
|
+
expect(context.waitFor).toBe('READY');
|
|
75
|
+
expect(context.waitTimeout).toBe(60000);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
export async function capsule({
|
|
2
|
+
encapsulate,
|
|
3
|
+
CapsulePropertyTypes,
|
|
4
|
+
makeImportStack
|
|
5
|
+
}: {
|
|
6
|
+
encapsulate: any
|
|
7
|
+
CapsulePropertyTypes: any
|
|
8
|
+
makeImportStack: any
|
|
9
|
+
}) {
|
|
10
|
+
|
|
11
|
+
return encapsulate({
|
|
12
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
13
|
+
'#@stream44.studio/encapsulate/structs/Capsule': {},
|
|
14
|
+
'#': {
|
|
15
|
+
verbose: {
|
|
16
|
+
type: CapsulePropertyTypes.Literal,
|
|
17
|
+
value: true,
|
|
18
|
+
},
|
|
19
|
+
showOutput: {
|
|
20
|
+
type: CapsulePropertyTypes.Literal,
|
|
21
|
+
value: false,
|
|
22
|
+
},
|
|
23
|
+
image: {
|
|
24
|
+
type: CapsulePropertyTypes.Literal,
|
|
25
|
+
value: '' as string,
|
|
26
|
+
},
|
|
27
|
+
name: {
|
|
28
|
+
type: CapsulePropertyTypes.Literal,
|
|
29
|
+
value: undefined as string | undefined,
|
|
30
|
+
},
|
|
31
|
+
ports: {
|
|
32
|
+
type: CapsulePropertyTypes.Literal,
|
|
33
|
+
value: [] as { internal: number; external: number }[],
|
|
34
|
+
},
|
|
35
|
+
volumes: {
|
|
36
|
+
type: CapsulePropertyTypes.Literal,
|
|
37
|
+
value: [] as string[],
|
|
38
|
+
},
|
|
39
|
+
env: {
|
|
40
|
+
type: CapsulePropertyTypes.Literal,
|
|
41
|
+
value: {} as Record<string, string>,
|
|
42
|
+
},
|
|
43
|
+
network: {
|
|
44
|
+
type: CapsulePropertyTypes.Literal,
|
|
45
|
+
value: undefined as string | undefined,
|
|
46
|
+
},
|
|
47
|
+
workdir: {
|
|
48
|
+
type: CapsulePropertyTypes.Literal,
|
|
49
|
+
value: undefined as string | undefined,
|
|
50
|
+
},
|
|
51
|
+
waitFor: {
|
|
52
|
+
type: CapsulePropertyTypes.Literal,
|
|
53
|
+
value: undefined as string | undefined,
|
|
54
|
+
},
|
|
55
|
+
waitTimeout: {
|
|
56
|
+
type: CapsulePropertyTypes.Literal,
|
|
57
|
+
value: 30000,
|
|
58
|
+
},
|
|
59
|
+
detach: {
|
|
60
|
+
type: CapsulePropertyTypes.Literal,
|
|
61
|
+
value: true,
|
|
62
|
+
},
|
|
63
|
+
removeOnExit: {
|
|
64
|
+
type: CapsulePropertyTypes.Literal,
|
|
65
|
+
value: false,
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
derive: {
|
|
69
|
+
type: CapsulePropertyTypes.Function,
|
|
70
|
+
value: function (this: any, overrides?: {
|
|
71
|
+
image?: string;
|
|
72
|
+
name?: string;
|
|
73
|
+
ports?: { internal: number; external: number }[];
|
|
74
|
+
volumes?: string[];
|
|
75
|
+
env?: Record<string, string>;
|
|
76
|
+
network?: string;
|
|
77
|
+
workdir?: string;
|
|
78
|
+
waitFor?: string;
|
|
79
|
+
waitTimeout?: number;
|
|
80
|
+
detach?: boolean;
|
|
81
|
+
removeOnExit?: boolean;
|
|
82
|
+
verbose?: boolean;
|
|
83
|
+
showOutput?: boolean;
|
|
84
|
+
command?: string;
|
|
85
|
+
}): Record<string, any> {
|
|
86
|
+
return {
|
|
87
|
+
image: this.image,
|
|
88
|
+
name: this.name,
|
|
89
|
+
ports: this.ports,
|
|
90
|
+
volumes: this.volumes,
|
|
91
|
+
env: this.env,
|
|
92
|
+
network: this.network,
|
|
93
|
+
workdir: this.workdir,
|
|
94
|
+
waitFor: this.waitFor,
|
|
95
|
+
waitTimeout: this.waitTimeout,
|
|
96
|
+
detach: this.detach,
|
|
97
|
+
removeOnExit: this.removeOnExit,
|
|
98
|
+
verbose: this.verbose,
|
|
99
|
+
showOutput: this.showOutput,
|
|
100
|
+
...overrides,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}, {
|
|
107
|
+
importMeta: import.meta,
|
|
108
|
+
importStack: makeImportStack(),
|
|
109
|
+
capsuleName: '@stream44.studio/t44-docker.com/caps/ContainerContext',
|
|
110
|
+
})
|
|
111
|
+
}
|