Skip to content
Open
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
56 changes: 55 additions & 1 deletion src/core/identity.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import {
CreateApiKeyCredentialProviderCommand,
CreateOauth2CredentialProviderCommand,
CreatePaymentCredentialProviderCommand,
DeleteApiKeyCredentialProviderCommand,
DeleteOauth2CredentialProviderCommand,
DeletePaymentCredentialProviderCommand,
GetApiKeyCredentialProviderCommand,
GetOauth2CredentialProviderCommand,
GetPaymentCredentialProviderCommand,
ListApiKeyCredentialProvidersCommand,
ListOauth2CredentialProvidersCommand,
UpdateApiKeyCredentialProviderCommand,
UpdateOauth2CredentialProviderCommand,
UpdatePaymentCredentialProviderCommand,
type CreateApiKeyCredentialProviderResponse,
type CreateOauth2CredentialProviderResponse,
type DeleteApiKeyCredentialProviderResponse,
Expand All @@ -19,19 +23,33 @@ import {
type ListOauth2CredentialProvidersResponse,
type UpdateApiKeyCredentialProviderResponse,
type UpdateOauth2CredentialProviderResponse,
type CreatePaymentCredentialProviderResponse,
type DeletePaymentCredentialProviderResponse,
type GetPaymentCredentialProviderResponse,
type UpdatePaymentCredentialProviderResponse,
} from "@aws-sdk/client-bedrock-agentcore-control";
import type {
CoreIdentityClient,
CreateApiKeyCredentialProviderInput,
CreateOauth2CredentialProviderInput,
CreatePaymentCredentialProviderInput,
UpdateApiKeyCredentialProviderInput,
UpdateOauth2CredentialProviderInput,
UpdatePaymentCredentialProviderInput,
} from "../handlers/identity/types";
import { createControlClient } from "./factories";
import type { AwsClients, CoreOptions } from "./types";
import { toClientConfig } from "./utils";

// createIdentityClient builds an IdentityClient that owns its control-plane client,
// for callers constructed outside CoreClient (which hands out its cached ones).
export const createIdentityClient = (): IdentityClient =>
new IdentityClient({ control: createControlClient });

export class IdentityClient implements CoreIdentityClient {
constructor(private readonly clients: AwsClients) {}
// Only the control plane is used, so the dependency is narrowed to it: CoreClient
// still satisfies this by passing itself.
constructor(private readonly clients: Pick<AwsClients, "control">) {}

async createApiKeyCredentialProvider(
input: CreateApiKeyCredentialProviderInput,
Expand Down Expand Up @@ -124,4 +142,40 @@ export class IdentityClient implements CoreIdentityClient {
.control(toClientConfig(options))
.send(new DeleteOauth2CredentialProviderCommand({ name }));
}

async createPaymentCredentialProvider(
input: CreatePaymentCredentialProviderInput,
options: CoreOptions,
): Promise<CreatePaymentCredentialProviderResponse> {
return this.clients
.control(toClientConfig(options))
.send(new CreatePaymentCredentialProviderCommand(input));
}

async getPaymentCredentialProvider(
name: string,
options: CoreOptions,
): Promise<GetPaymentCredentialProviderResponse> {
return this.clients
.control(toClientConfig(options))
.send(new GetPaymentCredentialProviderCommand({ name }));
}

async updatePaymentCredentialProvider(
input: UpdatePaymentCredentialProviderInput,
options: CoreOptions,
): Promise<UpdatePaymentCredentialProviderResponse> {
return this.clients
.control(toClientConfig(options))
.send(new UpdatePaymentCredentialProviderCommand(input));
}

async deletePaymentCredentialProvider(
name: string,
options: CoreOptions,
): Promise<DeletePaymentCredentialProviderResponse> {
return this.clients
.control(toClientConfig(options))
.send(new DeletePaymentCredentialProviderCommand({ name }));
}
}
26 changes: 25 additions & 1 deletion src/core/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ export class CoreClient implements AwsClients {
this.projectManager = new FsProjectManager({
logger: this.logger.child({ module: "projectManager" }),
createCloudFormationClient: config.createCloudFormationClient,
// A project deploy provisions credential providers through the same Identity
// client the `agentcore identity` commands use, against its target's credentials.
identity: this.identity,
});
this.describeBedrockAgent = config.describeBedrockAgent ?? describeBedrockAgent;
}
Expand Down Expand Up @@ -164,6 +167,27 @@ export class CoreClient implements AwsClients {

// cacheKey derives a stable cache key from a ClientConfig so that distinct
// configurations (region, endpoint, ...) map to distinct cached clients.
//
// `credentials` is a provider function or an object of resolved credentials, so it
// cannot be serialized — JSON.stringify drops functions silently, which would map two
// callers with different credentials in the same region onto one cached client. It is
// keyed by identity instead.
function cacheKey(config: ClientConfig): string {
return JSON.stringify(config);
const { credentials, ...serializable } = config;
const suffix = credentials ? `|credentials:${credentialsId(credentials)}` : "";
return JSON.stringify(serializable) + suffix;
}

const credentialsIds = new WeakMap<object, number>();
let nextCredentialsId = 0;

// credentialsId assigns each credential source a stable id for the lifetime of the
// object, so the same source reuses its client and a different one gets its own.
function credentialsId(credentials: NonNullable<ClientConfig["credentials"]>): number {
let id = credentialsIds.get(credentials);
if (id === undefined) {
id = nextCredentialsId++;
credentialsIds.set(credentials, id);
}
return id;
}
125 changes: 122 additions & 3 deletions src/core/project/backends/cdk.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { afterEach, describe, expect, test } from "bun:test";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
Expand All @@ -9,6 +8,11 @@ import { FsReadWriteJson } from "../../../io";
import { ProjectSpecSchema } from "../../../projectSchemas/project";
import { createSilentLogger } from "../../../testing";
import { CdkBackend } from "./cdk";
import type {
CredentialProviderCalls,
CredentialProvisioner,
PaymentCredentialRemover,
} from "./cdk/credentials";
import { DEPLOYED_STATE_RELATIVE_PATH, updateTargetState } from "./cdk/deployedState";
import type { DeployBackendInput } from "./types";
import type { BootstrapState } from "./cdk/environment";
Expand All @@ -26,6 +30,31 @@ const json = new FsReadWriteJson({ logger: createSilentLogger() });
/** A template holding only what CDK adds itself, as an empty project synthesizes. */
const METADATA_ONLY = { CDKMetadata: { Type: "AWS::CDK::Metadata" } };

/**
* Identity for backends whose provisioning is not under test: these projects declare
* no credentials, and the tests that do exercise provisioning inject their own
* CredentialProvisioner. Any call here is a test that stopped meaning what it says.
*/
function unusedIdentity(): CredentialProviderCalls {
const unexpected = (call: string) => async (): Promise<never> => {
throw new Error(`unexpected Identity call: ${call}`);
};
return {
getApiKeyCredentialProvider: unexpected("getApiKeyCredentialProvider"),
createApiKeyCredentialProvider: unexpected("createApiKeyCredentialProvider"),
updateApiKeyCredentialProvider: unexpected("updateApiKeyCredentialProvider"),
getOauth2CredentialProvider: unexpected("getOauth2CredentialProvider"),
createOauth2CredentialProvider: unexpected("createOauth2CredentialProvider"),
updateOauth2CredentialProvider: unexpected("updateOauth2CredentialProvider"),
getPaymentCredentialProvider: unexpected("getPaymentCredentialProvider"),
createPaymentCredentialProvider: unexpected("createPaymentCredentialProvider"),
updatePaymentCredentialProvider: unexpected("updatePaymentCredentialProvider"),
deleteApiKeyCredentialProvider: unexpected("deleteApiKeyCredentialProvider"),
deleteOauth2CredentialProvider: unexpected("deleteOauth2CredentialProvider"),
deletePaymentCredentialProvider: unexpected("deletePaymentCredentialProvider"),
};
}

function deployInput(overrides: Partial<DeployBackendInput> = {}): DeployBackendInput {
return { target: TARGET, confirmTeardown: async () => false, ...overrides };
}
Expand Down Expand Up @@ -125,6 +154,8 @@ type HarnessOptions = {
template?: boolean;
failOperation?: CdkOperation["kind"];
bootstrapError?: Error;
provisionCredentials?: CredentialProvisioner;
removePaymentCredentials?: PaymentCredentialRemover;
/** Stack returned by CloudFormation. Defaults to a present stack; null means absent. */
describedStack?: Stack | null;
};
Expand All @@ -148,6 +179,7 @@ function harness(options: HarnessOptions = {}) {

const backend = new CdkBackend({
logger: createSilentLogger(),
identity: unusedIdentity(),
runner: async (command, { cwd }) => {
commands.push({ command, cwd });
},
Expand Down Expand Up @@ -196,6 +228,10 @@ function harness(options: HarnessOptions = {}) {
},
};
},
...(options.provisionCredentials && { provisionCredentials: options.provisionCredentials }),
...(options.removePaymentCredentials && {
removePaymentCredentials: options.removePaymentCredentials,
}),
describeStack: async (region, provider, stackName) => {
stackReads.push({ stackName, region, credentials: provider });
if (options.describedStack === null) return undefined;
Expand Down Expand Up @@ -265,6 +301,7 @@ describe("CdkBackend.build", () => {
const input = await project();
const subject = new CdkBackend({
logger: createSilentLogger(),
identity: unusedIdentity(),
runner: async () => {
throw new Error("cdk synth exploded");
},
Expand Down Expand Up @@ -321,26 +358,86 @@ describe("CdkBackend.deploy", () => {

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

const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH);
expect(JSON.parse(await Bun.file(statePath).text())).toEqual({
targets: {
default: {
resources: { credentials: {} },
stackArn:
"arn:aws:cloudformation:us-east-1:111122223333:stack/AgentCore-example-default/abc",
},
},
});
});

test("provisions credentials before synth and records them under the target", async () => {
const input = await project();
await writeAssembly(input, [TARGET.name]);
const provisionCredentials: CredentialProvisioner = async function* () {
yield { message: "Preparing credential provider 'openai-key'" };
return { "openai-key": { credentialProviderArn: "arn:apikey:openai-key" } };
};
const subject = harness({
outputs: { RuntimeArn: "arn:runtime" },
stackArn: "arn:aws:cloudformation:us-east-1:111122223333:stack/AgentCore-example-default/abc",
provisionCredentials,
});

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

// The credential step runs (and its ARNs are recorded) before synthesis, so
// the assembly is synthesized against a state file that already describes them.
const messages = deployed.events.map((event) => event.message);
expect(messages.indexOf("Preparing credential provider 'openai-key'")).toBeLessThan(
messages.indexOf("Synthesizing CloudFormation templates"),
);

// The pre-synth credentials write and the post-deploy stack-ARN write merge
// into one target entry rather than clobbering each other.
const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH);
expect(JSON.parse(await Bun.file(statePath).text())).toEqual({
targets: {
default: {
stackArn:
"arn:aws:cloudformation:us-east-1:111122223333:stack/AgentCore-example-default/abc",
resources: {
credentials: { "openai-key": { credentialProviderArn: "arn:apikey:openai-key" } },
},
},
},
});
});

test("fails a deploy whose result carries no stack ARN, recording nothing", async () => {
test("fails a deploy whose result carries no stack ARN, recording no binding", async () => {
const input = await project();
await writeAssembly(input, [TARGET.name]);
const subject = harness({ outputs: { RuntimeArn: "arn:runtime" }, omitStackArn: true });

await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toThrow(
/without a stack ARN/,
);
expect(existsSync(join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH))).toBe(false);
// The pre-synth credentials write may have created the file, but the failed
// deploy must not have recorded a stack binding.
const state = JSON.parse(
await Bun.file(join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH)).text(),
);
expect(state.targets.default?.stackArn).toBeUndefined();
});

test("checks local CDK prerequisites before provisioning credentials", async () => {
const input = await project(false); // no agentcore/cdk/node_modules
let provisioned = false;
// eslint-disable-next-line require-yield -- a spy that should never run (deploy fails first)
const provisionCredentials: CredentialProvisioner = async function* () {
provisioned = true;
return {};
};
const subject = harness({ provisionCredentials });

await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toThrow(
/npm install/,
);
expect(provisioned).toBe(false);
});

test("fails before touching AWS when the existing state file is malformed", async () => {
Expand Down Expand Up @@ -459,6 +556,28 @@ describe("CdkBackend.deploy", () => {
});
});

test("removes the project's payment credential providers after its stack", async () => {
const input = await project();
await writeAssembly(input, [TARGET.name], { resources: METADATA_ONLY });
const removals: string[] = [];
const removePaymentCredentials: PaymentCredentialRemover = async function* (project) {
removals.push(project.name);
yield { message: "Removing credential provider 'wallet'" };
};
const subject = harness({ removePaymentCredentials });

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

expect(removals).toEqual(["example"]);
// After the destroy, since a resource in the stack may still be using it.
const messages = deployed.events.map((event) => event.message);
expect(messages.indexOf("Removing stack AgentCore-example-default-0")).toBeLessThan(
messages.indexOf("Removing credential provider '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
Loading
Loading