Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/cli/commands/export/__tests__/container-export.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { ConfigIO } from '../../../../lib';
import { AgentCoreProjectSpecSchema, HarnessSpecSchema } from '../../../../schema';
import { handleExportHarness } from '../harness-action';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('node:child_process', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
execSync: vi.fn(),
}));

describe('exported container Dockerfiles', () => {
const originalCwd = process.cwd();
let projectRoot: string;
let configIO: ConfigIO;

beforeEach(async () => {
vi.stubEnv('AGENTCORE_TELEMETRY_DISABLED', '1');
projectRoot = mkdtempSync(join(tmpdir(), 'container-export-'));
vi.stubEnv('INIT_CWD', projectRoot);
configIO = new ConfigIO({ baseDir: join(projectRoot, 'agentcore') });
await configIO.writeProjectSpec(
AgentCoreProjectSpecSchema.parse({
name: 'ExportProbe',
version: 1,
harnesses: [{ name: 'SourceHarness', path: 'app/SourceHarness' }],
})
);
process.chdir(projectRoot);
});

afterEach(() => {
process.chdir(originalCwd);
vi.unstubAllEnvs();
rmSync(projectRoot, { recursive: true, force: true });
});

it('hardens a containerUri layer while preserving the base and requiring OS refresh', async () => {
const baseImage = 'public.ecr.aws/example/custom-python:latest';
await configIO.writeHarnessSpec(
'SourceHarness',
HarnessSpecSchema.parse({
name: 'SourceHarness',
model: { provider: 'bedrock', modelId: 'global.anthropic.claude-sonnet-4-6' },
containerUri: baseImage,
})
);

const result = await handleExportHarness({ name: 'SourceHarness', targetAgentName: 'ExportedAgent' });

if (!result.success) throw result.error;
expect(result.success).toBe(true);
const dockerfile = readFileSync(join(result.agentPath, 'Dockerfile'), 'utf8');
expect(dockerfile).toContain(`FROM ${baseImage}`);
expect(dockerfile).toContain('UV_NO_CACHE=1');
expect(dockerfile).toContain('/usr/local/bin/python -m pip uninstall -y uv');
expect(dockerfile).toContain('OS packages');
expect(dockerfile).toContain('appropriate package manager');
expect(dockerfile).not.toContain('apt-get upgrade');
expect(dockerfile).toContain('CMD ["opentelemetry-instrument", "python", "-m", "main"]');
});

it('preserves a custom Dockerfile and emits hardened build-layer guidance', async () => {
const original = 'FROM customer/base:latest\nRUN echo customer-customization\n';
const harnessDir = join(projectRoot, 'app', 'SourceHarness');
mkdirSync(harnessDir, { recursive: true });
writeFileSync(join(harnessDir, 'Custom.Dockerfile'), original);
await configIO.writeHarnessSpec(
'SourceHarness',
HarnessSpecSchema.parse({
name: 'SourceHarness',
model: { provider: 'bedrock', modelId: 'global.anthropic.claude-sonnet-4-6' },
dockerfile: 'Custom.Dockerfile',
})
);

const result = await handleExportHarness({ name: 'SourceHarness', targetAgentName: 'ExportedAgent' });

if (!result.success) throw result.error;
expect(result.success).toBe(true);
expect(readFileSync(join(result.agentPath, 'Custom.Dockerfile'), 'utf8')).toBe(original);
const notes = readFileSync(result.notesPath, 'utf8');
expect(notes).toContain('UV_NO_CACHE=1');
expect(notes).toContain('/usr/local/bin/python -m pip uninstall -y uv');
expect(notes).toContain('OS packages');
expect(notes).toContain('package manager');
});
});
8 changes: 8 additions & 0 deletions src/cli/commands/export/__tests__/harness-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ describe('buildCustomDockerfileNote', () => {
expect(note.message).toContain('COPY --chown=bedrock_agentcore:bedrock_agentcore . .');
expect(note.message).toContain('CMD ["opentelemetry-instrument", "python", "-m", "main"]');
});

it('provides a cache-free build layer and requires base-specific OS updates', () => {
const note = buildCustomDockerfileNote('Custom.Dockerfile', 'AgentX');
expect(note.message).toContain('UV_NO_CACHE=1');
expect(note.message).toContain('/usr/local/bin/python -m pip uninstall -y uv');
expect(note.message).toContain('OS packages');
expect(note.message).toContain('package manager');
});
});

// ============================================================================
Expand Down
17 changes: 13 additions & 4 deletions src/cli/commands/export/harness-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,13 +373,17 @@ export function buildCustomDockerfileNote(dockerfile: string, targetAgentName: s
`the exported agent will NOT run as-is: a harness Dockerfile has no dependency install, code copy, or ` +
`startup command (the harness runtime supplied those). Add the Strands agent build layer to the end ` +
`of app/${targetAgentName}/${dockerfile} before \`agentcore deploy\` ` +
`(adjust if your base image is not Python 3.12+/uv, or already sets WORKDIR/USER):\n\n` +
`(adjust if your base image is not Python 3.12+/uv, or already sets WORKDIR/USER). ` +
`First refresh the base image and its OS packages using the appropriate package manager, ` +
`with the privileges required by that base. Adjust the global Python path below to match ` +
`the interpreter used to install uv; retain uv if your custom application requires it at runtime:\n\n` +
` WORKDIR /app\n` +
` RUN pip install --no-cache-dir uv\n` +
` ENV UV_NO_CACHE=1 PATH="/app/.venv/bin:$PATH"\n` +
` COPY pyproject.toml uv.lock ./\n` +
` RUN uv sync --frozen --no-dev --no-install-project\n` +
` COPY --chown=bedrock_agentcore:bedrock_agentcore . .\n` +
` RUN uv sync --frozen --no-dev\n` +
` RUN uv sync --frozen --no-dev && /usr/local/bin/python -m pip uninstall -y uv\n` +
` USER bedrock_agentcore\n` +
` EXPOSE 8080 8000 9000\n` +
` CMD ["opentelemetry-instrument", "python", "-m", "main"]\n\n` +
Expand Down Expand Up @@ -482,7 +486,11 @@ function writeDockerfileStub(agentDir: string, containerUri: string): void {
const content = [
`# Base image from the source harness: ${containerUri}`,
'# The generated Strands agent is layered on top. If the base image does not',
'# include Python 3.12+ or uv, add install steps before the COPY/RUN below.',
'# include Python 3.12+, add install steps before the COPY/RUN below.',
'# Refresh the base image and its OS packages with the appropriate package manager.',
'# Run those updates with the privileges required by your base image.',
'# Adjust /usr/local/bin/python below to the global interpreter used to install uv.',
'# Retain uv if your custom application requires it at runtime.',
`FROM ${containerUri}`,
'',
'RUN pip install --no-cache-dir uv',
Expand All @@ -493,6 +501,7 @@ function writeDockerfileStub(agentDir: string, containerUri: string): void {
'ARG UV_INDEX',
'',
'ENV UV_SYSTEM_PYTHON=1 \\',
' UV_NO_CACHE=1 \\',
' UV_COMPILE_BYTECODE=1 \\',
' UV_NO_PROGRESS=1 \\',
' PYTHONUNBUFFERED=1 \\',
Expand All @@ -507,7 +516,7 @@ function writeDockerfileStub(agentDir: string, containerUri: string): void {
'RUN uv sync --frozen --no-dev --no-install-project',
'',
'COPY --chown=bedrock_agentcore:bedrock_agentcore . .',
'RUN uv sync --frozen --no-dev',
'RUN uv sync --frozen --no-dev && /usr/local/bin/python -m pip uninstall -y uv',
'',
'USER bedrock_agentcore',
'',
Expand Down
42 changes: 42 additions & 0 deletions src/cli/commands/import/__tests__/import-no-deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* Verifies that the import command correctly handles starter toolkit projects
* that were created but never deployed (no agent_id/memory_id in YAML).
*/
import { handleImport } from '../actions.js';
import { parseStarterToolkitYaml } from '../yaml-parser.js';
import assert from 'node:assert';
import * as fs from 'node:fs';
Expand Down Expand Up @@ -647,6 +648,47 @@ agents:
expect(mockSetupPythonProject).not.toHaveBeenCalled();
});

it('generates the hardened shared Dockerfile when the imported source has none', async () => {
const sourceDir = path.join(tmpDir, 'starter', 'src');
fs.mkdirSync(sourceDir, { recursive: true });
fs.writeFileSync(path.join(sourceDir, 'worker.py'), 'print("imported")\n');
fs.writeFileSync(path.join(sourceDir, '.dockerignore'), 'customer-specific-ignore\n');
fs.writeFileSync(
yamlPath,
`agents:
test_agent:
name: test_agent
entrypoint: worker.py
deployment_type: container
source_path: ${JSON.stringify(sourceDir)}
aws:
account: '111122223333'
region: us-east-1
`
);
mockReadProjectSpec.mockResolvedValue({
name: 'myproject',
version: 1,
runtimes: [],
memories: [],
knowledgeBases: [],
credentials: [],
});
mockReadAWSDeploymentTargets.mockResolvedValue([]);

const result = await handleImport({ source: yamlPath });

assert(result.success);
const appDir = path.join(tmpDir, 'myproject', 'app', 'test_agent');
const dockerfile = fs.readFileSync(path.join(appDir, 'Dockerfile'), 'utf8');
expect(dockerfile).toContain('apt-get upgrade -y');
expect(dockerfile).toContain('UV_NO_CACHE=1');
expect(dockerfile).toContain('/usr/local/bin/python -m pip uninstall -y uv');
expect(dockerfile).toContain('PATH="/app/.venv/bin:$PATH"');
expect(dockerfile).toContain('CMD ["opentelemetry-instrument", "python", "-m", "worker"]');
expect(fs.readFileSync(path.join(appDir, '.dockerignore'), 'utf8')).toBe('customer-specific-ignore\n');
});

it('returns correct stackName in result', async () => {
mockReadProjectSpec.mockResolvedValue({
name: 'myproject',
Expand Down
37 changes: 8 additions & 29 deletions src/cli/commands/import/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import type {
import { isContainerBuild } from '../../../schema/constants';
import { validateAwsCredentials } from '../../aws/account';
import { arnPrefix } from '../../aws/partition';
import { ANSI, PYTHON_BASE_IMAGE } from '../../constants';
import { ANSI } from '../../constants';
import { ExecLogger } from '../../logging';
import { setupPythonProject } from '../../operations/python/setup';
import { copyAndRenderDir } from '../../templates/render';
import { getTemplatePath } from '../../templates/templateRoot';
import { resolveVpcIdFromSubnets } from '../shared/vpc-utils';
import { executeCdkImportPipeline } from './import-pipeline';
import { copyDirRecursive, fixPyprojectForSetuptools, toStackName } from './import-utils';
Expand Down Expand Up @@ -408,34 +410,11 @@ export async function handleImport(options: ImportOptions): Promise<ImportResult
logger.log('Generating Dockerfile for Container build');
onProgress?.(`Generating Dockerfile for Container build`);
const entryModule = path.basename(agent.entrypoint, '.py');
fs.writeFileSync(
destDockerfile,
[
`FROM ${PYTHON_BASE_IMAGE}`,
'RUN pip install --no-cache-dir uv',
'WORKDIR /app',
'',
'ENV UV_SYSTEM_PYTHON=1 \\',
' UV_COMPILE_BYTECODE=1 \\',
' UV_NO_PROGRESS=1 \\',
' PYTHONUNBUFFERED=1 \\',
' DOCKER_CONTAINER=1',
'',
'RUN useradd -m -u 1000 bedrock_agentcore',
'',
'COPY pyproject.toml uv.lock ./',
'RUN uv sync --frozen --no-dev --no-install-project',
'',
'COPY --chown=bedrock_agentcore:bedrock_agentcore . .',
'RUN uv sync --frozen --no-dev',
'',
'USER bedrock_agentcore',
'',
'EXPOSE 8080 8000 9000',
'',
`CMD ["opentelemetry-instrument", "python", "-m", "${entryModule}"]`,
'',
].join('\n')
await copyAndRenderDir(
getTemplatePath('container', 'python'),
appDir,
{ entrypoint: entryModule, enableOtel: true },
{ exclude: new Set(['.dockerignore']) }
);
}
}
Expand Down
Loading