Skip to content
68 changes: 65 additions & 3 deletions src/core/project/backends/cdk.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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";
import type { DeployResult, Project, ProjectEvent } from "../../../handlers/project/types";
import { ProjectSpecSchema } from "../../../projectSchemas/project";
import { createSilentLogger } from "../../../testing";
import { CdkBackend } from "./cdk";
import type { CredentialProvisioner } from "./cdk/credentials";
import { DEPLOYED_STATE_RELATIVE_PATH } from "./cdk/deployedState";
import type { DeployBackendInput } from "./types";
import type { BootstrapState } from "./cdk/environment";
Expand Down Expand Up @@ -120,6 +120,7 @@ type HarnessOptions = {
template?: boolean;
failOperation?: CdkOperation["kind"];
bootstrapError?: Error;
provisionCredentials?: CredentialProvisioner;
/** Whether CloudFormation still holds the target's stack. Defaults to present. */
stackExists?: boolean;
};
Expand Down Expand Up @@ -194,6 +195,7 @@ function harness(options: HarnessOptions = {}) {
},
};
},
...(options.provisionCredentials && { provisionCredentials: options.provisionCredentials }),
});

return {
Expand Down Expand Up @@ -312,22 +314,82 @@ describe("CdkBackend.deploy", () => {
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("fails a deploy whose result carries no stack ARN, recording nothing", async () => {
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 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
34 changes: 28 additions & 6 deletions src/core/project/backends/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { Logger } from "../../../logging";
import type { DeployBackendInput, ProjectBackend } from "./types";
import { createCloudFormationClient } from "../../factories";
import type { CreateCloudFormationClient } from "../../types";
import { createCredentialProvisioner, type CredentialProvisioner } from "./cdk/credentials";
import {
countDeployableResources,
stackArtifactForTarget,
Expand Down Expand Up @@ -51,6 +52,7 @@ export type CdkBackendConfig = {
stack?: StackProbe;
resolveAccount?: AccountResolver;
loadBootstrapTemplate?: BootstrapTemplateLoader;
provisionCredentials?: CredentialProvisioner;
};

/** Builds and deploys projects through the scaffolded CDK app. */
Expand All @@ -65,6 +67,7 @@ export class CdkBackend implements ProjectBackend {
private readonly stack: StackProbe;
private readonly resolveAccount: AccountResolver;
private readonly loadBootstrapTemplate: BootstrapTemplateLoader;
private readonly provisionCredentials: CredentialProvisioner;

constructor(config: CdkBackendConfig) {
this.logger = config.logger;
Expand All @@ -86,24 +89,30 @@ export class CdkBackend implements ProjectBackend {
((stackName, region, credentials) => probeStack(stackName, region, credentials, readStack));
this.resolveAccount = config.resolveAccount ?? resolveAwsAccount;
this.loadBootstrapTemplate = config.loadBootstrapTemplate ?? loadBootstrapTemplate;
this.provisionCredentials = config.provisionCredentials ?? createCredentialProvisioner();
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {
// Local prerequisites for synth. Checked before any AWS mutation so a missing
// toolchain or dependencies fails without having provisioned credentials.
private async ensureCdkDependencies(project: Project): Promise<void> {
const cdkDir = this.cdkDirectory(project);

if (!existsSync(join(cdkDir, "node_modules"))) {
throw new ProjectStateError(
`CDK dependencies are missing for project '${project.name}'. ` +
`Run 'cd ${cdkDir} && npm install'.`,
);
}
await this.checkTool("npm", "Install Node.js: https://nodejs.org/");
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {
await this.ensureCdkDependencies(project);

yield { message: "Synthesizing CloudFormation templates" };
await this.runner(
["npm", "run", "cdk", "--", "synth", "--quiet", "--output", this.assemblyDirectory(project)],
{
cwd: cdkDir,
cwd: this.cdkDirectory(project),
onOutput: (chunk) => this.logger.debug(chunk),
},
);
Expand All @@ -124,11 +133,24 @@ export class CdkBackend implements ProjectBackend {
);
}

// Validate any existing deployed state before mutating AWS. A malformed file
// must fail here — not after bootstrap/deploy — so we never leave AWS changed
// with the new stack ARN unrecorded because the post-deploy write can't parse it.
// Fail on local setup errors (missing toolchain/deps) and malformed state
// before any AWS mutation, so a local problem never leaves credentials
// provisioned or the stack ARN unrecorded.
await this.ensureCdkDependencies(project);
await readDeployedState(this.json, project.rootPath);

// Credential providers aren't stack resources; the synthesized app reads their
// ARNs from deployed-state.json, so they must exist and be recorded before synth.
const provisioned = yield* this.provisionCredentials(project, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could the local CDK prerequisite checks stay ahead of credential provisioning? I ran this with agentcore/cdk/node_modules missing: the provisioner ran and deployed-state.json was written, then deploy failed with the npm install guidance. Synthesis still has to happen after provisioning, but checking npm and dependencies separately first would avoid mutating AWS for a local setup error. Probably just good to have like for ux but not a blocker.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, a local setup error shouldn't mutate AWS. I'll hoist the npm/node_modules prerequisite check ahead of credential provisioning so we fail fast before creating anything. Synth still runs after provisioning, but the local-only checks don't need to.

credentials,
region: target.region,
});
// Recorded every deploy (even when empty) so dropping the last credential
// from the spec clears the stale entry instead of leaving it advertised.
await updateTargetState(this.json, project.rootPath, target.name, {
resources: { credentials: provisioned },
});

yield* this.build(project);
const assemblyDirectory = this.assemblyDirectory(project);
const artifact = await stackArtifactForTarget(this.json, assemblyDirectory, target.name);
Expand Down
192 changes: 192 additions & 0 deletions src/core/project/backends/cdk/credentials.client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { afterEach, describe, expect, mock, test } from "bun:test";

// credentials.test.ts drives the provisioner with a fake client; this covers the
// real factory by mocking the AWS SDK it lazily imports.

class ResourceNotFoundException extends Error {
constructor() {
super("not found");
this.name = "ResourceNotFoundException";
}
}
class GetApiKeyCredentialProviderCommand {
constructor(readonly input: unknown) {}
}
class CreateApiKeyCredentialProviderCommand {
constructor(readonly input: unknown) {}
}
class GetOauth2CredentialProviderCommand {
constructor(readonly input: unknown) {}
}
class CreateOauth2CredentialProviderCommand {
constructor(readonly input: unknown) {}
}

const sent: unknown[] = [];
let send: (command: unknown) => Promise<unknown>;

class BedrockAgentCoreControlClient {
constructor(readonly config: unknown) {}
send(command: unknown) {
sent.push(command);
return send(command);
}
}

mock.module("@aws-sdk/client-bedrock-agentcore-control", () => ({
BedrockAgentCoreControlClient,
GetApiKeyCredentialProviderCommand,
CreateApiKeyCredentialProviderCommand,
GetOauth2CredentialProviderCommand,
CreateOauth2CredentialProviderCommand,
ResourceNotFoundException,
}));

const { createIdentityProviderClient } = await import("./credentials");
const credentials = async () => ({ accessKeyId: "a", secretAccessKey: "b" });

afterEach(() => {
sent.length = 0;
});

describe("createIdentityProviderClient", () => {
test("passes region and credentials to the SDK client", async () => {
send = async () => ({ credentialProviderArn: "arn:cp" });
const client = await createIdentityProviderClient("eu-west-1", credentials);
await client.getApiKeyProvider("k");

expect((sent[0] as GetApiKeyCredentialProviderCommand).input).toEqual({ name: "k" });
});

test("maps an API key provider, including its secret ARN", async () => {
send = async () => ({
credentialProviderArn: "arn:cp",
apiKeySecretArn: { secretArn: "arn:secret" },
});
const client = await createIdentityProviderClient("us-east-1", credentials);

expect(await client.getApiKeyProvider("k")).toEqual({
credentialProviderArn: "arn:cp",
clientSecretArn: "arn:secret",
});
});

test("maps an OAuth2 provider it finds, including its secret ARN", async () => {
send = async () => ({
credentialProviderArn: "arn:cp",
clientSecretArn: { secretArn: "arn:secret" },
});
const client = await createIdentityProviderClient("us-east-1", credentials);

expect(await client.getOauth2Provider("o")).toEqual({
credentialProviderArn: "arn:cp",
clientSecretArn: "arn:secret",
});
});

test("omits the secret ARN when Identity returns none", async () => {
send = async () => ({ credentialProviderArn: "arn:cp" });
const client = await createIdentityProviderClient("us-east-1", credentials);

expect(await client.getApiKeyProvider("k")).toEqual({ credentialProviderArn: "arn:cp" });
});

test("returns undefined when the provider does not exist", async () => {
send = async () => {
throw new ResourceNotFoundException();
};
const client = await createIdentityProviderClient("us-east-1", credentials);

expect(await client.getApiKeyProvider("missing")).toBeUndefined();
expect(await client.getOauth2Provider("missing")).toBeUndefined();
});

test("propagates errors other than not-found", async () => {
const failure = Object.assign(new Error("denied"), { name: "AccessDeniedException" });
send = async () => {
throw failure;
};
const client = await createIdentityProviderClient("us-east-1", credentials);

await expect(client.getApiKeyProvider("k")).rejects.toBe(failure);
});

test("throws when Identity returns no provider ARN", async () => {
send = async () => ({});
const client = await createIdentityProviderClient("us-east-1", credentials);

await expect(client.createApiKeyProvider({ name: "k", apiKey: "sk" })).rejects.toThrow(
/no credentialProviderArn/,
);
});

test("creates an API key provider from an inline key", async () => {
send = async () => ({ credentialProviderArn: "arn:cp" });
const client = await createIdentityProviderClient("us-east-1", credentials);

await client.createApiKeyProvider({ name: "k", apiKey: "sk-live" });

expect((sent[0] as CreateApiKeyCredentialProviderCommand).input).toEqual({
name: "k",
apiKey: "sk-live",
});
});

test("creates an API key provider from an external secret reference", async () => {
send = async () => ({ credentialProviderArn: "arn:cp" });
const client = await createIdentityProviderClient("us-east-1", credentials);

const secretRef = { secretId: "s", jsonKey: "apiKey" };
await client.createApiKeyProvider({ name: "k", secretRef });

expect((sent[0] as CreateApiKeyCredentialProviderCommand).input).toEqual({
name: "k",
apiKeySecretConfig: secretRef,
apiKeySecretSource: "EXTERNAL",
});
});

test("returns the created API key provider's secret ARN", async () => {
send = async () => ({
credentialProviderArn: "arn:cp",
apiKeySecretArn: { secretArn: "arn:secret" },
});
const client = await createIdentityProviderClient("us-east-1", credentials);

expect(await client.createApiKeyProvider({ name: "k", apiKey: "sk" })).toEqual({
credentialProviderArn: "arn:cp",
clientSecretArn: "arn:secret",
});
});

test("creates an OAuth2 provider without a returned secret ARN", async () => {
send = async () => ({ credentialProviderArn: "arn:cp" });
const client = await createIdentityProviderClient("us-east-1", credentials);

expect(
await client.createOauth2Provider({
name: "o",
vendor: "CustomOauth2",
config: { customOauth2ProviderConfig: { oauthDiscovery: { discoveryUrl: "u" } } },
}),
).toEqual({ credentialProviderArn: "arn:cp" });
});

test("creates an OAuth2 provider with its vendor and config", async () => {
send = async () => ({
credentialProviderArn: "arn:cp",
clientSecretArn: { secretArn: "arn:secret" },
});
const client = await createIdentityProviderClient("us-east-1", credentials);

const config = { customOauth2ProviderConfig: { oauthDiscovery: { discoveryUrl: "u" } } };
const result = await client.createOauth2Provider({ name: "o", vendor: "CustomOauth2", config });

expect((sent[0] as CreateOauth2CredentialProviderCommand).input).toEqual({
name: "o",
credentialProviderVendor: "CustomOauth2",
oauth2ProviderConfigInput: config,
});
expect(result).toEqual({ credentialProviderArn: "arn:cp", clientSecretArn: "arn:secret" });
});
});
Loading
Loading