forked from aws/agentcore-cli
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontainer-dev-server.ts
More file actions
193 lines (171 loc) · 7.01 KB
/
container-dev-server.ts
File metadata and controls
193 lines (171 loc) · 7.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
import { CONTAINER_INTERNAL_PORT, DOCKERFILE_NAME, getDockerfilePath } from '../../../lib';
import { getUvBuildArgs } from '../../../lib/packaging/build-args';
import { detectContainerRuntime, getStartHint } from '../../external-requirements/detect';
import { DevServer, type LogLevel, type SpawnConfig } from './dev-server';
import { waitForServerReady } from './utils';
import { type ChildProcess, spawn, spawnSync } from 'child_process';
import { existsSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
/** Dev server for Container agents. Builds and runs a Docker container using the user's Dockerfile. */
export class ContainerDevServer extends DevServer {
private runtimeBinary = '';
/** Docker image names must be lowercase. */
private get imageName(): string {
return `agentcore-dev-${this.config.agentName}`.toLowerCase();
}
/** Container name for lifecycle management. */
private get containerName(): string {
return this.imageName;
}
/** Override start to wait for the container's server to accept connections before
* signaling readiness. The base class spawns `docker run`, but the internal server
* needs time to boot. We poll the mapped port so the TUI only enables input once
* the container is actually ready to handle requests. */
override async start(): Promise<ChildProcess | null> {
const child = await super.start();
if (child) {
const { onLog } = this.options.callbacks;
onLog('system', `Container ${this.containerName} started, waiting for server to be ready...`);
// Poll until the container's server is accepting connections (up to 60s)
const ready = await waitForServerReady(this.options.port);
if (ready) {
// Trigger TUI readiness detection (useDevServer looks for this exact string)
onLog('info', 'Application startup complete');
} else {
onLog('error', 'Container server did not become ready within 60 seconds.');
}
}
return child;
}
/** Override kill to stop the container properly, cleaning up the port proxy.
* Uses async spawn so the UI can render "Stopping..." while container stops. */
override kill(): void {
if (this.runtimeBinary) {
// Fire-and-forget: stop container asynchronously so UI remains responsive
spawn(this.runtimeBinary, ['stop', this.containerName], { stdio: 'ignore' });
}
super.kill();
}
protected async prepare(): Promise<boolean> {
const { onLog } = this.options.callbacks;
// 1. Detect container runtime
const { runtime, notReadyRuntimes } = await detectContainerRuntime();
if (!runtime) {
if (notReadyRuntimes.length > 0) {
onLog(
'error',
`Found ${notReadyRuntimes.join(', ')} but not ready. Start a runtime:\n${getStartHint(notReadyRuntimes)}`
);
} else {
onLog('error', 'No container runtime found. Install Docker, Podman, or Finch.');
}
return false;
}
this.runtimeBinary = runtime.binary;
// 2. Verify Dockerfile exists
const dockerfileName = this.config.dockerfile ?? DOCKERFILE_NAME;
const dockerfilePath = getDockerfilePath(this.config.directory, this.config.dockerfile);
if (!existsSync(dockerfilePath)) {
onLog('error', `${dockerfileName} not found at ${dockerfilePath}. Container agents require a Dockerfile.`);
return false;
}
// 3. Remove any stale container from a previous run (prevents "proxy already running" errors)
spawnSync(this.runtimeBinary, ['rm', '-f', this.containerName], { stdio: 'ignore' });
// 4. Build the container image, streaming output in real-time
onLog('system', `Building container image: ${this.imageName}...`);
const exitCode = await this.streamBuild(
['-t', this.imageName, '-f', dockerfilePath, ...getUvBuildArgs(), this.config.directory],
onLog
);
if (exitCode !== 0) {
onLog('error', `Container build failed (exit code ${exitCode})`);
return false;
}
onLog('system', 'Container image built successfully.');
return true;
}
/** Run a container build and stream stdout/stderr lines to onLog in real-time. */
private streamBuild(args: string[], onLog: (level: LogLevel, message: string) => void): Promise<number | null> {
return new Promise(resolve => {
const child = spawn(this.runtimeBinary, ['build', ...args], { stdio: 'pipe' });
const streamLines = (stream: NodeJS.ReadableStream) => {
let buffer = '';
stream.on('data', (chunk: Buffer) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop()!;
for (const line of lines) {
if (line.trim()) onLog('system', line);
}
});
stream.on('end', () => {
if (buffer.trim()) onLog('system', buffer);
});
};
if (child.stdout) streamLines(child.stdout);
if (child.stderr) streamLines(child.stderr);
child.on('error', err => {
onLog('error', `Build process error: ${err.message}`);
resolve(1);
});
child.on('close', code => resolve(code));
});
}
protected getSpawnConfig(): SpawnConfig {
const { port, envVars = {} } = this.options;
// Forward AWS credentials from host environment into the container
const awsEnvKeys = [
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_SESSION_TOKEN',
'AWS_REGION',
'AWS_DEFAULT_REGION',
'AWS_PROFILE',
];
const awsEnvVars: Record<string, string> = {};
for (const key of awsEnvKeys) {
if (process.env[key]) {
awsEnvVars[key] = process.env[key]!;
}
}
// Mount ~/.aws to a neutral path accessible by any container user, and set
// AWS SDK env vars to point to it. This supports SSO, profiles, and credential files
// regardless of what USER the Dockerfile specifies.
const awsDir = join(homedir(), '.aws');
const awsContainerPath = '/aws-config';
const awsMountArgs = existsSync(awsDir) ? ['-v', `${awsDir}:${awsContainerPath}:ro`] : [];
const awsConfigEnv = existsSync(awsDir)
? {
AWS_CONFIG_FILE: `${awsContainerPath}/config`,
AWS_SHARED_CREDENTIALS_FILE: `${awsContainerPath}/credentials`,
}
: {};
// Environment variables: AWS creds + config paths + user env + container-specific overrides.
// Disable OpenTelemetry SDK — no collector is running locally, and the OTEL
// exporter connection errors would crash or pollute the dev server output.
const envArgs = Object.entries({
...awsEnvVars,
...awsConfigEnv,
...envVars,
LOCAL_DEV: '1',
PORT: String(CONTAINER_INTERNAL_PORT),
OTEL_SDK_DISABLED: 'true',
}).flatMap(([k, v]) => ['-e', `${k}=${v}`]);
return {
cmd: this.runtimeBinary,
args: [
'run',
'--rm',
'--name',
this.containerName,
...awsMountArgs,
'-p',
`${port}:${CONTAINER_INTERNAL_PORT}`,
...envArgs,
this.imageName,
],
env: { ...process.env },
};
}
}