diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 61ee9f203..f35fe6724 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -44,6 +44,11 @@ jobs: with: ref: ${{ inputs.ref }} persist-credentials: false + # Native binaries still run the project's CDK app in Node. CodeBuild's + # Windows image ships an older Node; use the supported runtime for tests. + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "22" - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - run: bun install --frozen-lockfile @@ -89,6 +94,9 @@ jobs: with: ref: ${{ inputs.ref }} persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "22" - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - run: bun install --frozen-lockfile - run: bun run build diff --git a/docs/runtime-prebuilt-images.md b/docs/runtime-prebuilt-images.md new file mode 100644 index 000000000..a21064f66 --- /dev/null +++ b/docs/runtime-prebuilt-images.md @@ -0,0 +1,59 @@ +# Deploy an existing Runtime image + +Use `--advanced-configuration` on `agentcore add runtime` to configure an image you have already +built and pushed to ECR: + +```sh +agentcore add runtime --name agent \ + --advanced-configuration '{"containerUri":"123456789012.dkr.ecr.us-west-2.amazonaws.com/agent:v1"}' +agentcore deploy +agentcore invoke --runtime agent --payload '{"prompt":"Hello"}' +``` + +Private ECR tags and digests, and public ECR image URIs, are supported. The image must satisfy the +AgentCore Runtime contract, including Linux ARM64 and the selected protocol's server requirements. +The toolkit does not build, copy, publish, or check the existence of this image. + +The flag accepts one JSON object. Keys use the same camelCase names and values as `agentcore.json`: + +- `containerUri`, `description`, `executionRoleArn`, `additionalPolicies` +- `envVars` (an array of `{ "name": "...", "value": "..." }` objects) +- `networkMode`, `networkConfig` +- `authorizerType`, `authorizerConfiguration` +- `protocol`, `requestHeaderAllowlist` +- `lifecycleConfiguration`, `filesystemConfigurations`, `tags` + +Unknown keys, invalid values, and a setting supplied both in this object and through an existing +standalone flag are rejected before the project is changed. Existing standalone flags continue to +work. Basic options such as `--name`, `--language`, and Bedrock Agent import selectors stay outside +this object. There is no global deploy override. + +An existing image does not need `--language`. The persisted Runtime still has `build: "Container"` +and schema-required `entrypoint` and `codeLocation` placeholders. The CLI creates no source folder, +and build/deploy do not read those placeholders or any local Dockerfile or manifest. + +`agentcore build` synthesizes the deployment. `agentcore deploy` uses the configured URI directly +and grants the Runtime execution role the applicable ECR pull permissions. Neither command +creates a managed ECR repository, CodeBuild project, source archive, or image-build custom resource +for this Runtime. `agentcore dev` requires a Runtime with local source; use deploy/invoke for an +existing image. + +Change `containerUri` in `agentcore/agentcore.json` and deploy to update the image. Use a new tag or +digest to trigger an update; replacing the contents of an unchanged tag does not change the +CloudFormation template. Named endpoints continue to select Runtime versions. + +`networkMode: "VPC"` configures the deployed workload. A pre-built image does not require +`networkConfig.vpcId` for CodeBuild and is not subject to CodeBuild's five-security-group check. +Runtime networking must still satisfy VPC and account limits, including the security-groups-per-ENI +quota. This setting does not restrict inbound invocation to private endpoints. +The deployed workload still needs the [documented VPC networking prerequisites](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agentcore-vpc.html#agentcore-vpc-endpoints), +including ECR and S3 connectivity for container images. Skipping CodeBuild removes the remote +package-installation step; it does not remove the Runtime's image-pull networking requirements. + +This feature requires a released `@aws/agentcore-cdk` package containing CDK PR #440. Older packages +can silently strip unknown schema properties; the CLI now detects that condition and fails before +deployment instead of accidentally choosing CodeBuild. Release the CDK change and update the +vended package pin before releasing the CLI change. + +The Dockerfile/CodeBuild deployment path remains the default. Toolkit-managed local builds and +private-only invocation are separate proposals. diff --git a/e2eTest/README.md b/e2eTest/README.md index ea7f54dd9..04df7a19b 100644 --- a/e2eTest/README.md +++ b/e2eTest/README.md @@ -21,3 +21,22 @@ run through a shell, so paths containing spaces, quoting, and shell syntax are n ```sh AGENTCORE_CLI_PATH=/path/to/agentcore bun run test:e2e ``` + +The pre-built Runtime tests accept existing Linux ARM64 HTTP images. The test handler must return +`{"version":"v1","echo":}` for the initial image and `"v2"` for the update: + +```sh +export AGENTCORE_PREBUILT_V1_URI='' +export AGENTCORE_PREBUILT_V1_DIGEST_URI='' +export AGENTCORE_PREBUILT_V2_URI='' +# Optional: an existing public ECR image with the v1 handler. +export AGENTCORE_PREBUILT_PUBLIC_URI='' +# Before CDK PR #440 is released, install a locally packed feature package. +export AGENTCORE_CDK_PACKAGE=/absolute/path/to/aws-agentcore-cdk.tgz +bunx vitest run --config vitest.prebuilt.e2e.config.ts +``` + +These tests skip when their image variables are absent. They exercise the JSON advanced flag, +build/deploy/invoke, digest pinning, a named endpoint, an image update retaining the Runtime ID, +absence of build resources, and stack teardown. They never create or publish the image fixtures. +The focused configuration cleans up only the stacks this suite creates. diff --git a/e2eTest/project/prebuilt-runtime.test.ts b/e2eTest/project/prebuilt-runtime.test.ts new file mode 100644 index 000000000..c3c27831e --- /dev/null +++ b/e2eTest/project/prebuilt-runtime.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, test } from "vitest"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { randomUUID } from "node:crypto"; +import { + CloudFormationClient, + DescribeStacksCommand, + GetTemplateCommand, +} from "@aws-sdk/client-cloudformation"; +import z from "zod"; +import { TAGS } from "../constants"; +import { CliRunner, parseResult } from "../helpers/run"; +import { retry } from "../helpers/retry"; +import { TIMEOUT_MS } from "../timeouts"; + +// Supply existing ARM64 HTTP fixtures; this suite never creates or publishes images. +const v1 = process.env.AGENTCORE_PREBUILT_V1_URI; +const digest = process.env.AGENTCORE_PREBUILT_V1_DIGEST_URI; +const v2 = process.env.AGENTCORE_PREBUILT_V2_URI; +const publicUri = process.env.AGENTCORE_PREBUILT_PUBLIC_URI; +const resultSchema = z.object({ message: z.string() }).passthrough(); +const operationSchema = z.object({ operation: z.string() }); +const invocationSchema = z.object({ + statusCode: z.literal(200), + body: z.string(), + complete: z.literal(true), +}); + +describe("pre-built Runtime images", { tags: [TAGS.RUNTIME] }, () => { + const cli = new CliRunner(); + const cfn = new CloudFormationClient({ + region: process.env.AWS_REGION ?? "us-west-2", + }); + + async function createProject() { + const parent = await mkdtemp(join(tmpdir(), "agentcore-prebuilt-e2e-")); + const name = `pbe2e${randomUUID().replaceAll("-", "").slice(0, 12)}`; + const created = parseResult( + z.object({ project: z.object({ path: z.string() }) }), + await cli.run( + ["create", "--name", name, "--template", "empty", "--skip-git", "--json"], + parent, + ), + ); + const path = created.project.path; + // Before the CDK release, exercise the actual package built from PR #440. + if (process.env.AGENTCORE_CDK_PACKAGE) { + await promisify(execFile)( + process.platform === "win32" ? "npm.cmd" : "npm", + ["install", process.env.AGENTCORE_CDK_PACKAGE], + { cwd: join(path, "agentcore", "cdk") }, + ); + } + const stack = `AgentCore-${name}-default`; + console.info(`pre-built Runtime test project: ${path}; stack: ${stack}`); + return { path, stack }; + } + + async function addImage(path: string, name: string, containerUri: string) { + parseResult( + operationSchema, + await cli.run( + [ + "add", + "runtime", + "--name", + name, + "--advanced-configuration", + JSON.stringify({ containerUri }), + "--json", + ], + path, + ), + ); + await expect(readFile(join(path, "app", name, "main.py"))).rejects.toMatchObject({ + code: "ENOENT", + }); + } + + async function invoke(path: string, name: string, version: string, qualifier?: string) { + await retry( + async () => { + const response = parseResult( + invocationSchema, + await cli.run( + [ + "invoke", + "--runtime", + name, + "--session-id", + randomUUID(), + "--payload", + '{"test":"prebuilt-e2e"}', + "--json", + ...(qualifier ? ["--qualifier", qualifier] : []), + ], + path, + ), + ); + expect(JSON.parse(response.body)).toEqual({ + version, + echo: { test: "prebuilt-e2e" }, + }); + }, + TIMEOUT_MS.PROJECT_INVOKE, + 2_000, + ); + } + + async function runtimeIds(stack: string) { + const response = await cfn.send(new DescribeStacksCommand({ StackName: stack })); + return Object.fromEntries( + (response.Stacks?.[0]?.Outputs ?? []) + .filter((output) => output.ExportName?.endsWith("-RuntimeId")) + .map((output) => [output.ExportName!, output.OutputValue!]), + ); + } + + async function assertNoBuilder(stack: string) { + const response = await cfn.send(new GetTemplateCommand({ StackName: stack })); + const template = JSON.parse(response.TemplateBody!) as { + Resources: Record; + }; + const types = Object.values(template.Resources).map((resource) => resource.Type); + expect(types).not.toContain("AWS::CodeBuild::Project"); + expect(types).not.toContain("AWS::ECR::Repository"); + expect(types.every((type) => !type.startsWith("Custom::"))).toBe(true); + } + + async function cleanup(path: string) { + parseResult(operationSchema, await cli.run(["remove", "all", "--yes", "--json"], path)); + parseResult(resultSchema, await cli.run(["deploy", "--yes", "--json"], path)); + } + + test.skipIf(!v1 || !digest || !v2)( + "tag, digest, named endpoint, image update, and stack teardown", + { timeout: 5 * TIMEOUT_MS.PROJECT_DEPLOY + 5 * TIMEOUT_MS.PROJECT_INVOKE }, + async () => { + const { path, stack } = await createProject(); + try { + await addImage(path, "tagged", v1!); + await addImage(path, "digest", digest!); + parseResult(resultSchema, await cli.run(["build", "--json"], path)); + parseResult(resultSchema, await cli.run(["deploy", "--yes", "--json"], path)); + await assertNoBuilder(stack); + await invoke(path, "tagged", "v1"); + await invoke(path, "digest", "v1"); + const idsBefore = await runtimeIds(stack); + + parseResult( + operationSchema, + await cli.run( + [ + "add", + "runtime-endpoint", + "--runtime", + "tagged", + "--name", + "stable", + "--version", + "1", + "--json", + ], + path, + ), + ); + const specPath = join(path, "agentcore", "agentcore.json"); + const spec = JSON.parse(await readFile(specPath, "utf8")); + spec.runtimes.find((runtime: { name: string }) => runtime.name === "tagged").containerUri = + v2; + await writeFile(specPath, JSON.stringify(spec, null, 2)); + parseResult(resultSchema, await cli.run(["deploy", "--yes", "--json"], path)); + expect(await runtimeIds(stack)).toEqual(idsBefore); + await assertNoBuilder(stack); + await invoke(path, "tagged", "v2"); + await invoke(path, "digest", "v1"); + await invoke(path, "tagged", "v1", "stable"); + } finally { + await cleanup(path); + } + }, + ); + + test.skipIf(!publicUri)( + "an existing public ECR image deploys and invokes without a build", + { timeout: 2 * TIMEOUT_MS.PROJECT_DEPLOY + TIMEOUT_MS.PROJECT_INVOKE }, + async () => { + const { path, stack } = await createProject(); + try { + await addImage(path, "public", publicUri!); + parseResult(resultSchema, await cli.run(["deploy", "--yes", "--json"], path)); + await assertNoBuilder(stack); + await invoke(path, "public", "v1"); + } finally { + await cleanup(path); + } + }, + ); +}); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index d023e82c1..35c6cc645 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -10,7 +10,7 @@ import type { ResolvedDeployedResource, ResolvedProjectResource, } from "../../../handlers/project/types"; -import { cdkCompatibilityWarning } from "./cdk/compatibility"; +import { assertPrebuiltRuntimeSupport, cdkCompatibilityWarning } from "./cdk/compatibility"; import { createLineSplitter, FsReadWriteJson, @@ -208,6 +208,7 @@ export class CdkBackend implements ProjectBackend { ); } await this.checkTool("npm", "Install Node.js: https://nodejs.org/"); + await assertPrebuiltRuntimeSupport(cdkDir, project.spec.runtimes, this.runner); } private async *synthesize( diff --git a/src/core/project/backends/cdk/compatibility.test.ts b/src/core/project/backends/cdk/compatibility.test.ts index 706562788..93d5c390a 100644 --- a/src/core/project/backends/cdk/compatibility.test.ts +++ b/src/core/project/backends/cdk/compatibility.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { cdkCompatibilityWarning, MINIMUM_COMPATIBLE_CDK_VERSION } from "./compatibility"; +import { + assertPrebuiltRuntimeSupport, + cdkCompatibilityWarning, + MINIMUM_COMPATIBLE_CDK_VERSION, +} from "./compatibility"; +import type { ProjectRuntime } from "../../../../projectSchemas/runtime"; const tempDirectories: string[] = []; @@ -57,3 +62,72 @@ describe("cdkCompatibilityWarning", () => { expect(await cdkCompatibilityWarning(directory)).toBeUndefined(); }); }); + +describe("pre-built Runtime CDK support", () => { + const runtime: ProjectRuntime = { + name: "image", + build: "Container", + codeLocation: "app/image" as ProjectRuntime["codeLocation"], + entrypoint: "main.py", + containerUri: "123456789012.dkr.ecr.us-west-2.amazonaws.com/agent:v1", + }; + + async function schemaPackage(code: string): Promise { + const directory = await cdkDirectoryWith("1.0.1"); + const packageDirectory = join(directory, "node_modules", "@aws", "agentcore-cdk"); + await writeFile( + join(packageDirectory, "package.json"), + JSON.stringify({ version: "1.0.1", main: "index.cjs" }), + ); + await writeFile(join(packageDirectory, "index.cjs"), code); + return directory; + } + + test("fails when the installed schema strips containerUri", async () => { + const directory = await schemaPackage( + "exports.AgentEnvSpecSchema = { safeParse: () => ({ success: true, data: {} }) };", + ); + await expect(assertPrebuiltRuntimeSupport(directory, [runtime])).rejects.toThrow( + "does not support pre-built Runtime images", + ); + }); + + test("accepts a schema that preserves the configured URI", async () => { + const directory = await schemaPackage( + "exports.AgentEnvSpecSchema = { safeParse: input => ({ success: true, data: input }) };", + ); + await expect(assertPrebuiltRuntimeSupport(directory, [runtime])).resolves.toBeUndefined(); + }); + + test("fails for missing or broken feature dependencies", async () => { + const directory = await cdkDirectoryWith("1.0.0"); + await expect(assertPrebuiltRuntimeSupport(directory, [runtime])).rejects.toThrow("#440"); + }); + + test("does not load the CDK package for ordinary runtimes", async () => { + await expect( + assertPrebuiltRuntimeSupport( + "/missing/project", + [{ ...runtime, containerUri: undefined }], + async () => { + throw new Error("ordinary runtimes must not launch the feature probe"); + }, + ), + ).resolves.toBeUndefined(); + }); + + test("probes with Node without passing environment secrets to the process", async () => { + const commands: string[][] = []; + await assertPrebuiltRuntimeSupport( + "/project/cdk", + [{ ...runtime, envVars: [{ name: "TOKEN", value: "sensitive-test-value" }] }], + async (command, options) => { + expect(options.cwd).toBe("/project/cdk"); + commands.push(command); + }, + ); + expect(commands[0]?.slice(0, 3)).toEqual(["node", "--input-type=commonjs", "-e"]); + expect(commands[0]?.at(-1)).toBe(runtime.containerUri); + expect(JSON.stringify(commands)).not.toContain("sensitive-test-value"); + }); +}); diff --git a/src/core/project/backends/cdk/compatibility.ts b/src/core/project/backends/cdk/compatibility.ts index 3e56bf0b0..630be8b3a 100644 --- a/src/core/project/backends/cdk/compatibility.ts +++ b/src/core/project/backends/cdk/compatibility.ts @@ -1,9 +1,61 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import semver from "semver"; +import type { ProjectRuntime } from "../../../../projectSchemas/runtime"; +import { ProjectStateError } from "../../../../errors"; +import { runProcess, type ProcessRunner } from "../../../../io"; export const MINIMUM_COMPATIBLE_CDK_VERSION = "1.0.0"; +const PREBUILT_SCHEMA_PROBE = ` +const { createRequire } = require("node:module"); +const { join } = require("node:path"); +const library = createRequire(join(process.cwd(), "package.json"))("@aws/agentcore-cdk"); +const containerUri = process.argv[1]; +const parsed = library.AgentEnvSpecSchema?.safeParse({ + name: "prebuilt_compatibility", + build: "Container", + entrypoint: "main.py", + codeLocation: "app/prebuilt_compatibility", + containerUri, +}); +if (!parsed?.success || parsed.data.containerUri !== containerUri) process.exitCode = 1; +`; + +/** + * Older Runtime schemas strip containerUri; do not let them select CodeBuild silently. + * Probe in Node, like the CDK app: standalone binaries cannot reliably resolve the + * project's external modules through their compiled-in Bun module resolver. + */ +export async function assertPrebuiltRuntimeSupport( + cdkDirectory: string, + runtimes: readonly ProjectRuntime[], + runner: ProcessRunner = runProcess, +): Promise { + const prebuilt = runtimes.find((runtime) => runtime.containerUri !== undefined); + if (!prebuilt) return; + let cause: unknown; + try { + // Only the image URI is passed, never the Runtime's environment or credentials. + await runner( + ["node", "--input-type=commonjs", "-e", PREBUILT_SCHEMA_PROBE, prebuilt.containerUri!], + { + cwd: cdkDirectory, + }, + ); + return; + } catch (error) { + // Report the feature dependency instead of an incidental module-load error. + cause = error; + } + throw new ProjectStateError( + `Runtime "${prebuilt.name}" uses containerUri, but this project's @aws/agentcore-cdk ` + + `does not support pre-built Runtime images. Install a release containing ` + + `aws/agentcore-l3-cdk-constructs#440 in ${cdkDirectory}, then rebuild or redeploy.`, + { cause }, + ); +} + export async function cdkCompatibilityWarning(cdkDirectory: string): Promise { const packagePath = join(cdkDirectory, "node_modules", "@aws", "agentcore-cdk", "package.json"); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 0772c7f00..2fb005aba 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -779,6 +779,14 @@ export class FsProjectManager implements ProjectManager { break; } case "runtime": { + if ( + isRuntimeDocsConfig(input.resourceConfig) && + input.resourceConfig.containerUri !== undefined + ) { + projectSpec.runtimes.push(buildDocsRuntimeSpec(input.resourceConfig)); + yield { type: "step", message: "configuring runtime from the existing ECR image" }; + break; + } const outputPath = join(project.rootPath, "app", input.resourceConfig.name); // The folder may hold the user's own code, but never another resource's. const owner = appFolderOwner(projectSpec, project.rootPath, outputPath); diff --git a/src/core/project/runtimeBuild.test.ts b/src/core/project/runtimeBuild.test.ts index 47ab9452d..d83de58ab 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 be74f38f3..6f83cbb56 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/core/project/templates/runtimeDocs.ts b/src/core/project/templates/runtimeDocs.ts index 09261c4d5..3409b109d 100644 --- a/src/core/project/templates/runtimeDocs.ts +++ b/src/core/project/templates/runtimeDocs.ts @@ -95,17 +95,19 @@ export function runtimeDocsTree( } /** - * The agentcore.json entry for a docs-only runtime, with starting values: a - * CodeZip build on the latest runtime for the language. dev, build, and deploy - * update build, runtimeVersion, and entrypoint from the code once it exists. + * A new runtime starts with CodeZip on the latest language runtime, or Container + * when an existing image is supplied. The pre-built path retains schema-required + * source placeholders without creating or building those files. */ export function buildDocsRuntimeSpec(input: RuntimeDocsConfig): ProjectRuntime { const { name, language, ...infra } = input; return { name, codeLocation: `app/${name}` as ProjectRuntime["codeLocation"], - build: "CodeZip", - runtimeVersion: language === "TypeScript" ? LATEST_NODE : LATEST_PYTHON, + build: infra.containerUri ? "Container" : "CodeZip", + ...(infra.containerUri + ? { containerUri: infra.containerUri } + : { runtimeVersion: language === "TypeScript" ? LATEST_NODE : LATEST_PYTHON }), entrypoint: docsRuntimeEntrypoint(language), protocol: infra.protocol ?? "HTTP", ...(infra.description && { description: infra.description }), diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts index c67ef6f2a..9ee819520 100644 --- a/src/handlers/project/add/runtime/index.test.ts +++ b/src/handlers/project/add/runtime/index.test.ts @@ -65,6 +65,174 @@ async function runtimeOf(projectRoot: string, name: string) { return spec.runtimes.find((candidate: { name: string }) => candidate.name === name); } +describe("runtime advanced configuration", () => { + const privateUri = "123456789012.dkr.ecr.us-west-2.amazonaws.com/team/agent:v1"; + + test.each([ + privateUri, + "public.ecr.aws/example/agent:v1", + `${privateUri.split(":")[0]}@sha256:${"a".repeat(64)}`, + ])("adds a pre-built image without language or local scaffolding: %s", async (containerUri) => { + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); + const { io, core } = await run([ + "add", + "runtime", + "--name", + "image", + "--advanced-configuration", + JSON.stringify({ containerUri }), + ]); + expect(await runtimeOf(projectRoot, "image")).toEqual({ + name: "image", + codeLocation: "app/image", + entrypoint: "main.py", + build: "Container", + protocol: "HTTP", + containerUri, + }); + expect(await Bun.file(join(projectRoot, "app", "image", "README.md")).exists()).toBe(false); + expect(core.projectCommands).toEqual([]); + expect(io.stderr()).toContain("from a pre-built ECR image"); + expect(io.stderr()).not.toContain("write your agent"); + }); + + test("persists validated advanced settings with a pre-built VPC image", async () => { + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); + const advanced = { + containerUri: privateUri, + networkMode: "VPC", + networkConfig: { + subnets: ["subnet-0123456789abcdef0"], + securityGroups: Array.from({ length: 6 }, (_, i) => `sg-${String(i).padStart(17, "0")}`), + }, + authorizerType: "AWS_IAM", + lifecycleConfiguration: { idleRuntimeSessionTimeout: 300 }, + envVars: [{ name: "IMAGE_VERSION", value: "v1" }], + protocol: "MCP", + tags: { test: "prebuilt" }, + }; + await run([ + "add", + "runtime", + "--name", + "image", + "--language", + "typescript", + "--advanced-configuration", + JSON.stringify(advanced), + ]); + expect(await runtimeOf(projectRoot, "image")).toMatchObject({ + ...advanced, + entrypoint: "main.js", + build: "Container", + }); + }); + + test("advanced configuration also applies to the existing CodeZip path", async () => { + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); + await run([ + "add", + "runtime", + "--name", + "source", + "--language", + "python", + "--advanced-configuration", + '{"lifecycleConfiguration":{"idleRuntimeSessionTimeout":300}}', + ]); + expect(await runtimeOf(projectRoot, "source")).toMatchObject({ + build: "CodeZip", + lifecycleConfiguration: { idleRuntimeSessionTimeout: 300 }, + }); + }); + + test.each([ + "{", + "null", + "[]", + '"not an object"', + '{"containerUri":"https://example.com/image:v1"}', + '{"container_uri":"typo"}', + '{"name":"override"}', + '{"build":"CodeZip"}', + '{"containerUri":"123456789012.dkr.ecr.us-west-2.amazonaws.com/agent:v1","dockerfile":"Dockerfile"}', + '{"networkMode":"VPC"}', + '{"authorizerType":"CUSTOM_JWT"}', + ])("rejects invalid advanced configuration before writing files: %s", async (advanced) => { + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); + const before = await specOf(projectRoot); + await expect( + run([ + "add", + "runtime", + "--name", + "invalid", + "--language", + "python", + "--advanced-configuration", + advanced, + ]), + ).rejects.toBeInstanceOf(InputValidationError); + expect(await specOf(projectRoot)).toEqual(before); + expect(await Bun.file(join(projectRoot, "app", "invalid", "README.md")).exists()).toBe(false); + }); + + test("rejects duplicate configuration from an existing flag", async () => { + const { cleanup } = await initProject(); + cleanups.push(cleanup); + await expect( + run([ + "add", + "runtime", + "--name", + "image", + "--network-mode", + "PUBLIC", + "--advanced-configuration", + JSON.stringify({ containerUri: privateUri, networkMode: "PUBLIC" }), + ]), + ).rejects.toThrow("Specify networkMode"); + }); + + test("rejects pre-built images with a Bedrock Agent import", async () => { + const { cleanup } = await initProject(); + cleanups.push(cleanup); + await expect( + run([ + "add", + "runtime", + "--name", + "image", + "--agent-id", + "A1B2C3D4E5", + "--advanced-configuration", + JSON.stringify({ containerUri: privateUri }), + ]), + ).rejects.toThrow("containerUri cannot be combined with --agent-id"); + }); + + test("rejects advanced protocol overrides for a Bedrock Agent import", async () => { + const { cleanup } = await initProject(); + cleanups.push(cleanup); + await expect( + run([ + "add", + "runtime", + "--name", + "image", + "--agent-id", + "A1B2C3D4E5", + "--advanced-configuration", + '{"protocol":"MCP"}', + ]), + ).rejects.toThrow("protocol cannot be overridden"); + }); +}); + describe("project add runtime", () => { const allInfrastructureFlags = [ "--description", diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index 84b033f8c..91e7ba4d8 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -4,7 +4,12 @@ import { isChinaRegion } from "../../../../core/partition"; import type { AddProjectResourceConfig } from "../types"; import { parseJsonFlag, parseTags, tagsFlag } from "../../../utils"; import { InputValidationError, RegionUnsupportedFeatureError } from "../../../../errors"; -import { AgentNameSchema, type EnvVar } from "../../../../projectSchemas/runtime"; +import { + AgentNameSchema, + ProjectRuntimeSchema, + type EnvVar, +} from "../../../../projectSchemas/runtime"; +import { buildDocsRuntimeSpec } from "../../../../core/project/templates/runtimeDocs"; import { RuntimeAuthorizerTypeSchema } from "../../../../projectSchemas/auth"; import { NetworkModeSchema, @@ -16,6 +21,7 @@ import type { AddResourceInput } from "../../types"; import { RuntimeDocsConfigSchema, RuntimeResourceConfigSchema, + RuntimeAdvancedConfigurationSchema, type ImportBedrockAgentInput, } from "./types"; import { @@ -60,6 +66,14 @@ export function toAddRuntimeInput(input: RuntimeInput): AddResourceInput { : RuntimeDocsConfigSchema.safeParse(input); if (!result.success) throw new InputValidationError(z.prettifyError(result.error), { cause: result.error }); + if ("scaffoldRuntimeInput" in result.data) { + if (result.data.containerUri !== undefined) + throw new InputValidationError("containerUri cannot be combined with a Bedrock Agent import"); + } else { + const runtime = ProjectRuntimeSchema.safeParse(buildDocsRuntimeSpec(result.data)); + if (!runtime.success) + throw new InputValidationError(z.prettifyError(runtime.error), { cause: runtime.error }); + } return { resourceType: "runtime", resourceConfig: result.data }; } @@ -72,7 +86,15 @@ export function runtimeDocsAddedMessage( name: string, language: "Python" | "TypeScript", protocol: ProtocolMode, + containerUri?: string, ): string { + if (containerUri !== undefined) + return [ + `added runtime "${name}" (${protocol}) from a pre-built ECR image`, + "", + "next steps:", + " 1. deploy it with agentcore deploy", + ].join("\n"); const folder = `app/${name}`; return [ `added runtime "${name}" (${language}, ${protocol})`, @@ -94,7 +116,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => flag("name", "the name of the Runtime", AgentNameSchema, { group: CONFIGURATION }), flag( "language", - "language you will write the Runtime in: python or typescript (required unless --agent-id imports a Bedrock Agent)", + "language you will write the Runtime in: python or typescript (required unless importing a Bedrock Agent or using a pre-built image)", z.enum(["python", "typescript"]).optional(), { group: CONFIGURATION }, ), @@ -108,6 +130,12 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => group: CONFIGURATION, }), tagsFlag({ group: CONFIGURATION }), + flag( + "advanced-configuration", + "advanced Runtime settings as JSON using agentcore.json keys (containerUri, networkMode, authorizerType, etc.)", + z.string().optional(), + { group: CONFIGURATION }, + ), flag( "environment-variables", "environment variables (JSON object of key/value strings)", @@ -190,9 +218,51 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => const project = ctx.require(ProjectKey); const deploymentTargets = await config.projectManager.listTargets(project); requireDeployedNameFits("Runtime", project.name, flags.name, "_", 48, deploymentTargets); + const rawAdvanced = parseJsonFlag( + "advanced-configuration", + flags["advanced-configuration"], + ); + const parsedAdvanced = RuntimeAdvancedConfigurationSchema.safeParse( + rawAdvanced === undefined ? {} : rawAdvanced, + ); + if (!parsedAdvanced.success) + throw new InputValidationError( + `Invalid --advanced-configuration:\n${z.prettifyError(parsedAdvanced.error)}`, + { cause: parsedAdvanced.error }, + ); + const advanced = parsedAdvanced.data; + const legacyFlags = { + description: "description", + executionRoleArn: "role-arn", + additionalPolicies: "additional-policies", + envVars: "environment-variables", + networkMode: "network-mode", + networkConfig: "network-config", + authorizerType: "authorizer-type", + authorizerConfiguration: "authorizer-configuration", + protocol: "protocol", + requestHeaderAllowlist: "request-header-allowlist", + lifecycleConfiguration: "lifecycle-configuration", + filesystemConfigurations: "filesystem-configurations", + tags: "tags", + } as const; + for (const [key, flagName] of Object.entries(legacyFlags)) { + if (key in advanced && flags[flagName] !== undefined) + throw new InputValidationError( + `Specify ${key} in --advanced-configuration or --${flagName}, not both`, + ); + } // Passing --agent-id is what selects a Bedrock Agent import. const isImport = flags["agent-id"] !== undefined; + if (isImport && advanced.containerUri !== undefined) + throw new InputValidationError( + "containerUri cannot be combined with --agent-id; choose a pre-built image or an import", + ); + if (isImport && advanced.protocol !== undefined) + throw new InputValidationError( + "--agent-id imports Python HTTP runtime code; protocol cannot be overridden in --advanced-configuration", + ); if (!isImport) { const offending = (["agent-alias-id", "framework"] as const).find( @@ -212,7 +282,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => `--${offending} cannot be combined with it`, ); } - } else if (flags.language === undefined) { + } else if (flags.language === undefined && advanced.containerUri === undefined) { throw new InputValidationError( "required option '--language ' not specified: pass python or typescript", ); @@ -282,6 +352,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => flags["filesystem-configurations"], ), tags: parseTags(flags["tags"]), + ...advanced, }; if (isImport) { @@ -303,14 +374,14 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => return; } - const language = RUNTIME_LANGUAGES[flags.language!]; - const protocol = flags.protocol ?? "HTTP"; + const language = RUNTIME_LANGUAGES[flags.language ?? "python"]; + const protocol = infra.protocol ?? flags.protocol ?? "HTTP"; await addProjectResource( ctx, config, project, toAddRuntimeInput({ ...infra, language, protocol }), - runtimeDocsAddedMessage(flags.name, language, protocol), + runtimeDocsAddedMessage(flags.name, language, protocol, advanced.containerUri), ); }, }); diff --git a/src/handlers/project/add/runtime/types.ts b/src/handlers/project/add/runtime/types.ts index 4ddafc7e6..7a6443bf5 100644 --- a/src/handlers/project/add/runtime/types.ts +++ b/src/handlers/project/add/runtime/types.ts @@ -8,6 +8,7 @@ export type ImportBedrockAgentInput = z.infer): Promise<{ n } describe("project dev build settings sync", () => { + test("pre-built images fail before any local builder, environment, or listener starts", async () => { + const subject = harness({ + project: project({ + ...runtime("image", "Container"), + containerUri: "123456789012.dkr.ecr.us-west-2.amazonaws.com/agent:v1", + }), + }); + await expect(subject.run({ agent: "image" })).rejects.toThrow("uses a pre-built containerUri"); + expect(subject.container.inputs).toEqual([]); + expect(subject.codeZip.inputs).toEqual([]); + expect(subject.environmentInputs).toEqual([]); + expect(subject.collector.starts).toEqual([]); + expect(subject.ui.starts).toEqual([]); + }); + async function codeProject(files: Record) { const root = await mkdtemp(join(tmpdir(), "dev-sync-")); for (const [path, content] of Object.entries(files)) { diff --git a/src/handlers/project/dev/index.ts b/src/handlers/project/dev/index.ts index f743b5a96..fe0598ec1 100644 --- a/src/handlers/project/dev/index.ts +++ b/src/handlers/project/dev/index.ts @@ -228,6 +228,14 @@ function resolveAgentSelection(project: Project, agent?: string): AgentSelection `Agent '${agent}' was not found. Available agents: ${[...runtimes, ...harnesses].map(({ name }) => name).join(", ")}.`, ); } + const prebuilt = selectedRuntimes.find((runtime) => runtime.containerUri !== undefined); + if (prebuilt) { + throw new InputValidationError( + `Runtime '${prebuilt.name}' uses a pre-built containerUri. ` + + "Deploy it with agentcore deploy, then use agentcore invoke. " + + "For local dev, select a runtime with local source using --agent .", + ); + } const supportedRuntimes = selectedRuntimes.filter(supportsLocalDev); if (!supportedRuntimes.length) { throw new NotImplementedError( diff --git a/src/projectSchemas/container.ts b/src/projectSchemas/container.ts new file mode 100644 index 000000000..c09499964 --- /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 f2a574481..b64baa957 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 967991cb1..fccbb2161 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 e74694dfb..ea4259b5f 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({ diff --git a/vitest.prebuilt.e2e.config.ts b/vitest.prebuilt.e2e.config.ts new file mode 100644 index 000000000..4510598fc --- /dev/null +++ b/vitest.prebuilt.e2e.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; +import { TAGS } from "./e2eTest/constants"; + +// This focused suite cleans up only the stacks it creates. +export default defineConfig({ + test: { + include: ["e2eTest/project/prebuilt-runtime.test.ts"], + tags: [{ name: TAGS.RUNTIME }], + reporters: ["verbose"], + }, +});