Skip to content

Commit cdd5a15

Browse files
authored
feat: add custom dockerfile support for Container agent builds (#783)
* 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) * chore: remove dead code and redundant tests from dockerfile PR - Remove unused ADVANCED_GROUP_LABELS constant (dead code) - Remove unnecessary export on DOCKERFILE_NAME_REGEX - Fix stale `steps` dependency in useGenerateWizard setAdvanced callback - Trim computeByoSteps.test.ts to dockerfile-only tests (remove 11 tests for pre-existing behavior unchanged by this PR) - Remove redundant "uses default Dockerfile" tests that duplicate existing coverage in preflight, config, and container packager test files - Consolidate shell metacharacter it.each from 5 cases to 1 representative Confidence: high Scope-risk: narrow * fix: skip template Dockerfile scaffolding when custom dockerfile is set When a custom dockerfile is configured (e.g. Dockerfile.gpu), the renderer was still copying the default template Dockerfile into the agent directory, leaving an unused file alongside the custom one. Thread the dockerfile config through AgentRenderConfig and use a new exclude option on copyAndRenderDir to skip the template Dockerfile when a custom one is specified. The .dockerignore is still scaffolded. Constraint: copyAndRenderDir is a shared utility used by all renderers Rejected: Delete template after render | user requested option A (don't create) Confidence: high Scope-risk: narrow * fix: use PathInput file picker for Dockerfile selection in TUI Replace the TextInput with PathInput for Dockerfile selection in both the BYO add-agent and Generate wizard flows. This gives users a real file browser with directory navigation and existence validation on submit, matching the UX pattern used by the policy file picker. BYO flow: PathInput scoped to the agent's code directory so users browse their existing files and pick a Dockerfile. Generate flow: PathInput scoped to cwd so users browse the filesystem to find a Dockerfile to copy into the new project. Added allowEmpty and emptyHelpText props to PathInput so users can press Enter to use the default Dockerfile. Constraint: PathInput is a shared component used by policy and import screens Rejected: Soft warning on TextInput | user preferred real file picker like policy Confidence: high Scope-risk: narrow
1 parent e266576 commit cdd5a15

26 files changed

Lines changed: 939 additions & 175 deletions

File tree

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

Lines changed: 2 additions & 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,
@@ -276,5 +277,6 @@ export async function mapGenerateConfigToRenderConfig(
276277
gatewayProviders,
277278
gatewayAuthTypes: [...new Set(gatewayProviders.map(g => g.authType))],
278279
protocol: config.protocol,
280+
dockerfile: config.dockerfile,
279281
};
280282
}

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

Lines changed: 28 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,27 @@ 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+
});
99127
});

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: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,35 @@ 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+
});
370399
});
371400

372401
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

src/cli/templates/BaseRenderer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ export abstract class BaseRenderer {
7171
const containerTemplateDir = path.join(this.baseTemplateDir, 'container', language);
7272

7373
if (existsSync(containerTemplateDir)) {
74-
await copyAndRenderDir(containerTemplateDir, projectDir, { ...templateData, entrypoint: 'main' });
74+
const exclude = this.config.dockerfile ? new Set(['Dockerfile']) : undefined;
75+
await copyAndRenderDir(containerTemplateDir, projectDir, { ...templateData, entrypoint: 'main' }, { exclude });
7576
}
7677
}
7778
}

src/cli/templates/render.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,19 @@ export async function copyDir(src: string, dest: string): Promise<void> {
4747
/**
4848
* Recursively copies a directory, rendering Handlebars templates.
4949
*/
50-
export async function copyAndRenderDir<T extends object>(src: string, dest: string, data: T): Promise<void> {
50+
export async function copyAndRenderDir<T extends object>(
51+
src: string,
52+
dest: string,
53+
data: T,
54+
options?: { exclude?: Set<string> }
55+
): Promise<void> {
5156
await fs.mkdir(dest, { recursive: true });
5257
const entries = await fs.readdir(src, { withFileTypes: true });
5358

5459
for (const entry of entries) {
55-
const srcPath = path.join(src, entry.name);
5660
const destName = resolveTemplateName(entry.name);
61+
if (options?.exclude?.has(destName)) continue;
62+
const srcPath = path.join(src, entry.name);
5763
const destPath = path.join(dest, destName);
5864

5965
if (entry.isDirectory()) {

src/cli/templates/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,6 @@ export interface AgentRenderConfig {
6666
gatewayAuthTypes: string[];
6767
/** Protocol (HTTP, MCP, A2A). Defaults to HTTP. */
6868
protocol?: ProtocolMode;
69+
/** Custom Dockerfile name — when set, the template Dockerfile is not scaffolded */
70+
dockerfile?: string;
6971
}

src/cli/tui/components/PathInput.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ interface PathInputProps {
1919
allowCreate?: boolean;
2020
/** Show hidden files (dotfiles) in completions (default: false) */
2121
showHidden?: boolean;
22+
/** Allow empty input (user presses Enter without selecting a file) */
23+
allowEmpty?: boolean;
24+
/** Message shown when user submits empty input (only if allowEmpty is true) */
25+
emptyHelpText?: string;
2226
}
2327

2428
interface CompletionItem {
@@ -133,6 +137,8 @@ export function PathInput({
133137
maxVisibleItems = 8,
134138
allowCreate = false,
135139
showHidden = false,
140+
allowEmpty = false,
141+
emptyHelpText,
136142
}: PathInputProps) {
137143
const [value, setValue] = useState(initialValue);
138144
const [cursor, setCursor] = useState(initialValue.length);
@@ -207,6 +213,10 @@ export function PathInput({
207213
if (key.return) {
208214
const trimmed = value.trim();
209215
if (!trimmed) {
216+
if (allowEmpty) {
217+
onSubmit('');
218+
return;
219+
}
210220
setError('Please enter a path');
211221
return;
212222
}
@@ -322,8 +332,9 @@ export function PathInput({
322332
)}
323333

324334
{/* Help text */}
325-
<Box marginTop={1}>
335+
<Box marginTop={1} flexDirection="column">
326336
<Text dimColor>↑↓ move → open ← back Enter submit Esc cancel</Text>
337+
{allowEmpty && emptyHelpText && !value && <Text dimColor>{emptyHelpText}</Text>}
327338
</Box>
328339
</Box>
329340
);

0 commit comments

Comments
 (0)