Skip to content
Merged
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
13 changes: 11 additions & 2 deletions src/core/project/manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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}'` };
Expand Down Expand Up @@ -280,5 +287,7 @@ function toProjectSpecKey(resourceType: ProjectResource) {
return "harnesses";
case "runtime":
return "runtimes";
case "config-bundle":
return "configBundles";
}
}
87 changes: 87 additions & 0 deletions src/handlers/project/add/config-bundle/index.ts
Original file line number Diff line number Diff line change
@@ -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://<path>, 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 <name>' not specified");
}
if (!flags.components) {
throw new InputValidationError("required option '--components <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 <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`);
},
});
2 changes: 2 additions & 0 deletions src/handlers/project/add/index.ts
Original file line number Diff line number Diff line change
@@ -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;
}
187 changes: 187 additions & 0 deletions src/handlers/project/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const projectRoot = await inProject("MyAgent");
Expand Down
5 changes: 5 additions & 0 deletions src/handlers/project/types.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -49,6 +50,10 @@ export type AddResourceInput =
| {
resourceType: "runtime";
resourceConfig: z.input<typeof ProjectRuntimeSchema>;
}
| {
resourceType: "config-bundle";
resourceConfig: z.input<typeof ConfigBundleSchema>;
};

export type ProjectResource = AddResourceInput["resourceType"];
Expand Down
6 changes: 4 additions & 2 deletions src/projectSchemas/config-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ export const ComponentConfigurationSchema = z.object({
export type ComponentConfiguration = z.infer<typeof ComponentConfigurationSchema>;
export const ComponentConfigurationMapSchema = z.record(z.string(), ComponentConfigurationSchema);
export type ComponentConfigurationMap = z.infer<typeof ComponentConfigurationMapSchema>;
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<typeof ConfigBundleSchema>;
Loading