diff --git a/src/handlers/project/add/index.ts b/src/handlers/project/add/index.ts index 8545ccba3..2d7970bfa 100644 --- a/src/handlers/project/add/index.ts +++ b/src/handlers/project/add/index.ts @@ -1,11 +1,13 @@ import { withProject } from "../../../middleware/"; import { Router } from "../../../router"; import { createAddHarnessHandler } from "./harness"; +import { createAddRuntimeHandler } from "./runtime"; import type { AddProjectResourceConfig } from "./types"; export function createAddProjectResourceHandler(config: AddProjectResourceConfig): Router { const projectAdd = new Router("add", "add project resources"); projectAdd.use(withProject({ projectManager: config.projectManager, cwd: process.cwd() })); projectAdd.handler(createAddHarnessHandler(config)); + projectAdd.handler(createAddRuntimeHandler(config)); return projectAdd; } diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts new file mode 100644 index 000000000..f26779760 --- /dev/null +++ b/src/handlers/project/add/runtime/index.ts @@ -0,0 +1,370 @@ +import z from "zod"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import type { AddProjectResourceConfig } from "../types"; +import { parseJsonFlag } from "../../../utils"; +import { InputValidationError } from "../../../../errors"; +import type { + AuthorizerConfiguration, + FilesystemConfiguration, + LifecycleConfiguration, + NetworkConfiguration, + ProtocolConfiguration, + RequestHeaderConfiguration, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { + type EnvVar, + type FilesystemConfiguration as ProjectFilesystemConfiguration, + type NetworkConfig, + BuildTypeSchema, +} from "../../../../projectSchemas/runtime"; +import type { AuthorizerConfig, RuntimeAuthorizerType } from "../../../../projectSchemas/auth"; +import { + type NetworkMode, + ProtocolModeSchema, + RuntimeVersionSchema, +} from "../../../../projectSchemas/constants"; +import { + runtimeModelProviderSchema, + RUNTIME_TEMPLATES, + runtimeMemoryConfigSchema, +} from "../../types"; +import { SourceResolver } from "../../../../io"; + +export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => + createHandler({ + name: "runtime", + description: + "adds a runtime to the current project either from a template or from existing local code", + flags: [ + flag("name", "the name of the runtime", z.string().optional()), + flag("description", "an optional description of the runtime", z.string().optional()), + flag("template", "template to scaffold from", z.enum(RUNTIME_TEMPLATES).optional()), + flag( + "role-arn", + "IAM role ARN that provides permissions for the runtime", + z.string().optional(), + ), + flag("code-location", "path to existing agent source code (BYO path)", z.string().optional()), + flag("build", "build type: CodeZip or Container", BuildTypeSchema.optional()), + flag("entrypoint", "entrypoint file, e.g. main.py:handler (BYO only)", z.string().optional()), + flag("protocol", "server protocol ex. HTTP, MCP, A2A, AGUI", ProtocolModeSchema.optional()), + flag( + "api-key", + "API key source for non-bedrock model providers: '-' for stdin, 'file://path' for file", + z.string().optional(), + ), + flag( + "model-provider", + "model provider (template only)", + runtimeModelProviderSchema.optional(), + ), + flag( + "runtime-version", + "language runtime, e.g. PYTHON_3_13, NODE_22 (BYO CodeZip only)", + RuntimeVersionSchema.optional(), + ), + flag( + "dockerfile", + "dockerfile path for the container build (BYO Container only)", + z.string().optional(), + ), + flag( + "build-context-path", + "docker build context directory relative to project root (BYO Container only)", + z.string().optional(), + ), + flag( + "custom-docker-build-args", + "docker build args as JSON key/value object (BYO Container only)", + z.string().optional(), + ), + flag( + "additional-policies", + "additional IAM policy ARNs or policy document paths for the execution role", + z.array(z.string()).optional(), + ), + flag( + "network-configuration", + "network configuration (JSON NetworkConfiguration)", + z.string().optional(), + ), + flag( + "vpc-id", + "VPC ID for Container builds in VPC mode (CodeBuild cannot infer it from subnets)", + z.string().optional(), + ), + flag( + "authorizer-configuration", + "inbound authorizer configuration (JSON AuthorizerConfiguration)", + z.string().optional(), + ), + flag( + "protocol-configuration", + "protocol configuration (JSON ProtocolConfiguration)", + z.string().optional(), + ), + flag( + "request-header-configuration", + "request header passthrough configuration (JSON RequestHeaderConfiguration)", + z.string().optional(), + ), + flag( + "lifecycle-configuration", + "lifecycle configuration (JSON LifecycleConfiguration)", + z.string().optional(), + ), + flag( + "environment-variables", + "environment variables (JSON object of key/value strings)", + z.string().optional(), + ), + flag( + "filesystem-configurations", + "filesystem mount configurations (JSON FilesystemConfiguration[])", + z.string().optional(), + ), + flag( + "memory", + "memory configuration (JSON with mode: none | create | existing ) (template only)", + z.string().optional(), + ), + flag("tags", "tags to apply (JSON object of key/value strings)", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags.name) + throw new InputValidationError("required option '--name ' not specified"); + + if (flags.template && flags["code-location"]) + throw new InputValidationError("--template and --code-location are mutually exclusive"); + + const isTemplate = !flags["code-location"]; + const template = flags.template ?? RUNTIME_TEMPLATES.HELLO_WORLD_PYTHON; + const templateOnlyFlags = (["memory", "model-provider", "api-key"] as const).filter( + (f) => flags[f], + ); + const byoOnlyFlags = ( + [ + "entrypoint", + "runtime-version", + "dockerfile", + "build-context-path", + "custom-docker-build-args", + ] as const + ).filter((f) => flags[f]); + + if (isTemplate && byoOnlyFlags.length > 0) + throw new InputValidationError( + `--${byoOnlyFlags[0]} is only available on the BYO path (--code-location)`, + ); + if (!isTemplate && templateOnlyFlags.length > 0) + throw new InputValidationError( + `--${templateOnlyFlags[0]} is only available on the template path (--template)`, + ); + + const inputNetwork = parseJsonFlag( + "network-configuration", + flags["network-configuration"], + ); + const inputAuthConfig = parseJsonFlag( + "authorizer-configuration", + flags["authorizer-configuration"], + ); + const inputProtocol = parseJsonFlag( + "protocol-configuration", + flags["protocol-configuration"], + ); + const inputRequestHeaders = parseJsonFlag( + "request-header-configuration", + flags["request-header-configuration"], + ); + const inputLifecycle = parseJsonFlag( + "lifecycle-configuration", + flags["lifecycle-configuration"], + ); + const inputFilesystems = parseJsonFlag( + "filesystem-configurations", + flags["filesystem-configurations"], + ); + const inputEnvironmentVariables = parseJsonFlag>( + "environment-variables", + flags["environment-variables"], + ); + const memoryConfiguration = parseMemoryConfig(flags["memory"]); + + // TODO: make entrypoint optional since container agents don't need it. + const entrypoint = flags.entrypoint ?? "main.py"; + + const network = toNetwork(inputNetwork); + + const source = new SourceResolver({ stdin: config.io.stdin }); + const apiKey = await source.resolveText("api-key", flags["api-key"]); + + if (flags["custom-docker-build-args"] && !flags.dockerfile && !flags["build-context-path"]) + throw new InputValidationError( + "--custom-docker-build-args requires --dockerfile or --build-context-path", + ); + + if (flags["vpc-id"] && !network?.networkConfig) + throw new InputValidationError( + "--vpc-id requires --network-configuration with VPC network configuration", + ); + + if (flags["protocol"] && flags["protocol-configuration"]) + throw new InputValidationError( + "--protocol and --protocol-configuration are mutually exclusive", + ); + + const auth = toAuthorizer(inputAuthConfig); + const requestHeaderAllowlist = toRequestHeaderAllowlist(inputRequestHeaders); + const filesystemConfigurations = toFilesystems(inputFilesystems); + + const infraConfig = { + name: flags.name, + description: flags.description, + executionRoleArn: flags["role-arn"], + additionalPolicies: flags["additional-policies"], + envVars: toEnvironmentVariables(inputEnvironmentVariables), + networkMode: network?.networkMode, + networkConfig: network?.networkConfig + ? { ...network.networkConfig, ...(flags["vpc-id"] ? { vpcId: flags["vpc-id"] } : {}) } + : undefined, + authorizerType: auth?.authorizerType, + authorizerConfiguration: auth?.authorizerConfiguration, + protocol: flags["protocol"] ?? inputProtocol?.serverProtocol, + requestHeaderAllowlist, + lifecycleConfiguration: inputLifecycle, + filesystemConfigurations, + tags: parseJsonFlag>("tags", flags["tags"]), + }; + + const runtimeConfig = isTemplate + ? { + source: "template" as const, + template, + memory: memoryConfiguration, + modelProvider: { apiKey, provider: flags["model-provider"] }, + ...infraConfig, + } + : { + source: "byo" as const, + codeLocation: flags["code-location"]!, + build: flags.build, + entrypoint, + runtimeVersion: flags["runtime-version"], + dockerfile: flags.dockerfile, + buildContextPath: flags["build-context-path"], + customDockerBuildArgs: parseJsonFlag>( + "custom-docker-build-args", + flags["custom-docker-build-args"], + ), + ...infraConfig, + }; + + const project = ctx.require(ProjectKey); + for await (const event of config.projectManager.addResource(project, { + resourceType: "runtime", + resourceConfig: runtimeConfig, + })) { + config.io.stderr.write(`${event.message}\n`); + } + + config.io.stderr.write(`added runtime '${flags.name}' to '${project.name}'\n`); + }, + }); + +/** Parses and validates the --memory JSON flag against the runtime memory config schema. */ +function parseMemoryConfig( + raw: string | undefined, +): z.infer | undefined { + if (!raw) return undefined; + const parsed = parseJsonFlag>("memory", raw); + const result = runtimeMemoryConfigSchema.safeParse(parsed); + if (!result.success) throw new InputValidationError(z.prettifyError(result.error)); + return result.data; +} + +/** Converts API flat {key: value} map to project schema [{name, value}] array. */ +function toEnvironmentVariables(envVars: Record | undefined): EnvVar[] { + return envVars ? Object.entries(envVars).map(([name, value]) => ({ name, value })) : []; +} + +/** Converts API NetworkConfiguration to project schema networkMode + networkConfig fields. */ +function toNetwork( + network: NetworkConfiguration | undefined, +): { networkMode: NetworkMode; networkConfig: NetworkConfig | undefined } | undefined { + if (!network) return undefined; + return { + networkMode: network.networkMode as NetworkMode, + networkConfig: network.networkModeConfig + ? { + subnets: network.networkModeConfig.subnets ?? [], + securityGroups: network.networkModeConfig.securityGroups ?? [], + } + : undefined, + }; +} + +/** Converts API AuthorizerConfiguration union to project schema authorizerType + authorizerConfiguration. */ +function toAuthorizer( + auth: AuthorizerConfiguration | undefined, +): + { authorizerType: RuntimeAuthorizerType; authorizerConfiguration: AuthorizerConfig } | undefined { + if (!auth) return undefined; + if ("customJWTAuthorizer" in auth && auth.customJWTAuthorizer) { + const c = auth.customJWTAuthorizer; + if (!c.discoveryUrl) + throw new InputValidationError("discoveryUrl is required in authorizer configuration"); + return { + authorizerType: "CUSTOM_JWT", + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl: c.discoveryUrl, + allowedAudience: c.allowedAudience, + allowedClients: c.allowedClients, + allowedScopes: c.allowedScopes, + }, + }, + }; + } + throw new InputValidationError("Unrecognized authorizer configuration variant"); +} + +/** Unwraps API RequestHeaderConfiguration union to project schema string[]. */ +function toRequestHeaderAllowlist( + headers: RequestHeaderConfiguration | undefined, +): string[] | undefined { + if (!headers) return undefined; + if ("requestHeaderAllowlist" in headers && headers.requestHeaderAllowlist) { + return headers.requestHeaderAllowlist; + } + throw new InputValidationError("Unrecognized request header configuration variant"); +} + +/** Converts API FilesystemConfiguration[] tagged unions to project schema format. */ +function toFilesystems( + filesystems: FilesystemConfiguration[] | undefined, +): ProjectFilesystemConfiguration[] | undefined { + if (!filesystems || filesystems.length === 0) return undefined; + return filesystems.map((fs): ProjectFilesystemConfiguration => { + if ("sessionStorage" in fs && fs.sessionStorage) { + return { sessionStorage: { mountPath: fs.sessionStorage.mountPath! } }; + } + if ("efsAccessPoint" in fs && fs.efsAccessPoint) { + return { + efsAccessPoint: { + accessPointArn: fs.efsAccessPoint.accessPointArn!, + mountPath: fs.efsAccessPoint.mountPath!, + }, + }; + } + if ("s3FilesAccessPoint" in fs && fs.s3FilesAccessPoint) { + return { + s3FilesAccessPoint: { + accessPointArn: fs.s3FilesAccessPoint.accessPointArn!, + mountPath: fs.s3FilesAccessPoint.mountPath!, + }, + }; + } + throw new InputValidationError("Unrecognized filesystem configuration variant"); + }); +} diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index ee479dac9..d5b4a63dc 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -10,7 +10,7 @@ import { TestGlobalConfigAccessor, testIO, } from "../../testing"; -import { InputValidationError } from "../../errors"; +import { InputValidationError, NotImplementedError } from "../../errors"; import { FsReadWriteJson, type ReadWriteJson } from "../../io"; async function run(args: string[], opts?: { core?: TestCoreClient }) { @@ -715,3 +715,353 @@ describe("project build", () => { await expect(run(["build"])).rejects.toThrow(/npm install/); }); }); + +// TODO: Replace NotImplementedError assertions with output assertions once +// FsProjectManager.addResource supports the "runtime" resource type. +describe("project add runtime", () => { + const byo = ["--code-location", "app/my_agent"]; + const template = ["--template", "hello-world-python"]; + + // Verifies each valid flag combination passes handler validation. + test.each<[string, string[]]>([ + ["minimal — name only (defaults to template)", ["--name", "my_agent"]], + ["explicit template path", ["--name", "my_agent", ...template]], + ["minimal — BYO path with build", ["--name", "my_agent", ...byo, "--build", "CodeZip"]], + [ + "BYO container with dockerfile", + ["--name", "my_agent", ...byo, "--build", "Container", "--dockerfile", "Dockerfile"], + ], + [ + "entrypoint + runtime-version for CodeZip", + [ + "--name", + "my_agent", + ...byo, + "--build", + "CodeZip", + "--entrypoint", + "app.py:main", + "--runtime-version", + "PYTHON_3_13", + ], + ], + ["description", ["--name", "my_agent", ...template, "--description", "A test agent"]], + [ + "role-arn", + ["--name", "my_agent", ...template, "--role-arn", "arn:aws:iam::123456789012:role/MyRole"], + ], + [ + "network-configuration — VPC", + [ + "--name", + "my_agent", + ...template, + "--network-configuration", + '{"networkMode":"VPC","networkModeConfig":{"subnets":["subnet-abc"],"securityGroups":["sg-123"]}}', + ], + ], + [ + "network-configuration — PUBLIC", + ["--name", "my_agent", ...template, "--network-configuration", '{"networkMode":"PUBLIC"}'], + ], + [ + "authorizer-configuration — customJWT", + [ + "--name", + "my_agent", + ...template, + "--authorizer-configuration", + '{"customJWTAuthorizer":{"discoveryUrl":"https://idp.example.com/.well-known/openid-configuration","allowedAudience":["app"]}}', + ], + ], + [ + "protocol-configuration — MCP", + ["--name", "my_agent", ...template, "--protocol-configuration", '{"serverProtocol":"MCP"}'], + ], + [ + "protocol-configuration — A2A", + ["--name", "my_agent", ...template, "--protocol-configuration", '{"serverProtocol":"A2A"}'], + ], + [ + "protocol-configuration — AGUI", + ["--name", "my_agent", ...template, "--protocol-configuration", '{"serverProtocol":"AGUI"}'], + ], + [ + "request-header-configuration", + [ + "--name", + "my_agent", + ...template, + "--request-header-configuration", + '{"requestHeaderAllowlist":["X-Custom-Header","Authorization"]}', + ], + ], + [ + "lifecycle-configuration", + [ + "--name", + "my_agent", + ...template, + "--lifecycle-configuration", + '{"idleRuntimeSessionTimeout":300,"maxLifetime":3600}', + ], + ], + [ + "environment-variables", + [ + "--name", + "my_agent", + ...template, + "--environment-variables", + '{"LOG_LEVEL":"debug","APP_ENV":"staging"}', + ], + ], + [ + "filesystem-configurations — sessionStorage", + [ + "--name", + "my_agent", + ...template, + "--filesystem-configurations", + '[{"sessionStorage":{"mountPath":"/mnt/data"}}]', + ], + ], + [ + "filesystem-configurations — efsAccessPoint", + [ + "--name", + "my_agent", + ...template, + "--filesystem-configurations", + '[{"efsAccessPoint":{"accessPointArn":"arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-abc","mountPath":"/mnt/efs"}}]', + ], + ], + [ + "filesystem-configurations — s3FilesAccessPoint", + [ + "--name", + "my_agent", + ...template, + "--filesystem-configurations", + '[{"s3FilesAccessPoint":{"accessPointArn":"arn:aws:s3files:us-east-1:123456789012:file-system/fs-abc/access-point/fsap-def","mountPath":"/mnt/s3"}}]', + ], + ], + ["tags", ["--name", "my_agent", ...template, "--tags", '{"team":"ml","env":"prod"}']], + [ + "dockerfile + build-context-path", + [ + "--name", + "my_agent", + ...byo, + "--build", + "Container", + "--dockerfile", + "docker/Dockerfile.gpu", + "--build-context-path", + ".", + ], + ], + [ + "custom-docker-build-args with dockerfile", + [ + "--name", + "my_agent", + ...byo, + "--build", + "Container", + "--dockerfile", + "Dockerfile", + "--custom-docker-build-args", + '{"AGENT_NAME":"my_agent","VERSION":"1.0"}', + ], + ], + [ + "custom-docker-build-args with build-context-path", + [ + "--name", + "my_agent", + ...byo, + "--build", + "Container", + "--build-context-path", + ".", + "--custom-docker-build-args", + '{"AGENT_NAME":"my_agent"}', + ], + ], + [ + "additional-policies", + [ + "--name", + "my_agent", + ...template, + "--additional-policies", + "arn:aws:iam::123456789012:policy/MyPolicy", + ], + ], + ["protocol shortcut", ["--name", "my_agent", ...template, "--protocol", "MCP"]], + [ + "memory — create with strategies", + [ + "--name", + "my_agent", + ...template, + "--memory", + '{"mode":"create","strategies":["SEMANTIC","EPISODIC"]}', + ], + ], + [ + "memory — existing by ARN", + [ + "--name", + "my_agent", + ...template, + "--memory", + '{"mode":"existing","arn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/MyMem"}', + ], + ], + ["memory — disabled", ["--name", "my_agent", ...template, "--memory", '{"mode":"disabled"}']], + ["model-provider — openai", ["--name", "my_agent", ...template, "--model-provider", "openai"]], + [ + "build on template path (overlay)", + ["--name", "my_agent", ...template, "--build", "Container"], + ], + [ + "vpc-id with VPC network configuration", + [ + "--name", + "my_agent", + ...byo, + "--build", + "Container", + "--dockerfile", + "Dockerfile", + "--network-configuration", + '{"networkMode":"VPC","networkModeConfig":{"subnets":["subnet-abc"],"securityGroups":["sg-123"]}}', + "--vpc-id", + "vpc-0123456789abcdef0", + ], + ], + ])("%s — accepts flags", async (_label, flags) => { + await inProject(); + await expect(run(["add", "runtime", ...flags])).rejects.toBeInstanceOf(NotImplementedError); + }); + + // Rejects invalid flag combinations with InputValidationError. + test.each<[string, string[]]>([ + ["missing --name", ["--template", "hello-world-python"]], + [ + "--template and --code-location are mutually exclusive", + ["--name", "my_agent", "--template", "hello-world-python", "--code-location", "app/agent"], + ], + [ + "--custom-docker-build-args requires --dockerfile or --build-context-path", + [ + "--name", + "my_agent", + ...byo, + "--build", + "Container", + "--custom-docker-build-args", + '{"KEY":"value"}', + ], + ], + [ + "--vpc-id requires --network-configuration with VPC network configuration", + [ + "--name", + "my_agent", + ...byo, + "--build", + "Container", + "--dockerfile", + "Dockerfile", + "--vpc-id", + "vpc-0123456789abcdef0", + ], + ], + [ + "unrecognized authorizer configuration variant", + ["--name", "my_agent", ...template, "--authorizer-configuration", '{"unknownAuth":{}}'], + ], + [ + "missing discoveryUrl in authorizer", + [ + "--name", + "my_agent", + ...template, + "--authorizer-configuration", + '{"customJWTAuthorizer":{"allowedAudience":["a"]}}', + ], + ], + [ + "unrecognized filesystem configuration variant", + ["--name", "my_agent", ...template, "--filesystem-configurations", '[{"unknownFs":{}}]'], + ], + [ + "invalid JSON in --network-configuration", + ["--name", "my_agent", ...template, "--network-configuration", "{bad}"], + ], + [ + "unrecognized request header configuration variant", + [ + "--name", + "my_agent", + ...template, + "--request-header-configuration", + '{"unknownVariant":["X-Foo"]}', + ], + ], + [ + "--protocol and --protocol-configuration are mutually exclusive", + [ + "--name", + "my_agent", + ...template, + "--protocol", + "MCP", + "--protocol-configuration", + '{"serverProtocol":"MCP"}', + ], + ], + [ + "--entrypoint is only available on BYO path", + ["--name", "my_agent", ...template, "--entrypoint", "main.py"], + ], + [ + "--runtime-version is only available on BYO path", + ["--name", "my_agent", ...template, "--runtime-version", "PYTHON_3_13"], + ], + [ + "--dockerfile is only available on BYO path", + ["--name", "my_agent", ...template, "--dockerfile", "Dockerfile"], + ], + [ + "--build-context-path is only available on BYO path", + ["--name", "my_agent", ...template, "--build-context-path", "."], + ], + [ + "--custom-docker-build-args is only available on BYO path", + ["--name", "my_agent", ...template, "--custom-docker-build-args", '{"KEY":"val"}'], + ], + [ + "--memory is only available on template path", + ["--name", "my_agent", ...byo, "--memory", '{"mode":"disabled"}'], + ], + [ + "--model-provider is only available on template path", + ["--name", "my_agent", ...byo, "--model-provider", "openai"], + ], + [ + "--api-key is only available on template path", + ["--name", "my_agent", ...byo, "--api-key", "-"], + ], + [ + "invalid memory JSON schema", + ["--name", "my_agent", ...template, "--memory", '{"mode":"invalid"}'], + ], + ])("%s", async (_label, flags) => { + await inProject(); + await expect(run(["add", "runtime", ...flags])).rejects.toBeInstanceOf(InputValidationError); + }); +}); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index c187abb96..79a43c7ff 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -1,14 +1,30 @@ import { HarnessSpecSchema } from "../../projectSchemas/harness"; import type { ProjectSpecSchema } from "../../projectSchemas/project"; -import type z from "zod"; -import type { ProjectRuntimeSchema } from "../../projectSchemas/runtime"; +import z from "zod"; +import type { + BuildType, + EnvVar, + FilesystemConfiguration, + LifecycleConfiguration, + NetworkConfig, +} from "../../projectSchemas/runtime"; +import type { AuthorizerConfig, RuntimeAuthorizerType } from "../../projectSchemas/auth"; +import type { NetworkMode, ProtocolMode, RuntimeVersion } from "../../projectSchemas/constants"; +import { MemoryStrategyType } from "@aws-sdk/client-bedrock-agentcore-control"; -/** Available project templates for scaffolding new AgentCore projects. */ -export const PROJECT_TEMPLATES = { +/** Available runtime templates for scaffolding agent code. A subset of {@link PROJECT_TEMPLATES} describing runtimes only */ +export const RUNTIME_TEMPLATES = { HELLO_WORLD_PYTHON: "hello-world-python", HELLO_WORLD_PYTHON_CONTAINER: "hello-world-python-container", } as const; +export type RuntimeTemplate = (typeof RUNTIME_TEMPLATES)[keyof typeof RUNTIME_TEMPLATES]; + +/** Available project templates for scaffolding new AgentCore projects. */ +export const PROJECT_TEMPLATES = { + ...RUNTIME_TEMPLATES, +} as const; + export type ProjectTemplate = (typeof PROJECT_TEMPLATES)[keyof typeof PROJECT_TEMPLATES]; export type CreateProjectInput = { @@ -40,6 +56,46 @@ export type Project = { spec: z.infer; }; +/** Shared infrastructure config fields for a runtime (independent of source type). */ +type RuntimeInfraConfig = { + name: string; + description?: string; + executionRoleArn?: string; + additionalPolicies?: string[]; + envVars?: EnvVar[]; + networkMode?: NetworkMode; + networkConfig?: NetworkConfig; + authorizerType?: RuntimeAuthorizerType; + authorizerConfiguration?: AuthorizerConfig; + protocol?: ProtocolMode; + requestHeaderAllowlist?: string[]; + lifecycleConfiguration?: LifecycleConfiguration; + filesystemConfigurations?: FilesystemConfiguration[]; + tags?: Record; +}; + +/** BYO path: user provides existing code location and build config. */ +type RuntimeByoConfig = RuntimeInfraConfig & { + source: "byo"; + codeLocation: string; + build?: BuildType; + entrypoint?: string; + runtimeVersion?: RuntimeVersion; + dockerfile?: string; + buildContextPath?: string; + customDockerBuildArgs?: Record; +}; + +/** Template path: CLI scaffolds agent code from a template. */ +type RuntimeTemplateConfig = RuntimeInfraConfig & { + source: "template"; + template: RuntimeTemplate; + memory?: RuntimeMemoryConfig; + modelProvider?: RuntimeModelProviderConfig; +}; + +export type RuntimeResourceConfig = RuntimeByoConfig | RuntimeTemplateConfig; + /** Discriminated union input for {@link ProjectManager.addResource}. */ export type AddResourceInput = | { @@ -48,7 +104,7 @@ export type AddResourceInput = } | { resourceType: "runtime"; - resourceConfig: z.input; + resourceConfig: RuntimeResourceConfig; }; export type ProjectResource = AddResourceInput["resourceType"]; @@ -69,3 +125,25 @@ export interface ProjectManager { /** Add a resource to an existing AgentCore project. */ addResource(project: Project, input: AddResourceInput): AsyncGenerator; } + +export const runtimeModelProviderSchema = z.enum(["bedrock", "anthropic", "openai", "gemini"]); +export type RuntimeModelProvider = z.infer; + +export type RuntimeModelProviderConfig = { + provider?: RuntimeModelProvider; + apiKey?: string; +}; + +export const runtimeMemoryConfigSchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("disabled") }), + z.object({ + mode: z.literal("create"), + strategies: z.array(z.enum(Object.values(MemoryStrategyType))), + }), + z.object({ + mode: z.literal("existing"), + arn: z.string().min(1), + }), +]); + +export type RuntimeMemoryConfig = z.input; diff --git a/src/projectSchemas/runtime.ts b/src/projectSchemas/runtime.ts index 61dc95886..aba8d291b 100644 --- a/src/projectSchemas/runtime.ts +++ b/src/projectSchemas/runtime.ts @@ -8,7 +8,7 @@ import { VPC_ID_PATTERN, isContainerBuild, } from "./constants"; -import type { DirectoryPath, FilePath } from "./types"; +import type { DirectoryPath } from "./types"; import { AuthorizerConfigSchema, RuntimeAuthorizerTypeSchema } from "./auth"; import { ConnectionSchema } from "./connections"; import { TagsSchema } from "./tags"; @@ -49,7 +49,7 @@ export const EntrypointSchema = z .regex( /^[a-zA-Z0-9_][a-zA-Z0-9_/.-]*\.(py|ts|js)(:[a-zA-Z_][a-zA-Z0-9_]*)?$/, 'Must be a Python (.py) or TypeScript (.ts/.js) file path with optional handler (e.g., "main.py:handler" or "index.ts")', - ) as unknown as z.ZodType; + ); const DirectoryPathSchema = z.string().min(1) as unknown as z.ZodType; const DOCKERFILE_PATH_ALLOWED_CHARS = /^[A-Za-z0-9._/-]+$/; export function isValidDockerfilePath(p: string): boolean {