Skip to content
Draft
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
53 changes: 53 additions & 0 deletions src/core/project/runtimeBuild.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand Down
27 changes: 25 additions & 2 deletions src/core/project/runtimeBuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(", ")}`);
Expand All @@ -79,6 +81,26 @@ export function syncRuntimeBuild(runtime: ProjectRuntime, projectRoot: string):
const next: ProjectRuntime = { ...runtime };
const reasons: Partial<Record<SyncedField, string>> = {};
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);

Expand Down Expand Up @@ -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.`,
]);
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions src/projectSchemas/container.ts
Original file line number Diff line number Diff line change
@@ -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)",
);
15 changes: 3 additions & 12 deletions src/projectSchemas/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down
30 changes: 30 additions & 0 deletions src/projectSchemas/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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" } },
Expand All @@ -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({
Expand Down Expand Up @@ -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 };
Expand All @@ -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);
});
});
30 changes: 26 additions & 4 deletions src/projectSchemas/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
Expand All @@ -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",
Expand All @@ -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
) {
Expand All @@ -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:
Expand Down Expand Up @@ -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",
Expand All @@ -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({
Expand Down
Loading