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
129 changes: 129 additions & 0 deletions src/core/project/backends/cdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,48 @@ describe("CdkBackend.deploy", () => {
]);
});

test("teardown leaves a payment provider recorded by another target", async () => {
const input = await project();
input.spec = ProjectSpecSchema.parse({ ...input.spec, migratedFrom: "v1" });
await writeAssembly(input, [TARGET.name], { resources: METADATA_ONLY });
const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH);
await mkdir(dirname(statePath), { recursive: true });
const wallet = {
credentialProviderArn:
"arn:aws:acps:us-east-1:111122223333:" +
"token-vault/default/paymentcredentialprovider/wallet",
authorizerType: "PaymentCredentialProvider",
};
await writeFile(
statePath,
JSON.stringify({
targets: {
default: { stackArn: "arn:stack:default", resources: { credentials: { wallet } } },
prod: { stackArn: "arn:stack:prod", resources: { credentials: { wallet } } },
},
}),
);
const handed: CredentialRemovalInput[] = [];
const subject = harness({
removeCredentials: async function* (_project, removalInput) {
handed.push(removalInput);
yield { type: "step", message: "checked credential removals" };
},
});

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

expect(handed[0]?.providers).toEqual([]);
expect(stepMessages(deployed.events)).toContain(
"Leaving credential provider 'wallet' in place: also recorded for target 'prod'",
);
const state = JSON.parse(await Bun.file(statePath).text());
expect(state.targets.default).toBeUndefined();
expect(state.targets.prod.resources.credentials.wallet).toEqual(wallet);
});

test("removes a recorded provider whose credential left the spec, after the stack update", async () => {
const input = await project();
input.spec = ProjectSpecSchema.parse({
Expand Down Expand Up @@ -1073,6 +1115,93 @@ describe("CdkBackend.deploy", () => {
expect(deployed.result).toEqual({ outputs: {} });
});

test("deploy leaves a dropped payment provider used by another target", async () => {
const input = await project();
input.spec = ProjectSpecSchema.parse({ ...input.spec, migratedFrom: "v1" });
await writeAssembly(input, [TARGET.name]);
const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH);
await mkdir(dirname(statePath), { recursive: true });
const wallet = {
credentialProviderArn:
"arn:aws:bedrock-agentcore:us-east-1:111122223333:" +
"token-vault/default/paymentcredentialprovider/wallet",
authorizerType: "PaymentCredentialProvider",
};
await writeFile(
statePath,
JSON.stringify({
targets: {
default: { resources: { credentials: { wallet } } },
prod: { resources: { credentials: { wallet } } },
},
}),
);
const handed: CredentialRemovalInput[] = [];
const subject = harness({
removeCredentials: async function* (_project, removalInput) {
handed.push(removalInput);
yield { type: "step", message: "checked credential removals" };
},
});

const deployed = await collectDeploy(subject.backend.deploy(input, deployInput()));

expect(handed[0]?.providers).toEqual([]);
expect(stepMessages(deployed.events)).toContain(
"Leaving credential provider 'wallet' in place: also recorded for target 'prod'",
);
const state = JSON.parse(await Bun.file(statePath).text());
expect(state.targets.default.resources.credentials).toEqual({});
expect(state.targets.prod.resources.credentials.wallet).toEqual(wallet);
});

test("deploy removes a provider when the other target's matching name is in another account", async () => {
const input = await project();
input.spec = ProjectSpecSchema.parse({ ...input.spec, migratedFrom: "v1" });
await writeAssembly(input, [TARGET.name]);
const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH);
await mkdir(dirname(statePath), { recursive: true });
const wallet = {
credentialProviderArn:
"arn:aws:bedrock-agentcore:us-east-1:111122223333:" +
"token-vault/default/paymentcredentialprovider/wallet",
authorizerType: "PaymentCredentialProvider",
};
await writeFile(
statePath,
JSON.stringify({
targets: {
default: { resources: { credentials: { wallet } } },
prod: {
resources: {
credentials: {
wallet: {
...wallet,
credentialProviderArn:
"arn:aws:bedrock-agentcore:us-west-2:444455556666:" +
"token-vault/default/paymentcredentialprovider/wallet",
},
},
},
},
},
}),
);
const handed: CredentialRemovalInput[] = [];
const subject = harness({
removeCredentials: async function* (_project, removalInput) {
handed.push(removalInput);
yield { type: "step", message: "checked credential removals" };
},
});

await collectDeploy(subject.backend.deploy(input, deployInput()));

expect(handed[0]?.providers).toEqual([
{ name: "wallet", authorizerType: "PaymentCredentialProvider", physicalName: "wallet" },
]);
});

test("says to add a resource when there is no stack to remove either", async () => {
const input = await project();
await writeAssembly(input, [TARGET.name], { resources: METADATA_ONLY });
Expand Down
73 changes: 67 additions & 6 deletions src/core/project/backends/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
type CredentialProviderCalls,
type CredentialProviderRef,
type CredentialProvisioner,
type CredentialRemovalInput,
type CredentialRemover,
type DeployedCredentials,
} from "./cdk/credentials";
Expand All @@ -54,6 +55,7 @@ import {
removeTargetState,
stackReferenceOf,
updateTargetState,
type DeployedState,
} from "./cdk/deployedState";
import {
bootstrapStackReader,
Expand Down Expand Up @@ -112,6 +114,32 @@ function stackExportName(stackName: string, ...parts: string[]): string {
.join("-");
}

function targetsRecordingPaymentProvider(
state: DeployedState,
target: AwsDeploymentTarget,
provider: CredentialProviderRef,
): string[] {
if (provider.authorizerType !== "PaymentCredentialProvider") return [];
const physicalName = provider.physicalName ?? provider.name;
return Object.entries(state.targets).flatMap(([name, saved]) => {
if (name === target.name) return [];
const sharesProvider = Object.values(saved.resources?.credentials ?? {}).some((credential) => {
if (credential.authorizerType && credential.authorizerType !== "PaymentCredentialProvider") {
return false;
}
const arn = credential.credentialProviderArn;
const parts = arn.split(":");
return (
(parts[2] === "bedrock-agentcore" || parts[2] === "acps") &&
parts[3] === target.region &&
parts[4] === target.account &&
arn.endsWith(`/paymentcredentialprovider/${physicalName}`)
);
});
return sharesProvider ? [name] : [];
});
}

function findDeployedResourceId(
stack: Stack,
input: Pick<ResolvedDeployedResource, "resourceType" | "name">,
Expand Down Expand Up @@ -273,9 +301,8 @@ export class CdkBackend implements ProjectBackend {
// Read before provisioning rewrites the credentials map: it is the only record
// of what this target provisioned, so it is the only way to find a provider whose
// credential has since left the spec.
const recorded =
(await readDeployedState(this.json, project.rootPath)).targets[target.name]?.resources
?.credentials ?? {};
const deployedState = await readDeployedState(this.json, project.rootPath);
const recorded = deployedState.targets[target.name]?.resources?.credentials ?? {};
assertPaymentConnectorCredentials(project, target.name, recorded);
const orphaned = orphanedCredentials(recorded, project.spec.credentials);

Expand All @@ -302,7 +329,15 @@ 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, recorded });
return yield* this.teardown({
project,
artifact,
input,
options,
orphaned,
recorded,
deployedState,
});
}

// Only after a real deploy is confirmed: a teardown (empty assembly) returns
Expand Down Expand Up @@ -403,7 +438,7 @@ export class CdkBackend implements ProjectBackend {

// After the stack update, since a resource in it may have been using the provider
// until this deploy removed the reference.
yield* this.removeCredentials(project, {
yield* this.removeUnsharedCredentials(project, target, deployedState, {
credentials,
region: target.region,
targetName: target.name,
Expand Down Expand Up @@ -437,6 +472,7 @@ export class CdkBackend implements ProjectBackend {
options,
orphaned,
recorded,
deployedState,
}: {
project: Project;
artifact: StackArtifact;
Expand All @@ -445,6 +481,7 @@ export class CdkBackend implements ProjectBackend {
/** Providers recorded for this target before the deploy that the spec no longer declares. */
orphaned: CredentialProviderRef[];
recorded: DeployedCredentials;
deployedState: DeployedState;
}): AsyncGenerator<ProjectEvent, DeployResult> {
const { target } = input;
if (!(await this.describeStack(target.region, options.credentials, artifact.stackName))) {
Expand Down Expand Up @@ -475,7 +512,7 @@ export class CdkBackend implements ProjectBackend {
// After the stack, since a resource in it may still be using the provider. The
// declared credentials are included because `agentcore remove all` empties the spec
// before the deploy that gets here, so what it recorded is all that names them.
yield* this.removeCredentials(project, {
yield* this.removeUnsharedCredentials(project, target, deployedState, {
credentials: options.credentials,
region: target.region,
targetName: target.name,
Expand All @@ -490,6 +527,30 @@ export class CdkBackend implements ProjectBackend {
return { outputs: {}, tornDown: true };
}

private async *removeUnsharedCredentials(
project: Project,
target: AwsDeploymentTarget,
deployedState: DeployedState,
input: CredentialRemovalInput,
): AsyncGenerator<ProjectEvent, void> {
const providers: CredentialProviderRef[] = [];
for (const provider of input.providers) {
const otherTargets = targetsRecordingPaymentProvider(deployedState, target, provider);
if (otherTargets.length === 0) {
providers.push(provider);
continue;
}
yield {
type: "step",
message:
`Leaving credential provider '${provider.physicalName ?? provider.name}' in place: ` +
`also recorded for target${otherTargets.length === 1 ? "" : "s"} ` +
otherTargets.map((name) => `'${name}'`).join(", "),
};
}
yield* this.removeCredentials(project, { ...input, providers });
}

/**
* Runs one Toolkit operation with its progress streamed as `output` events.
* The trailing lines are also kept so a failure can carry them: the Toolkit's
Expand Down
Loading
Loading