From 2fbdf1330fa1640c37c5c8f64b7de6e3ffc053c2 Mon Sep 17 00:00:00 2001 From: gitikavj Date: Tue, 27 Oct 2026 20:30:26 +0000 Subject: [PATCH] feat(runtime): support pre-built ECR images --- src/core/project/runtimeBuild.test.ts | 53 +++++++++++++++++++++++++++ src/core/project/runtimeBuild.ts | 27 +++++++++++++- src/projectSchemas/container.ts | 15 ++++++++ src/projectSchemas/harness.ts | 15 ++------ src/projectSchemas/runtime.test.ts | 30 +++++++++++++++ src/projectSchemas/runtime.ts | 30 +++++++++++++-- 6 files changed, 152 insertions(+), 18 deletions(-) create mode 100644 src/projectSchemas/container.ts diff --git a/src/core/project/runtimeBuild.test.ts b/src/core/project/runtimeBuild.test.ts index 47ab9452d2..d83de58abc 100644 --- a/src/core/project/runtimeBuild.test.ts +++ b/src/core/project/runtimeBuild.test.ts @@ -282,6 +282,59 @@ describe("build", () => { expect(syncRuntimeBuild(first.runtime, root).changes).toEqual([]); }); + test("a pre-built image ignores local Dockerfiles and manifests", async () => { + const root = await project({ + "app/search/Dockerfile": "FROM scratch\n", + "app/search/pyproject.toml": "[invalid", + "app/search/package.json": "{invalid", + }); + const prebuilt = runtime({ + build: "Container", + containerUri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/search:release", + runtimeVersion: undefined, + }); + + expect(syncRuntimeBuild(prebuilt, root)).toEqual({ + runtime: prebuilt, + changes: [], + warnings: [], + }); + expect(prepareRuntimes(root, [prebuilt], "deploy")).toEqual({ + runtimes: [prebuilt], + changed: [], + messages: [], + }); + }); + + test("a pre-built image does not require its local code folder", async () => { + const root = await project({}); + const prebuilt = runtime({ + build: "Container", + codeLocation: "app/missing" as ProjectRuntime["codeLocation"], + containerUri: "public.ecr.aws/example/search:release", + runtimeVersion: undefined, + }); + + expect(prepareRuntimes(root, [prebuilt], "deploy").runtimes).toEqual([prebuilt]); + }); + + test("a pre-built image removes a stale runtimeVersion without reading local files", async () => { + const root = await project({}); + const prebuilt = runtime({ + build: "Container", + codeLocation: "app/missing" as ProjectRuntime["codeLocation"], + containerUri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/search:release", + }); + + expect(syncRuntimeBuild(prebuilt, root).changes).toEqual([ + { + field: "runtimeVersion", + from: "PYTHON_3_14", + reason: "pre-built container image", + }, + ]); + }); + test("a Container runtime whose Dockerfile is missing fails instead of switching", async () => { const root = await project({ "app/search/main.py": "", diff --git a/src/core/project/runtimeBuild.ts b/src/core/project/runtimeBuild.ts index be74f38f3b..6f83cbb561 100644 --- a/src/core/project/runtimeBuild.ts +++ b/src/core/project/runtimeBuild.ts @@ -61,7 +61,9 @@ export function describeChange(name: string, change: RuntimeChange): string { */ function codeZipRevertEdits(runtime: ProjectRuntime): string { const edits = ['set "build": "CodeZip"']; - const containerOnly = (["dockerfile", "buildContextPath", "customDockerBuildArgs"] as const) + const containerOnly = ( + ["containerUri", "dockerfile", "buildContextPath", "customDockerBuildArgs"] as const + ) .filter((field) => runtime[field] !== undefined) .map((field) => `"${field}"`); if (containerOnly.length > 0) edits.push(`remove ${containerOnly.join(", ")}`); @@ -79,6 +81,26 @@ export function syncRuntimeBuild(runtime: ProjectRuntime, projectRoot: string): const next: ProjectRuntime = { ...runtime }; const reasons: Partial> = {}; const warnings: string[] = []; + + // A pre-built image is already the complete container artifact. Local manifests, Dockerfiles, + // and build contexts do not participate in deployment, so synchronization must not inspect or + // infer from them. runtimeVersion remains container-inapplicable and is cleaned up as usual. + if (runtime.containerUri !== undefined) { + if (next.runtimeVersion !== undefined) { + delete next.runtimeVersion; + reasons.runtimeVersion = "pre-built container image"; + } + const changes = (["runtimeVersion"] as const) + .filter((field) => runtime[field] !== next[field]) + .map((field) => ({ + field, + from: runtime[field], + to: next[field], + reason: reasons[field] ?? "agentcore.json", + })); + return { runtime: next, changes, warnings }; + } + const context = contextDir(runtime, projectRoot); const contextLabel = displayPath(projectRoot, context); @@ -526,7 +548,7 @@ export function prepareRuntimes( const pointAtReadme = (found: string[]) => problems.push(...found.map((problem) => `${problem.replace(/\.?$/, ".")} See ${readme}.`)); const folder = resolve(projectRoot, runtime.codeLocation); - if (!isDirectory(folder)) { + if (runtime.containerUri === undefined && !isDirectory(folder)) { pointAtReadme([ `runtime "${runtime.name}": ${displayPath(projectRoot, folder)}/ does not exist.`, ]); @@ -564,6 +586,7 @@ function runtimeProblems( const folder = resolve(projectRoot, runtime.codeLocation); const folderLabel = `${displayPath(projectRoot, folder)}/`; const problems: string[] = []; + if (runtime.containerUri !== undefined) return problems; if (runtime.build === "Container") { const context = contextDir(runtime, projectRoot); const dockerfile = runtime.dockerfile ?? DOCKERFILE_NAME; diff --git a/src/projectSchemas/container.ts b/src/projectSchemas/container.ts new file mode 100644 index 0000000000..c09499964c --- /dev/null +++ b/src/projectSchemas/container.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const CONTAINER_URI_PATTERN = + /^(([0-9]{12})\.dkr\.ecr\.([a-z0-9-]+)\.amazonaws\.com(\.cn)?|public\.ecr\.aws)\/((?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)*[a-z0-9]+(?:[._-][a-z0-9]+)*)(?::([^:@]{1,300}))?(?:@(.+))?$/; + +export const MAX_CONTAINER_URI_LENGTH = 1024; + +export const ContainerUriSchema = z + .string() + .min(1) + .max(MAX_CONTAINER_URI_LENGTH) + .regex( + CONTAINER_URI_PATTERN, + "containerUri must be an ECR image URI (12-digit private ECR or public.ecr.aws)", + ); diff --git a/src/projectSchemas/harness.ts b/src/projectSchemas/harness.ts index f2a5744811..b64baa957a 100644 --- a/src/projectSchemas/harness.ts +++ b/src/projectSchemas/harness.ts @@ -11,14 +11,13 @@ import { SessionStorageSchema, } from "./runtime"; import { AuthorizerConfigSchema, RuntimeAuthorizerTypeSchema } from "./auth"; +import { ContainerUriSchema } from "./container"; import { ConnectionSchema } from "./connections"; import { uniqueBy } from "./zod-util"; import { TagsSchema } from "./tags"; import { z } from "zod"; import { modelIdHelp } from "./modelDocs"; -export const CONTAINER_URI_PATTERN = - /^(([0-9]{12})\.dkr\.ecr\.([a-z0-9-]+)\.amazonaws\.com(\.cn)?|public\.ecr\.aws)\/((?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)*[a-z0-9]+(?:[._-][a-z0-9]+)*)(?::([^:@]{1,300}))?(?:@(.+))?$/; -export const MAX_CONTAINER_URI_LENGTH = 1024; +export { CONTAINER_URI_PATTERN, MAX_CONTAINER_URI_LENGTH } from "./container"; export const MAX_ENV_VAR_VALUE_LENGTH = 5000; export const MAX_ENV_VARS = 50; export const MAX_ENV_VAR_KEY_LENGTH = 100; @@ -515,15 +514,7 @@ export const HarnessSpecSchema = z maxTokens: z.number().int().min(1).optional(), timeoutSeconds: z.number().int().min(1).optional(), truncation: HarnessTruncationConfigSchema.optional(), - containerUri: z - .string() - .min(1) - .max(MAX_CONTAINER_URI_LENGTH) - .regex( - CONTAINER_URI_PATTERN, - "containerUri must be an ECR image URI (12-digit private ECR or public.ecr.aws)", - ) - .optional(), + containerUri: ContainerUriSchema.optional(), dockerfile: z.string().min(1).optional(), executionRoleArn: z.string().optional(), networkMode: NetworkModeSchema.optional(), diff --git a/src/projectSchemas/runtime.test.ts b/src/projectSchemas/runtime.test.ts index 967991cb15..fccbb21611 100644 --- a/src/projectSchemas/runtime.test.ts +++ b/src/projectSchemas/runtime.test.ts @@ -19,6 +19,12 @@ const containerAgent = { entrypoint: "main.py", codeLocation: "./agent", }; +const privateContainerUri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/agent:latest"; +const publicContainerUri = "public.ecr.aws/example/agent@sha256:abc123"; +const prebuiltAgent = { + ...containerAgent, + containerUri: privateContainerUri, +}; const networkConfig = { subnets: ["subnet-0123456789abcdef0"], securityGroups: ["sg-0123456789abcdef0"], @@ -84,6 +90,7 @@ describe("runtime custom validation", () => { }); it("restricts container-only fields to container builds", () => { for (const field of [ + { containerUri: privateContainerUri }, { dockerfile: "Dockerfile" }, { buildContextPath: "." }, { customDockerBuildArgs: { KEY: "value" } }, @@ -92,6 +99,27 @@ describe("runtime custom validation", () => { expect(ProjectRuntimeSchema.safeParse({ ...containerAgent, ...field }).success).toBe(true); } }); + it.each([privateContainerUri, publicContainerUri])( + "accepts a supported pre-built ECR image URI: %s", + (containerUri) => { + expect(ProjectRuntimeSchema.safeParse({ ...prebuiltAgent, containerUri }).success).toBe(true); + }, + ); + it.each([ + "docker.io/example/agent:latest", + "12345678901.dkr.ecr.us-east-1.amazonaws.com/agent:latest", + "123456789012.dkr.ecr.us-east-1.amazonaws.com/UPPERCASE:latest", + `${privateContainerUri}${"x".repeat(1025)}`, + ])("rejects an invalid pre-built image URI: %s", (containerUri) => { + expect(ProjectRuntimeSchema.safeParse({ ...prebuiltAgent, containerUri }).success).toBe(false); + }); + it.each([ + { dockerfile: "Dockerfile" }, + { buildContextPath: "." }, + { customDockerBuildArgs: { STAGE: "prod" } }, + ])("rejects local build configuration with containerUri: %j", (field) => { + expect(ProjectRuntimeSchema.safeParse({ ...prebuiltAgent, ...field }).success).toBe(false); + }); it("rejects reserved build args and control characters", () => { expect( ProjectRuntimeSchema.safeParse({ @@ -150,6 +178,7 @@ describe("runtime custom validation", () => { expect(capped.success).toBe(false); expect(capped.error?.issues[0]?.path).toEqual(["networkConfig", "securityGroups"]); expect(ProjectRuntimeSchema.safeParse({ ...codeZipAgent, ...vpc }).success).toBe(true); + expect(ProjectRuntimeSchema.safeParse({ ...prebuiltAgent, ...vpc }).success).toBe(true); }); it("requires a VPC ID for container builds in VPC mode only", () => { const vpc = { networkMode: "VPC" as const, networkConfig }; @@ -166,6 +195,7 @@ describe("runtime custom validation", () => { // CodeZip never reaches CodeBuild, so it needs no VPC ID. expect(ProjectRuntimeSchema.safeParse({ ...codeZipAgent, ...vpc }).success).toBe(true); + expect(ProjectRuntimeSchema.safeParse({ ...prebuiltAgent, ...vpc }).success).toBe(true); expect(ProjectRuntimeSchema.safeParse(containerAgent).success).toBe(true); }); }); diff --git a/src/projectSchemas/runtime.ts b/src/projectSchemas/runtime.ts index e74694dfba..ea4259b5f2 100644 --- a/src/projectSchemas/runtime.ts +++ b/src/projectSchemas/runtime.ts @@ -6,10 +6,10 @@ import { SECURITY_GROUP_ID_PATTERN, SUBNET_ID_PATTERN, VPC_ID_PATTERN, - isContainerBuild, } from "./constants"; import type { DirectoryPath } from "./types"; import { AuthorizerConfigSchema, RuntimeAuthorizerTypeSchema } from "./auth"; +import { ContainerUriSchema } from "./container"; import { ConnectionSchema } from "./connections"; import { TagsSchema } from "./tags"; import { z } from "zod"; @@ -308,6 +308,11 @@ export const ProjectRuntimeSchema = z build: BuildTypeSchema, entrypoint: EntrypointSchema, codeLocation: DirectoryPathSchema, + /** + * Existing private or public ECR image to deploy. When set, deployment skips local source, + * Dockerfile validation, CodeBuild, and the CDK-managed ECR repository. + */ + containerUri: ContainerUriSchema.optional(), dockerfile: DockerfilePathSchema.optional(), buildContextPath: DirectoryPathSchema.optional(), customDockerBuildArgs: z.record(EnvVarNameSchema, BuildArgValueSchema).optional(), @@ -331,6 +336,7 @@ export const ProjectRuntimeSchema = z connections: z.array(ConnectionSchema).optional(), }) .superRefine((data, ctx) => { + const requiresContainerBuild = data.build === "Container" && data.containerUri === undefined; if (data.networkMode === "VPC" && !data.networkConfig) { ctx.addIssue({ code: "custom", @@ -347,7 +353,7 @@ export const ProjectRuntimeSchema = z } if ( data.networkMode === "VPC" && - isContainerBuild(data) && + requiresContainerBuild && data.networkConfig && data.networkConfig.securityGroups.length > MAX_CONTAINER_BUILD_SECURITY_GROUPS ) { @@ -359,7 +365,7 @@ export const ProjectRuntimeSchema = z } // Mirrors the CDK, which feeds networkConfig.vpcId to the CodeBuild project's // VpcConfig. Only `build: "Container"` reaches that path, so CodeZip is exempt. - if (data.networkMode === "VPC" && data.build === "Container" && !data.networkConfig?.vpcId) { + if (data.networkMode === "VPC" && requiresContainerBuild && !data.networkConfig?.vpcId) { ctx.addIssue({ code: "custom", message: @@ -396,7 +402,12 @@ export const ProjectRuntimeSchema = z path: ["runtimeVersion"], }); } - for (const field of ["dockerfile", "buildContextPath", "customDockerBuildArgs"] as const) { + for (const field of [ + "containerUri", + "dockerfile", + "buildContextPath", + "customDockerBuildArgs", + ] as const) { if (data.build !== "Container" && data[field]) { ctx.addIssue({ code: "custom", @@ -405,6 +416,17 @@ export const ProjectRuntimeSchema = z }); } } + if (data.containerUri !== undefined) { + for (const field of ["dockerfile", "buildContextPath", "customDockerBuildArgs"] as const) { + if (data[field] !== undefined) { + ctx.addIssue({ + code: "custom", + message: `${field} cannot be used with containerUri`, + path: [field], + }); + } + } + } for (const key of Object.keys(data.customDockerBuildArgs ?? {})) { if (isReservedBuildArgKey(key)) { ctx.addIssue({