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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ up from the current directory.
Use `--mode browser` for Agent Inspector, `--skip-deploy` to reuse deployed harnesses,
and `--agent <name>` to choose an agent in mixed projects.

New payment credentials receive provider names that payment connectors can reference.
An existing payment credential keeps the provider ARN recorded for its deployment
target. If that ARN is incompatible with a manual connector, deploy stops before
provisioning anything. Add a payment credential under a different name and point
the connector to it; the old provider remains until you remove its credential
from the project and deploy each affected target.

## Commands

| Command | Purpose |
Expand Down
138 changes: 133 additions & 5 deletions src/core/project/backends/cdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ const TARGET = {
} as const;
const STACK_ARN =
"arn:aws:cloudformation:us-east-1:111122223333:stack/AgentCore-example-default/abc";
const OLD_PAYMENT_PROVIDER = "example_default_wallet";
const OLD_PAYMENT_ARN =
"arn:aws:bedrock-agentcore:us-east-1:111122223333:" +
`token-vault/default/paymentcredentialprovider/${OLD_PAYMENT_PROVIDER}`;
const json = new FsReadWriteJson({ logger: createSilentLogger() });

/** A template holding only what CDK adds itself, as an empty project synthesizes. */
Expand Down Expand Up @@ -609,7 +613,12 @@ describe("CdkBackend.deploy", () => {

// The target's name scopes the provider names, so provisioning needs it.
expect(handed).toEqual([
{ credentials: subject.credentials, region: TARGET.region, targetName: TARGET.name },
{
credentials: subject.credentials,
region: TARGET.region,
targetName: TARGET.name,
recorded: {},
},
]);
// The credential step runs (and its ARNs are recorded) before synthesis, so
// the assembly is synthesized against a state file that already describes them.
Expand Down Expand Up @@ -697,6 +706,60 @@ describe("CdkBackend.deploy", () => {
expect(subject.runs).toEqual([]);
});

test("rejects an incompatible recorded payment ARN before provisioning", async () => {
const input = await project();
input.spec = ProjectSpecSchema.parse({
name: "example",
version: 2,
credentials: [
{ name: "wallet", authorizerType: "PaymentCredentialProvider", provider: "CoinbaseCDP" },
],
payments: [
{
name: "MyPayments",
connectors: [{ name: "Coinbase", provider: "CoinbaseCDP", credentialName: "wallet" }],
},
],
});
const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH);
await mkdir(dirname(statePath), { recursive: true });
await writeFile(
statePath,
JSON.stringify({
targets: {
default: {
resources: {
credentials: {
wallet: {
authorizerType: "PaymentCredentialProvider",
credentialProviderArn: OLD_PAYMENT_ARN,
},
},
},
},
},
}),
);
let provisioned = false;
const provisionCredentials: CredentialProvisioner = async function* () {
provisioned = true;
yield { type: "step", message: "preparing payment credential" };
return {};
};
const subject = harness({ provisionCredentials });

await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toThrow(
/connector 'Coinbase'.*credential 'wallet'.*target 'default'.*different name/s,
);
expect(provisioned).toBe(false);
expect(subject.commands).toEqual([]);
expect(subject.runs).toEqual([]);
expect(
JSON.parse(await Bun.file(statePath).text()).targets.default.resources.credentials.wallet
.credentialProviderArn,
).toBe(OLD_PAYMENT_ARN);
});

test("will not remove a stack the user did not ask to remove", async () => {
const input = await project();
await writeAssembly(input, [TARGET.name], { resources: {} });
Expand Down Expand Up @@ -846,7 +909,7 @@ describe("CdkBackend.deploy", () => {
const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH);
await mkdir(dirname(statePath), { recursive: true });
const recordedWallet = {
credentialProviderArn: "arn:payment:wallet",
credentialProviderArn: OLD_PAYMENT_ARN,
authorizerType: "PaymentCredentialProvider" as const,
};
await writeFile(
Expand Down Expand Up @@ -877,7 +940,66 @@ describe("CdkBackend.deploy", () => {
credentials: subject.credentials,
region: TARGET.region,
targetName: TARGET.name,
providers: [{ name: "wallet", authorizerType: "PaymentCredentialProvider" }],
providers: [
{
name: "wallet",
authorizerType: "PaymentCredentialProvider",
physicalName: OLD_PAYMENT_PROVIDER,
},
],
},
]);
});

test("teardown removes a declared payment credential by its recorded physical name", async () => {
const input = await project();
input.spec = ProjectSpecSchema.parse({
name: "example",
version: 2,
credentials: [
{ name: "wallet", authorizerType: "PaymentCredentialProvider", provider: "CoinbaseCDP" },
],
});
await writeAssembly(input, [TARGET.name], { resources: METADATA_ONLY });
const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH);
await mkdir(dirname(statePath), { recursive: true });
const recordedWallet = {
credentialProviderArn: OLD_PAYMENT_ARN,
authorizerType: "PaymentCredentialProvider" as const,
};
await writeFile(
statePath,
JSON.stringify({
targets: {
default: {
stackArn: "arn:stack:default",
resources: { credentials: { wallet: recordedWallet } },
},
},
}),
);

const handed: CredentialRemovalInput[] = [];
const subject = harness({
provisionCredentials: async function* () {
yield { type: "step", message: "reusing existing payment credential" };
return { wallet: recordedWallet };
},
removeCredentials: async function* (_project, removalInput) {
handed.push(removalInput);
yield { type: "step", message: "removing existing payment credential" };
},
});

await collectDeploy(
subject.backend.deploy(input, deployInput({ confirmTeardown: async () => true })),
);

expect(handed[0]?.providers).toEqual([
{
name: "wallet",
authorizerType: "PaymentCredentialProvider",
physicalName: OLD_PAYMENT_PROVIDER,
},
]);
});
Expand Down Expand Up @@ -905,7 +1027,7 @@ describe("CdkBackend.deploy", () => {
authorizerType: "ApiKeyCredentialProvider",
},
wallet: {
credentialProviderArn: "arn:payment:wallet",
credentialProviderArn: OLD_PAYMENT_ARN,
authorizerType: "PaymentCredentialProvider",
},
},
Expand Down Expand Up @@ -933,7 +1055,13 @@ describe("CdkBackend.deploy", () => {
credentials: subject.credentials,
region: TARGET.region,
targetName: TARGET.name,
providers: [{ name: "wallet", authorizerType: "PaymentCredentialProvider" }],
providers: [
{
name: "wallet",
authorizerType: "PaymentCredentialProvider",
physicalName: OLD_PAYMENT_PROVIDER,
},
],
},
]);
// After the stack update, since a resource in the stack may have used the
Expand Down
16 changes: 14 additions & 2 deletions src/core/project/backends/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,16 @@ import type {
import { createCloudFormationClient } from "../../factories";
import type { AwsCredentials, CreateCloudFormationClient } from "../../types";
import {
assertPaymentConnectorCredentials,
createCredentialProvisioner,
createCredentialRemover,
orphanedCredentials,
providerRefFromRecorded,
type CredentialProviderCalls,
type CredentialProviderRef,
type CredentialProvisioner,
type CredentialRemover,
type DeployedCredentials,
} from "./cdk/credentials";
import {
countDeployableResources,
Expand Down Expand Up @@ -273,6 +276,7 @@ export class CdkBackend implements ProjectBackend {
const recorded =
(await readDeployedState(this.json, project.rootPath)).targets[target.name]?.resources
?.credentials ?? {};
assertPaymentConnectorCredentials(project, target.name, recorded);
const orphaned = orphanedCredentials(recorded, project.spec.credentials);

// Credential providers aren't stack resources; the synthesized app reads their
Expand All @@ -281,6 +285,7 @@ export class CdkBackend implements ProjectBackend {
credentials,
region: target.region,
targetName: target.name,
recorded,
});
// Recorded every deploy (even when empty) so dropping the last credential
// from the spec clears the stale entry instead of leaving it advertised.
Expand All @@ -297,7 +302,7 @@ export class CdkBackend implements ProjectBackend {
// with nothing in it as an instruction to delete the stack, and reports that
// as an ordinary successful deploy.
if ((await countDeployableResources(this.json, assemblyDirectory, artifact)) === 0) {
return yield* this.teardown({ project, artifact, input, options, orphaned });
return yield* this.teardown({ project, artifact, input, options, orphaned, recorded });
}

// Only after a real deploy is confirmed: a teardown (empty assembly) returns
Expand Down Expand Up @@ -431,13 +436,15 @@ export class CdkBackend implements ProjectBackend {
input,
options,
orphaned,
recorded,
}: {
project: Project;
artifact: StackArtifact;
input: DeployBackendInput;
options: CdkRunOptions;
/** Providers recorded for this target before the deploy that the spec no longer declares. */
orphaned: CredentialProviderRef[];
recorded: DeployedCredentials;
}): AsyncGenerator<ProjectEvent, DeployResult> {
const { target } = input;
if (!(await this.describeStack(target.region, options.credentials, artifact.stackName))) {
Expand Down Expand Up @@ -472,7 +479,12 @@ export class CdkBackend implements ProjectBackend {
credentials: options.credentials,
region: target.region,
targetName: target.name,
providers: [...orphaned, ...project.spec.credentials],
providers: [
...orphaned,
...project.spec.credentials.map((credential) =>
providerRefFromRecorded(credential, recorded),
),
],
});
await removeTargetState(this.json, project.rootPath, target.name);
return { outputs: {}, tornDown: true };
Expand Down
Loading
Loading