-
Notifications
You must be signed in to change notification settings - Fork 5.1k
Expand file tree
/
Copy pathdocker.ts
More file actions
301 lines (263 loc) · 9.01 KB
/
docker.ts
File metadata and controls
301 lines (263 loc) · 9.01 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
/**
* Docker orchestration — compose lifecycle, network, image pull/build, worker spawning.
*
* Local mode: builds locally, uses docker-compose.yml from repo root, mounts prompts.
* NPX mode: pulls from Docker Hub, uses bundled compose.yml.
*/
import { type ChildProcess, execFileSync, spawn } from 'node:child_process';
import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { getMode } from './mode.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const NPX_IMAGE_REPO = 'keygraph/shannon';
const DEV_IMAGE = 'shannon-worker';
export function getWorkerImage(version: string): string {
return getMode() === 'local' ? DEV_IMAGE : `${NPX_IMAGE_REPO}:${version}`;
}
function getComposeFile(): string {
return getMode() === 'local'
? path.resolve('docker-compose.yml')
: path.resolve(__dirname, '..', 'infra', 'compose.yml');
}
/** Generate an 8-char random hex suffix for container/queue names. */
export function randomSuffix(): string {
return crypto.randomBytes(4).toString('hex');
}
/** Run a command silently, return true if it succeeds. */
function runQuiet(cmd: string, args: string[]): boolean {
try {
execFileSync(cmd, args, { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
/** Run a command and return stdout, or empty string on failure. */
function runOutput(cmd: string, args: string[]): string {
try {
return execFileSync(cmd, args, { stdio: 'pipe', encoding: 'utf-8' }).trim();
} catch {
return '';
}
}
/**
* Check if Temporal is running and healthy.
*/
export function isTemporalReady(): boolean {
const output = runOutput('docker', [
'exec',
'shannon-temporal',
'temporal',
'operator',
'cluster',
'health',
'--address',
'localhost:7233',
]);
return output.includes('SERVING');
}
/**
* Ensure Temporal is running via compose.
*/
export async function ensureInfra(): Promise<void> {
if (isTemporalReady()) {
return;
}
const composeFile = getComposeFile();
console.log('Starting Shannon infrastructure...');
execFileSync('docker', ['compose', '-f', composeFile, 'up', '-d'], { stdio: 'inherit' });
console.log('Waiting for Temporal to be ready...');
for (let i = 0; i < 30; i++) {
if (isTemporalReady()) {
console.log('Temporal is ready!');
return;
}
await sleep(2000);
}
console.error('Timeout waiting for Temporal');
process.exit(1);
}
/**
* Build the worker image locally (local mode only).
*/
export function buildImage(noCache: boolean): void {
console.log(`Building ${DEV_IMAGE}...`);
const args = ['build'];
if (noCache) args.push('--no-cache');
args.push('-t', DEV_IMAGE, '.');
execFileSync('docker', args, { stdio: 'inherit' });
console.log(`Build complete: ${DEV_IMAGE}`);
}
/**
* Ensure the worker image is available.
* Local mode: auto-builds if missing. NPX mode: pulls from Docker Hub.
*/
export function ensureImage(version: string): void {
const image = getWorkerImage(version);
const exists = runQuiet('docker', ['image', 'inspect', image]);
if (exists) return;
if (getMode() === 'local') {
console.log('Worker image not found, building...');
buildImage(false);
} else {
console.log(`Pulling ${image}...`);
try {
execFileSync('docker', ['pull', image], { stdio: 'inherit' });
} catch {
console.error(`\nERROR: Failed to pull ${image}`);
console.error('The image may not be available for your platform yet.');
console.error('Check https://hub.docker.com/r/keygraph/shannon for available tags.');
process.exit(1);
}
pruneOldImages(version);
}
}
/**
* Detect if --add-host is needed (Linux without Podman).
* macOS has host.docker.internal built in.
*/
function addHostFlag(): string[] {
if (os.platform() === 'linux') {
const hasPodman = runQuiet('which', ['podman']);
if (!hasPodman) {
return ['--add-host', 'host.docker.internal:host-gateway'];
}
}
return [];
}
export interface WorkerOptions {
version: string;
url: string;
repo: { hostPath: string; containerPath: string };
workspacesDir: string;
taskQueue: string;
containerName: string;
envFlags: string[];
config?: { hostPath: string; containerPath: string };
credentials?: string;
promptsDir?: string;
outputDir?: string;
workspace: string;
pipelineTesting?: boolean;
debug?: boolean;
reportFormat?: 'md' | 'sarif';
}
/**
* Spawn the worker container in detached mode and return the process.
* When `opts.debug` is true, omits `--rm` so the container persists for log inspection.
*/
export function spawnWorker(opts: WorkerOptions): ChildProcess {
const args = ['run', '-d'];
if (!opts.debug) {
args.push('--rm');
}
args.push('--name', opts.containerName, '--network', 'shannon-net');
// Add host flag for Linux
args.push(...addHostFlag());
// UID remapping for Linux bind mounts
if (os.platform() === 'linux' && process.getuid && process.getgid) {
args.push('-e', `SHANNON_HOST_UID=${process.getuid()}`, '-e', `SHANNON_HOST_GID=${process.getgid()}`);
}
// Volume mounts
args.push('-v', `${opts.workspacesDir}:/app/workspaces`);
args.push('-v', `${opts.repo.hostPath}:${opts.repo.containerPath}:ro`);
// Writable overlays: shadow .shannon/ inside the :ro repo with workspace-backed dirs
const workspacePath = path.join(opts.workspacesDir, opts.workspace);
args.push('-v', `${path.join(workspacePath, 'deliverables')}:${opts.repo.containerPath}/.shannon/deliverables`);
args.push('-v', `${path.join(workspacePath, 'scratchpad')}:${opts.repo.containerPath}/.shannon/scratchpad`);
args.push('-v', `${path.join(workspacePath, '.playwright-cli')}:${opts.repo.containerPath}/.shannon/.playwright-cli`);
// Local mode: mount prompts for live editing
if (opts.promptsDir) {
args.push('-v', `${opts.promptsDir}:/app/apps/worker/prompts:ro`);
}
if (opts.config) {
args.push('-v', `${opts.config.hostPath}:${opts.config.containerPath}:ro`);
}
// Output directory for deliverables copy
if (opts.outputDir) {
args.push('-v', `${opts.outputDir}:/app/output`);
}
// Mount credentials file to fixed container path
if (opts.credentials) {
args.push('-v', `${opts.credentials}:/app/credentials/google-sa-key.json:ro`);
}
// Environment
args.push(...opts.envFlags);
// Forward report format selection + version to the worker. Done as env
// (rather than CLI args) because the worker reads them once at startup
// to wire DI providers, before any activity-input plumbing exists.
if (opts.reportFormat && opts.reportFormat !== 'md') {
args.push('-e', `SHANNON_REPORT_FORMAT=${opts.reportFormat}`);
}
args.push('-e', `SHANNON_VERSION=${opts.version}`);
// Container settings
args.push('--shm-size', '2gb', '--security-opt', 'seccomp=unconfined');
// Image
args.push(getWorkerImage(opts.version));
// Worker command
args.push('node', 'apps/worker/dist/temporal/worker.js', opts.url, opts.repo.containerPath);
args.push('--task-queue', opts.taskQueue);
if (opts.config) {
args.push('--config', opts.config.containerPath);
}
if (opts.outputDir) {
args.push('--output', '/app/output');
}
args.push('--workspace', opts.workspace);
if (opts.pipelineTesting) {
args.push('--pipeline-testing');
}
// Inherit stderr so `docker run` daemon errors surface to the user;
// ignore stdin/stdout (the container ID is noise).
return spawn('docker', args, {
stdio: ['ignore', 'ignore', 'inherit'],
// Prevent MSYS/Git Bash from converting Unix paths on Windows
...(os.platform() === 'win32' && { env: { ...process.env, MSYS_NO_PATHCONV: '1' } }),
});
}
/**
* Stop all running shannon-worker-* containers.
*/
export function stopWorkers(): void {
const workers = runOutput('docker', ['ps', '-q', '--filter', 'name=shannon-worker-']);
if (!workers) return;
const ids = workers.split('\n').filter(Boolean);
console.log('Stopping worker containers...');
execFileSync('docker', ['stop', ...ids], { stdio: 'inherit' });
}
/**
* Tear down the compose stack.
*/
export function stopInfra(clean: boolean): void {
const composeFile = getComposeFile();
const args = ['compose', '-f', composeFile, 'down'];
if (clean) args.push('-v');
execFileSync('docker', args, { stdio: 'inherit' });
}
/**
* Remove old keygraph/shannon images that don't match the current version.
*/
function pruneOldImages(currentVersion: string): void {
const output = runOutput('docker', ['images', NPX_IMAGE_REPO, '--format', '{{.Tag}}']);
if (!output) return;
const currentTag = currentVersion;
const stale = output.split('\n').filter((tag) => tag && tag !== currentTag);
for (const tag of stale) {
runQuiet('docker', ['rmi', `${NPX_IMAGE_REPO}:${tag}`]);
}
}
/**
* List running worker containers.
*/
export function listRunningWorkers(): string {
return runOutput('docker', [
'ps',
'--filter',
'name=shannon-worker-',
'--format',
'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}',
]);
}