diff --git a/src/assets/templates/strands-http-python/Dockerfile.template b/src/assets/templates/strands-http-python/Dockerfile.template new file mode 100644 index 000000000..cb3569eff --- /dev/null +++ b/src/assets/templates/strands-http-python/Dockerfile.template @@ -0,0 +1,40 @@ +FROM public.ecr.aws/docker/library/python:3.12-slim-trixie + +RUN pip install --no-cache-dir uv + +ARG UV_DEFAULT_INDEX +ARG UV_INDEX + +WORKDIR /app + +ENV UV_SYSTEM_PYTHON=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_NO_PROGRESS=1 \ + PYTHONUNBUFFERED=1 \ + DOCKER_CONTAINER=1 \ + UV_DEFAULT_INDEX=${UV_DEFAULT_INDEX} \ + UV_INDEX=${UV_INDEX} \ + PATH="/app/.venv/bin:$PATH" + +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 + +# AgentCore Runtime service contract ports +# https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-service-contract.html +# 8080: HTTP Mode +# 8000: MCP Mode +# 9000: A2A Mode +EXPOSE 8080 8000 9000 + +{{#if enableOtel}} +CMD ["opentelemetry-instrument", "python", "-m", "{{entrypoint}}"] +{{else}} +CMD ["python", "-m", "{{entrypoint}}"] +{{/if}} diff --git a/src/assets/templates/strands-http-python/dockerignore.template b/src/assets/templates/strands-http-python/dockerignore.template new file mode 100644 index 000000000..a0c4eb658 --- /dev/null +++ b/src/assets/templates/strands-http-python/dockerignore.template @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +dist/ +build/ + +# IDE +.vscode/ +.idea/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Secrets and environment files +.env +.env.* + +# Version control +.git/ + +# AgentCore build artifacts +.agentcore/artifacts/ +*.zip diff --git a/src/core/project/templates/runtime.ts b/src/core/project/templates/runtime.ts index 6a15566d1..39e538393 100644 --- a/src/core/project/templates/runtime.ts +++ b/src/core/project/templates/runtime.ts @@ -78,9 +78,6 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa if (input.protocol !== undefined && input.protocol !== "HTTP") throw new InputValidationError("the strands-python template only supports HTTP"); - if (input.scaffoldRuntimeInput.build !== "CodeZip") - throw new InputValidationError("the strands template only supports CodeZip builds"); - const filesystemConfigurations = input.filesystemConfigurations ?? []; const sessionStorageMountPath = filesystemConfigurations.flatMap((configuration) => "sessionStorage" in configuration ? [configuration.sessionStorage.mountPath] : [], @@ -115,14 +112,22 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa s3Mounts, needsOs: filesystemConfigurations.length > 0, hasConfigBundle: false, + enableOtel: true, + // entrypoint is only consumed on container path, where the docker file attempts to launch it as a module (without .py extension) + entrypoint: input.scaffoldRuntimeInput.entrypoint.replace(/\.py$/, ""), }; + const isContainer = input.scaffoldRuntimeInput.build === "Container"; const tree = await FsTreeNode.fromAssetSource( { assetSource }, { assetDir: "templates/strands-http-python" }, { rootDirName: input.name, transformContent: (raw) => templateRenderer.render(raw, context), - filter: (name, isDir) => memory !== undefined || !isDir || name !== "memory", + filter: (name, isDir) => { + if (isDir && name === "memory") return memory !== undefined; + if (name === "Dockerfile" || name === ".dockerignore") return isContainer; + return true; + }, }, ); return { diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts index e5c2f047f..59d52a83a 100644 --- a/src/handlers/project/add/runtime/index.test.ts +++ b/src/handlers/project/add/runtime/index.test.ts @@ -101,6 +101,10 @@ describe("project add runtime", () => { "container template build override to CodeZip": { build: "CodeZip", }, + "strands template overrides to Container": { + build: "Container", + dockerfile: "Dockerfile", + }, "all infrastructure flags": { description: "Configured runtime", executionRoleArn: "arn:aws:iam::123456789012:role/MyRole", @@ -155,6 +159,10 @@ describe("project add runtime", () => { "container template build override to CodeZip", ["--name", "my_agent", "--template", "hello-world-python-container", "--build", "CodeZip"], ], + [ + "strands template overrides to Container", + ["--name", "my_agent", "--template", "strands-python", "--build", "Container"], + ], ["custom — all scaffolding flags", ["--name", "my_agent", ...allScaffoldingFlags]], [ "custom — framework strands", @@ -319,6 +327,10 @@ describe("project add runtime", () => { ? flags[buildFlagIndex + 1] === "Container" : flags.includes("hello-world-python-container"); expect(runtime.runtimeVersion).toBe(isContainer ? undefined : "PYTHON_3_14"); + expect(await Bun.file(join(projectRoot, "app", name, "Dockerfile")).exists()).toBe(isContainer); + expect(await Bun.file(join(projectRoot, "app", name, ".dockerignore")).exists()).toBe( + isContainer, + ); }); test.each([ @@ -386,10 +398,6 @@ describe("project add runtime", () => { "strands-python only supports HTTP", ["--name", "my_agent", "--template", "strands-python", "--protocol", "MCP"], ], - [ - "strands-python only supports CodeZip builds", - ["--name", "my_agent", "--template", "strands-python", "--build", "Container"], - ], [ "invalid JSON in --network-config", ["--name", "my_agent", ...template, "--network-config", "{bad}"], diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 742a4965c..686289229 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -163,6 +163,51 @@ describe("project create", () => { expect(await Bun.file(join(projectRoot, "app", "custom_agent", "main.py")).exists()).toBe(true); }); + test("scaffolds a Container agent from the strands template", async () => { + const directory = await inTempDirectory(); + await run([ + "create", + "--name", + "MyProject", + "--template", + "strands-python", + "--build", + "Container", + "--skip-install", + "--skip-git", + ]); + + const projectRoot = join(directory, "MyProject"); + const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(spec.runtimes[0]).toMatchObject({ + name: "strands_agent", + build: "Container", + codeLocation: "app/strands_agent", + dockerfile: "Dockerfile", + }); + expect(spec.runtimes[0].runtimeVersion).toBeUndefined(); + const runtimeRoot = join(projectRoot, "app", "strands_agent"); + expect(await Bun.file(join(runtimeRoot, "Dockerfile")).exists()).toBe(true); + expect(await Bun.file(join(runtimeRoot, ".dockerignore")).exists()).toBe(true); + }); + + test("omits the Dockerfile from a CodeZip strands template", async () => { + const directory = await inTempDirectory(); + await run([ + "create", + "--name", + "MyProject", + "--template", + "strands-python", + "--skip-install", + "--skip-git", + ]); + + const runtimeRoot = join(directory, "MyProject", "app", "strands_agent"); + expect(await Bun.file(join(runtimeRoot, "Dockerfile")).exists()).toBe(false); + expect(await Bun.file(join(runtimeRoot, ".dockerignore")).exists()).toBe(false); + }); + test.each([ ["default", [], ["SEMANTIC", "USER_PREFERENCE", "SUMMARIZATION", "EPISODIC"]], ["none", ["--memory", "none"], []],