diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 4180cb03c..9ec02dd04 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -139,7 +139,7 @@ export class FsProjectManager implements ProjectManager { `a ${resourceType} with name '${resourceConfig.name}' already exists`, ); - const newResources = [...existingResources]; + const newResources: unknown[] = [...existingResources]; const scaffoldedPaths: string[] = []; switch (resourceType) { @@ -160,7 +160,14 @@ export class FsProjectManager implements ProjectManager { "runtime case not yet implemented in FsProjectManager.addResource", ); } - // TODO: add limited special casing for runtime and default for other resources that proxy directly to spec changes. + case "config-bundle": + newResources.push(resourceConfig); + break; + + default: { + const unhandled: never = input; + throw new NotImplementedError(`unsupported project resource: ${String(unhandled)}`); + } } yield { message: `Updating project spec file at '${agentCoreSpecPath}'` }; @@ -280,5 +287,7 @@ function toProjectSpecKey(resourceType: ProjectResource) { return "harnesses"; case "runtime": return "runtimes"; + case "config-bundle": + return "configBundles"; } } diff --git a/src/handlers/project/add/config-bundle/index.ts b/src/handlers/project/add/config-bundle/index.ts new file mode 100644 index 000000000..988d12c92 --- /dev/null +++ b/src/handlers/project/add/config-bundle/index.ts @@ -0,0 +1,87 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { SourceResolver } from "../../../../io"; +import { + ComponentConfigurationSchema, + ConfigBundleBranchNameSchema, + ConfigBundleCommitMessageSchema, + ConfigBundleDescriptionSchema, + ConfigBundleNameSchema, +} from "../../../../projectSchemas/config-bundle"; +import { KmsKeyArnSchema } from "../../../../projectSchemas/evaluator"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import { parseJsonFlagWithSchema } from "../../../utils"; +import type { AddProjectResourceConfig } from "../types"; + +const ComponentsSchema = z + .record(z.string().min(1), ComponentConfigurationSchema.strict()) + .refine((components) => Object.keys(components).length > 0, { + message: "must contain at least one component", + }); + +export const createAddConfigBundleHandler = (config: AddProjectResourceConfig) => + createHandler({ + name: "config-bundle", + description: "adds a configuration bundle to the current project", + flags: [ + flag("name", "the name of the configuration bundle", ConfigBundleNameSchema.optional()), + flag( + "description", + "a description of the configuration bundle", + ConfigBundleDescriptionSchema, + ), + flag( + "components", + "component configuration map (JSON inline, file://, or - for stdin)", + z.string().optional(), + { sensitive: true }, + ), + flag( + "branch-name", + "branch name for the initial configuration", + ConfigBundleBranchNameSchema.default("mainline"), + ), + flag( + "commit-message", + "message describing the initial configuration", + ConfigBundleCommitMessageSchema.optional(), + ), + flag( + "kms-key-arn", + "customer managed KMS key ARN for component configurations", + KmsKeyArnSchema.optional(), + ), + ], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + if (!flags.components) { + throw new InputValidationError("required option '--components ' not specified"); + } + + const source = new SourceResolver({ stdin: config.io.stdin }); + const componentsText = await source.resolveText("components", flags.components); + const components = parseJsonFlagWithSchema("components", componentsText, ComponentsSchema); + if (components === undefined) { + throw new InputValidationError("required option '--components ' not specified"); + } + + const project = ctx.require(ProjectKey); + for await (const event of config.projectManager.addResource(project, { + resourceType: "config-bundle", + resourceConfig: { + name: flags.name, + description: flags.description, + components, + branchName: flags["branch-name"], + commitMessage: flags["commit-message"], + kmsKeyArn: flags["kms-key-arn"], + }, + })) { + config.io.stderr.write(`${event.message}\n`); + } + + config.io.stderr.write(`added configuration bundle '${flags.name}' to '${project.name}'\n`); + }, + }); diff --git a/src/handlers/project/add/index.ts b/src/handlers/project/add/index.ts index 8545ccba3..859bc4cd7 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 { createAddConfigBundleHandler } from "./config-bundle"; import { createAddHarnessHandler } from "./harness"; 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(createAddConfigBundleHandler(config)); projectAdd.handler(createAddHarnessHandler(config)); return projectAdd; } diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 8a15404c3..1eadaa7b3 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -689,6 +689,193 @@ describe("project add harness", () => { }); }); +describe("project add config-bundle", () => { + const components = { + "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/orders-agent": { + configuration: { + systemPrompt: "Help customers with their orders.", + temperature: 0.2, + }, + }, + }; + + test("adds a configuration bundle to agentcore.json", async () => { + const projectRoot = await inProject(); + const { io } = await run([ + "add", + "config-bundle", + "--name", + "OrdersConfig", + "--components", + JSON.stringify(components), + ]); + + const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(spec.configBundles).toEqual([ + { + name: "OrdersConfig", + type: "ConfigurationBundle", + components, + branchName: "mainline", + }, + ]); + expect(io.stderr()).toContain("added configuration bundle 'OrdersConfig' to 'TestProject'"); + }); + + test("stores optional configuration bundle fields", async () => { + const projectRoot = await inProject(); + const kmsKeyArn = "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"; + + await run([ + "add", + "config-bundle", + "--name", + "OrdersConfig", + "--description", + "Configuration for the order support runtime", + "--components", + JSON.stringify(components), + "--branch-name", + "production", + "--commit-message", + "Add the initial order support configuration", + "--kms-key-arn", + kmsKeyArn, + ]); + + const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(spec.configBundles[0]).toEqual({ + name: "OrdersConfig", + type: "ConfigurationBundle", + description: "Configuration for the order support runtime", + components, + branchName: "production", + commitMessage: "Add the initial order support configuration", + kmsKeyArn, + }); + }); + + test("reads components from a file", async () => { + const projectRoot = await inProject(); + const componentsPath = join(projectRoot, "components.json"); + await Bun.write(componentsPath, JSON.stringify(components)); + + await run([ + "add", + "config-bundle", + "--name", + "OrdersConfig", + "--components", + `file://${componentsPath}`, + ]); + + const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(spec.configBundles[0].components).toEqual(components); + }); + + test("adds no files under app", async () => { + const projectRoot = await inProject(); + + await run([ + "add", + "config-bundle", + "--name", + "OrdersConfig", + "--components", + JSON.stringify(components), + ]); + + expect(existsSync(join(projectRoot, "app", "OrdersConfig"))).toBe(false); + }); + + test("rejects a duplicate configuration bundle name", async () => { + await inProject(); + const args = [ + "add", + "config-bundle", + "--name", + "OrdersConfig", + "--components", + JSON.stringify(components), + ]; + + await run(args); + await expect(run(args)).rejects.toBeInstanceOf(InputValidationError); + }); + + test.each([ + ["missing name", ["--components", JSON.stringify(components)]], + ["missing components", ["--name", "OrdersConfig"]], + ["invalid name", ["--name", "orders-config", "--components", JSON.stringify(components)]], + ["empty components", ["--name", "OrdersConfig", "--components", "{}"]], + [ + "component without configuration", + ["--name", "OrdersConfig", "--components", '{"arn:component":{}}'], + ], + [ + "non-object component configuration", + [ + "--name", + "OrdersConfig", + "--components", + '{"arn:component":{"configuration":"not-an-object"}}', + ], + ], + [ + "unexpected component field", + [ + "--name", + "OrdersConfig", + "--components", + '{"arn:component":{"configuration":{},"unexpected":true}}', + ], + ], + ["malformed components", ["--name", "OrdersConfig", "--components", "{not-json"]], + [ + "empty description", + ["--name", "OrdersConfig", "--description", "", "--components", JSON.stringify(components)], + ], + [ + "branch name above maximum length", + [ + "--name", + "OrdersConfig", + "--components", + JSON.stringify(components), + "--branch-name", + "b".repeat(129), + ], + ], + [ + "commit message above maximum length", + [ + "--name", + "OrdersConfig", + "--components", + JSON.stringify(components), + "--commit-message", + "m".repeat(501), + ], + ], + [ + "invalid KMS key ARN", + [ + "--name", + "OrdersConfig", + "--components", + JSON.stringify(components), + "--kms-key-arn", + "not-an-arn", + ], + ], + ])("rejects %s", async (_label, flags) => { + await inProject(); + await expect(run(["add", "config-bundle", ...flags])).rejects.toBeInstanceOf( + InputValidationError, + ); + }); +}); + describe("project build", () => { async function inBuildableProject(): Promise { const projectRoot = await inProject("MyAgent"); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index c187abb96..24da19e94 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -1,4 +1,5 @@ import { HarnessSpecSchema } from "../../projectSchemas/harness"; +import type { ConfigBundleSchema } from "../../projectSchemas/config-bundle"; import type { ProjectSpecSchema } from "../../projectSchemas/project"; import type z from "zod"; import type { ProjectRuntimeSchema } from "../../projectSchemas/runtime"; @@ -49,6 +50,10 @@ export type AddResourceInput = | { resourceType: "runtime"; resourceConfig: z.input; + } + | { + resourceType: "config-bundle"; + resourceConfig: z.input; }; export type ProjectResource = AddResourceInput["resourceType"]; diff --git a/src/projectSchemas/config-bundle.ts b/src/projectSchemas/config-bundle.ts index 73656f8fd..74a219124 100644 --- a/src/projectSchemas/config-bundle.ts +++ b/src/projectSchemas/config-bundle.ts @@ -15,13 +15,15 @@ export const ComponentConfigurationSchema = z.object({ export type ComponentConfiguration = z.infer; export const ComponentConfigurationMapSchema = z.record(z.string(), ComponentConfigurationSchema); export type ComponentConfigurationMap = z.infer; +export const ConfigBundleBranchNameSchema = z.string().max(128); +export const ConfigBundleCommitMessageSchema = z.string().max(500); export const ConfigBundleSchema = z.object({ name: ConfigBundleNameSchema, type: z.literal("ConfigurationBundle").default("ConfigurationBundle"), description: ConfigBundleDescriptionSchema, components: ComponentConfigurationMapSchema, - branchName: z.string().max(128).optional(), - commitMessage: z.string().max(500).optional(), + branchName: ConfigBundleBranchNameSchema.optional(), + commitMessage: ConfigBundleCommitMessageSchema.optional(), kmsKeyArn: KmsKeyArnSchema.optional(), }); export type ConfigBundle = z.infer;