Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -284,15 +284,14 @@ describe("project add config-bundle wizard", () => {
screen.unmount();
});

test("validates the deployed name against the longest project target", async () => {
test("validates the concatenated project and bundle name", async () => {
const projectRoot = await inProject();
await writeFile(
join(projectRoot, "agentcore", "aws-targets.json"),
JSON.stringify([{ name: "production", account: "111122223333", region: "us-east-1" }]),
);
// 78 characters: legal on its own, one over once the project and the target
// are prefixed.
const name = `c${"x".repeat(77)}`;

const name = `c${"x".repeat(89)}`;
const screen = renderScreen("/agentcore/add/config-bundle");

await waitForText(screen.lastFrame, "what should this configuration bundle be called?");
Expand All @@ -301,11 +300,23 @@ describe("project add config-bundle wizard", () => {

await waitForFlatText(screen.lastFrame, "is 101 characters. The maximum is 100.");
const frame = flatFrame(screen.lastFrame);
expect(frame).toContain("TestProject_production_");
expect(frame).toContain("Configuration bundle deployed name 'TestProjectc");
expect(frame).toContain("what should this configuration bundle be called?");
screen.unmount();
});

test("allows a bundle name that fits without the target prefix", async () => {
const projectRoot = await inProject();
await writeFile(
join(projectRoot, "agentcore", "aws-targets.json"),
JSON.stringify([{ name: "production", account: "111122223333", region: "us-east-1" }]),
);
const screen = renderScreen("/agentcore/add/config-bundle");

await reachComponentsStep(screen, `c${"x".repeat(88)}`);
screen.unmount();
});

test("a rejected add reports itself and hands the form back", async () => {
const projectRoot = await inProject();
// The name is taken, so addResource refuses it — the realistic failure, and
Expand Down
35 changes: 19 additions & 16 deletions src/handlers/project/add/config-bundle/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import z from "zod";
import { InputValidationError } from "../../../../errors";
import { SourceResolver } from "../../../../io";
import type { AwsDeploymentTarget } from "../../../../projectSchemas/aws-targets";
import {
ComponentConfigurationSchema,
ConfigBundleBranchNameSchema,
Expand All @@ -15,7 +14,7 @@ import { formatZodError } from "../../../../router/schema";
import { parseJsonFlag } from "../../../utils";
import type { AddResourceInput, Project } from "../../types";
import type { AddProjectResourceConfig } from "../types";
import { addProjectResource, requireDeployedNameFits, addDescription } from "../shared";
import { addProjectResource, addDescription } from "../shared";

// ComponentsSchema is the shape --components accepts. The wizard's components
// step validates against it too, rather than a copy of it.
Expand All @@ -40,15 +39,23 @@ export type ConfigBundleInput = {
kmsKeyArn?: string;
};

/** Config bundles keep the v0 physical name without a separator or target. */
export function requireConfigBundleNameFits(projectName: string, name: string): void {
const deployed = `${projectName}${name}`;
if (deployed.length <= 100) return;
throw new InputValidationError(
`Configuration bundle deployed name '${deployed}' is ${deployed.length} characters. The maximum is 100. The deployed name is <project><name>. Shorten the configuration bundle name.`,
);
}

// toAddConfigBundleInput is the one place a configuration bundle is built from
// user input, so the flags and the wizard cannot disagree about what a bundle
// is, what it defaults to, or how a deployed name is bounded.
export function toAddConfigBundleInput(
project: Project,
targets: readonly AwsDeploymentTarget[],
input: ConfigBundleInput,
): AddResourceInput {
requireDeployedNameFits("Configuration bundle", project.name, input.name, "_", 100, targets);
requireConfigBundleNameFits(project.name, input.name);
const components = ComponentsSchema.safeParse(input.components);
if (!components.success) {
throw new InputValidationError(
Expand Down Expand Up @@ -112,18 +119,14 @@ export const createAddConfigBundleHandler = (config: AddProjectResourceConfig) =
"components",
await source.resolveText("components", flags.components),
);
const input = toAddConfigBundleInput(
project,
await config.projectManager.listTargets(project),
{
name: flags.name,
components,
description: flags.description,
branchName: flags["branch-name"],
commitMessage: flags["commit-message"],
kmsKeyArn: flags["kms-key-arn"],
},
);
const input = toAddConfigBundleInput(project, {
name: flags.name,
components,
description: flags.description,
branchName: flags["branch-name"],
commitMessage: flags["commit-message"],
kmsKeyArn: flags["kms-key-arn"],
});

await addProjectResource(
ctx,
Expand Down
49 changes: 8 additions & 41 deletions src/handlers/project/add/config-bundle/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
TextField,
Wizard,
} from "../../../../components/wizard";
import type { AwsDeploymentTarget } from "../../../../projectSchemas/aws-targets";
import {
ConfigBundleBranchNameSchema,
ConfigBundleCommitMessageSchema,
Expand All @@ -18,11 +17,11 @@ import {
import { ProjectKey } from "../../../../router";
import type { ScreenProps } from "../../../types";
import type { Project } from "../../types";
import { LoadingFrame, ProjectGate, projectQueryKey, useProjectTargets } from "../../ProjectGate";
import { requireDeployedNameFits } from "../shared";
import { ProjectGate, projectQueryKey } from "../../ProjectGate";
import {
ComponentsSchema,
DEFAULT_BRANCH_NAME,
requireConfigBundleNameFits,
toAddConfigBundleInput,
type ConfigBundleInput,
} from "./index";
Expand Down Expand Up @@ -97,49 +96,18 @@ export function AddConfigBundleScreen({ ctx, core }: ScreenProps) {
onBack={() => navigate(ADD_MENU)}
>
{(project) => (
<AddConfigBundleLoader project={project} core={core} region={ctx.value(RegionKey)} />
<AddConfigBundleWizard project={project} core={core} region={ctx.value(RegionKey)} />
)}
</ProjectGate>
);
}

function AddConfigBundleLoader({
project,
core,
region,
}: {
project: Project;
core: ScreenProps["core"];
region: string | undefined;
}) {
const navigate = useNavigate();
const targets = useProjectTargets(core, project);

if (targets.data !== undefined) {
return (
<AddConfigBundleWizard project={project} targets={targets.data} core={core} region={region} />
);
}

return (
<LoadingFrame
breadcrumb={BREADCRUMB}
description={DESCRIPTION}
query={targets}
loadingLabel="loading deployment targets…"
onBack={() => navigate(ADD_MENU)}
/>
);
}

function AddConfigBundleWizard({
project,
targets,
core,
region,
}: {
project: Project;
targets: readonly AwsDeploymentTarget[];
core: ScreenProps["core"];
/** The command\'s resolved region, for the China gate of a project without targets. */
region: string | undefined;
Expand All @@ -155,21 +123,20 @@ function AddConfigBundleWizard({
const set = (update: Partial<ConfigBundleFormValues>) =>
setValues((current) => ({ ...current, ...update }));

// The deployed name is <project>_<target>_<name> and must fit the service
// cap, so the live check reports the real budget rather than the schema's.
// The deployed name is <project><name> and must fit the service cap.
const nameSchema = useMemo(
() =>
ConfigBundleNameSchema.superRefine((name, ctx) => {
try {
requireDeployedNameFits("Configuration bundle", project.name, name, "_", 100, targets);
requireConfigBundleNameFits(project.name, name);
} catch (error) {
ctx.addIssue({
code: "custom",
message: error instanceof Error ? error.message : String(error),
});
}
}),
[project.name, targets],
[project.name],
);

return (
Expand All @@ -180,7 +147,7 @@ function AddConfigBundleWizard({
onSubmit={async function* () {
const updated = yield* core.projectManager.addResource(
project,
toAddConfigBundleInput(project, targets, toConfigBundleInput(values)),
toAddConfigBundleInput(project, toConfigBundleInput(values)),
{ region },
);
queryClient.setQueryData(projectQueryKey(), updated);
Expand All @@ -195,7 +162,7 @@ function AddConfigBundleWizard({
<Step stepKey="name" prompt="what should this configuration bundle be called?">
<TextField
label="Bundle name"
help="letters, digits and underscores, starting with a letter; the deployed <project>_<target>_<name> must fit 100 characters"
help="letters, digits and underscores, starting with a letter; the deployed <project><name> must fit 100 characters"
placeholder="runtime_config"
value={values.name}
onChange={(name) => set({ name })}
Expand Down
15 changes: 13 additions & 2 deletions src/handlers/project/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1257,6 +1257,17 @@ describe("project add config-bundle", () => {
await expect(run(args)).rejects.toBeInstanceOf(InputValidationError);
});

test("accepts a configuration bundle name at the 100-character deployed limit", async () => {
const { projectRoot, cleanup } = await initProject();
cleanups.push(cleanup);
const name = `c${"x".repeat(88)}`;

await run(["add", "config-bundle", "--name", name, "--components", JSON.stringify(components)]);

const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json();
expect(spec.configBundles[0].name).toBe(name);
});

test.each<[string, string[], string?]>([
[
"missing name",
Expand All @@ -1271,8 +1282,8 @@ describe("project add config-bundle", () => {
["invalid name", ["--name", "orders-config", "--components", JSON.stringify(components)]],
[
"a deployed name over the 100-character service limit",
["--name", `c${"x".repeat(80)}`, "--components", JSON.stringify(components)],
`Configuration bundle deployed name 'TestProject_default_c${"x".repeat(80)}' is 101 characters. The maximum is 100.`,
["--name", `c${"x".repeat(89)}`, "--components", JSON.stringify(components)],
`Configuration bundle deployed name 'TestProjectc${"x".repeat(89)}' is 101 characters. The maximum is 100.`,
],
["empty components", ["--name", "OrdersConfig", "--components", "{}"]],
[
Expand Down
Loading