Skip to content

Commit b814968

Browse files
committed
feat: add custom dockerfile support for Container agent builds
Add an optional `dockerfile` field to Container agent configuration, allowing users to specify a custom Dockerfile name (e.g. Dockerfile.gpu) instead of the default "Dockerfile". Changes across all layers: - Schema: Add dockerfile field to AgentEnvSpecSchema with filename validation - CLI wizard: Add "Custom Dockerfile" option to Advanced settings multi-select, with dedicated Dockerfile input step in the breadcrumb wizard - Dev server: Thread dockerfile through container config to docker build - Deploy preflight: Validate custom dockerfile exists before deploy - Packaging: Pass dockerfile to container build commands - Security: getDockerfilePath rejects path traversal (/, \, ..) - Tests: 64 new/updated tests across schema, preflight, dev config, packaging, wizard, and constants Constraint: Dockerfile must be a filename only (no path separators) Rejected: Accept full paths | path traversal security risk Rejected: Auto-copy Dockerfile on create | users manage their own Dockerfiles Confidence: high Scope-risk: moderate Not-tested: Interactive TUI tested manually via TUI harness (not in CI)
1 parent f2e3deb commit b814968

22 files changed

Lines changed: 1153 additions & 171 deletions

File tree

src/cli/operations/agent/generate/schema-mapper.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ export function mapGenerateConfigToAgent(config: GenerateConfig): AgentEnvSpec {
115115
return {
116116
name: config.projectName,
117117
build: config.buildType ?? 'CodeZip',
118+
...(config.dockerfile && { dockerfile: config.dockerfile }),
118119
entrypoint: DEFAULT_PYTHON_ENTRYPOINT as FilePath,
119120
codeLocation: codeLocation as DirectoryPath,
120121
runtimeVersion: DEFAULT_PYTHON_VERSION,

src/cli/operations/deploy/__tests__/preflight-container.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ vi.mock('node:fs', () => ({
99

1010
vi.mock('../../../../lib', () => ({
1111
DOCKERFILE_NAME: 'Dockerfile',
12+
getDockerfilePath: (codeLocation: string, dockerfile?: string) => {
13+
// eslint-disable-next-line @typescript-eslint/no-require-imports
14+
const p = require('node:path') as typeof import('node:path');
15+
return p.join(codeLocation, dockerfile ?? 'Dockerfile');
16+
},
1217
resolveCodeLocation: vi.fn((codeLocation: string, configBaseDir: string) => {
1318
// eslint-disable-next-line @typescript-eslint/no-require-imports
1419
const p = require('node:path') as typeof import('node:path');
@@ -96,4 +101,37 @@ describe('validateContainerAgents', () => {
96101

97102
expect(() => validateContainerAgents(spec, CONFIG_ROOT)).toThrow(/agent-a.*agent-b/s);
98103
});
104+
105+
it('checks for custom dockerfile name when specified', () => {
106+
mockedExistsSync.mockReturnValue(true);
107+
108+
const spec = makeSpec([
109+
{ name: 'gpu-agent', build: 'Container', codeLocation: dir('agents/gpu'), dockerfile: 'Dockerfile.gpu' },
110+
]);
111+
112+
expect(() => validateContainerAgents(spec, CONFIG_ROOT)).not.toThrow();
113+
// Should check for Dockerfile.gpu, not the default Dockerfile
114+
const calledPath = mockedExistsSync.mock.calls[0]?.[0] as string;
115+
expect(calledPath).toContain('Dockerfile.gpu');
116+
});
117+
118+
it('throws with custom dockerfile name in error message when missing', () => {
119+
mockedExistsSync.mockReturnValue(false);
120+
121+
const spec = makeSpec([
122+
{ name: 'gpu-agent', build: 'Container', codeLocation: dir('agents/gpu'), dockerfile: 'Dockerfile.gpu' },
123+
]);
124+
125+
expect(() => validateContainerAgents(spec, CONFIG_ROOT)).toThrow(/Dockerfile\.gpu not found/);
126+
});
127+
128+
it('uses default Dockerfile when no custom dockerfile specified', () => {
129+
mockedExistsSync.mockReturnValue(true);
130+
131+
const spec = makeSpec([{ name: 'default-agent', build: 'Container', codeLocation: dir('agents/default') }]);
132+
133+
expect(() => validateContainerAgents(spec, CONFIG_ROOT)).not.toThrow();
134+
const calledPath = mockedExistsSync.mock.calls[0]?.[0] as string;
135+
expect(calledPath).toMatch(/\/Dockerfile$/);
136+
});
99137
});

src/cli/operations/deploy/preflight.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ConfigIO, DOCKERFILE_NAME, requireConfigRoot, resolveCodeLocation } from '../../../lib';
1+
import { ConfigIO, DOCKERFILE_NAME, getDockerfilePath, requireConfigRoot, resolveCodeLocation } from '../../../lib';
22
import type { AgentCoreProjectSpec, AwsDeploymentTarget } from '../../../schema';
33
import { validateAwsCredentials } from '../../aws/account';
44
import { LocalCdkProject } from '../../cdk/local-cdk-project';
@@ -147,11 +147,11 @@ export function validateContainerAgents(projectSpec: AgentCoreProjectSpec, confi
147147
for (const agent of projectSpec.runtimes || []) {
148148
if (agent.build === 'Container') {
149149
const codeLocation = resolveCodeLocation(agent.codeLocation, configRoot);
150-
const dockerfilePath = path.join(codeLocation, DOCKERFILE_NAME);
150+
const dockerfilePath = getDockerfilePath(codeLocation, agent.dockerfile);
151151

152152
if (!existsSync(dockerfilePath)) {
153153
errors.push(
154-
`Agent "${agent.name}": Dockerfile not found at ${dockerfilePath}. Container agents require a Dockerfile.`
154+
`Agent "${agent.name}": ${agent.dockerfile ?? DOCKERFILE_NAME} not found at ${dockerfilePath}. Container agents require a Dockerfile.`
155155
);
156156
}
157157
}

src/cli/operations/dev/__tests__/config.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,63 @@ describe('getDevConfig', () => {
367367
expect(config).not.toBeNull();
368368
expect(config!.isPython).toBe(true);
369369
});
370+
371+
it('threads dockerfile from Container agent spec to DevConfig', () => {
372+
const project: AgentCoreProjectSpec = {
373+
name: 'TestProject',
374+
version: 1,
375+
managedBy: 'CDK' as const,
376+
runtimes: [
377+
{
378+
name: 'ContainerAgent',
379+
build: 'Container',
380+
runtimeVersion: 'PYTHON_3_12',
381+
entrypoint: filePath('main.py'),
382+
codeLocation: dirPath('./agents/container'),
383+
protocol: 'HTTP',
384+
dockerfile: 'Dockerfile.gpu',
385+
},
386+
],
387+
memories: [],
388+
credentials: [],
389+
evaluators: [],
390+
onlineEvalConfigs: [],
391+
agentCoreGateways: [],
392+
policyEngines: [],
393+
};
394+
395+
const config = getDevConfig(workingDir, project, '/test/project/agentcore');
396+
expect(config).not.toBeNull();
397+
expect(config?.dockerfile).toBe('Dockerfile.gpu');
398+
});
399+
400+
it('returns undefined dockerfile when agent has no custom dockerfile', () => {
401+
const project: AgentCoreProjectSpec = {
402+
name: 'TestProject',
403+
version: 1,
404+
managedBy: 'CDK' as const,
405+
runtimes: [
406+
{
407+
name: 'ContainerAgent',
408+
build: 'Container',
409+
runtimeVersion: 'PYTHON_3_12',
410+
entrypoint: filePath('main.py'),
411+
codeLocation: dirPath('./agents/container'),
412+
protocol: 'HTTP',
413+
},
414+
],
415+
memories: [],
416+
credentials: [],
417+
evaluators: [],
418+
onlineEvalConfigs: [],
419+
agentCoreGateways: [],
420+
policyEngines: [],
421+
};
422+
423+
const config = getDevConfig(workingDir, project, '/test/project/agentcore');
424+
expect(config).not.toBeNull();
425+
expect(config?.dockerfile).toBeUndefined();
426+
});
370427
});
371428

372429
describe('getAgentPort', () => {

src/cli/operations/dev/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface DevConfig {
1010
isPython: boolean;
1111
buildType: BuildType;
1212
protocol: ProtocolMode;
13+
dockerfile?: string;
1314
}
1415

1516
interface DevSupportResult {
@@ -140,6 +141,7 @@ export function getDevConfig(
140141
isPython: isPythonAgent(targetAgent),
141142
buildType: targetAgent.build,
142143
protocol: targetAgent.protocol ?? 'HTTP',
144+
dockerfile: targetAgent.dockerfile,
143145
};
144146
}
145147

src/cli/operations/dev/container-dev-server.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CONTAINER_INTERNAL_PORT, DOCKERFILE_NAME } from '../../../lib';
1+
import { CONTAINER_INTERNAL_PORT, DOCKERFILE_NAME, getDockerfilePath } from '../../../lib';
22
import { getUvBuildArgs } from '../../../lib/packaging/build-args';
33
import { detectContainerRuntime, getStartHint } from '../../external-requirements/detect';
44
import { DevServer, type LogLevel, type SpawnConfig } from './dev-server';
@@ -73,9 +73,10 @@ export class ContainerDevServer extends DevServer {
7373
this.runtimeBinary = runtime.binary;
7474

7575
// 2. Verify Dockerfile exists
76-
const dockerfilePath = join(this.config.directory, DOCKERFILE_NAME);
76+
const dockerfileName = this.config.dockerfile ?? DOCKERFILE_NAME;
77+
const dockerfilePath = getDockerfilePath(this.config.directory, this.config.dockerfile);
7778
if (!existsSync(dockerfilePath)) {
78-
onLog('error', `Dockerfile not found at ${dockerfilePath}. Container agents require a Dockerfile.`);
79+
onLog('error', `${dockerfileName} not found at ${dockerfilePath}. Container agents require a Dockerfile.`);
7980
return false;
8081
}
8182

0 commit comments

Comments
 (0)