diff --git a/src/cli/commands/export/__tests__/container-export.test.ts b/src/cli/commands/export/__tests__/container-export.test.ts new file mode 100644 index 0000000000..7f7ca3eb4e --- /dev/null +++ b/src/cli/commands/export/__tests__/container-export.test.ts @@ -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>()), + 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'); + }); +}); diff --git a/src/cli/commands/export/__tests__/harness-action.test.ts b/src/cli/commands/export/__tests__/harness-action.test.ts index d3074a3d17..b5d92398a4 100644 --- a/src/cli/commands/export/__tests__/harness-action.test.ts +++ b/src/cli/commands/export/__tests__/harness-action.test.ts @@ -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'); + }); }); // ============================================================================ diff --git a/src/cli/commands/export/harness-action.ts b/src/cli/commands/export/harness-action.ts index 1bd18eaa00..576547c7c3 100644 --- a/src/cli/commands/export/harness-action.ts +++ b/src/cli/commands/export/harness-action.ts @@ -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` + @@ -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', @@ -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 \\', @@ -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', '', diff --git a/src/cli/commands/import/__tests__/import-no-deploy.test.ts b/src/cli/commands/import/__tests__/import-no-deploy.test.ts index 1bf8d5ebce..1cc497ab6b 100644 --- a/src/cli/commands/import/__tests__/import-no-deploy.test.ts +++ b/src/cli/commands/import/__tests__/import-no-deploy.test.ts @@ -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'; @@ -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', diff --git a/src/cli/commands/import/actions.ts b/src/cli/commands/import/actions.ts index 1c3f08116c..7da60ed98e 100644 --- a/src/cli/commands/import/actions.ts +++ b/src/cli/commands/import/actions.ts @@ -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'; @@ -408,34 +410,11 @@ export async function handleImport(options: ImportOptions): Promise