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
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 { BootstrapState } from "./cdk/environment";
import type { CdkCredentialProvider, CdkOperation, CdkOutputs, CdkRunOptions } from "./cdk/toolkit";
Expand Down Expand Up @@ -85,6 +85,7 @@ type HarnessOptions = {
template?: boolean;
failOperation?: CdkOperation["kind"];
bootstrapError?: Error;
provisionCredentials?: CredentialProvisioner;
};

function harness(options: HarnessOptions = {}) {
Expand Down Expand Up @@ -152,6 +153,7 @@ function harness(options: HarnessOptions = {}) {
},
};
},
...(options.provisionCredentials && { provisionCredentials: options.provisionCredentials }),
});

return {
Expand Down Expand Up @@ -269,22 +271,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, { target: TARGET }));

// 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, { target: TARGET }))).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, { target: TARGET }))).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 @@ -12,6 +12,7 @@ import {
import type { Logger } from "../../../logging";
import type { DeployBackendInput, ProjectBackend } from "./types";
import { stackArtifactIdForTarget } from "./cdk/assembly";
import { createCredentialProvisioner, type CredentialProvisioner } from "./cdk/credentials";
import { readDeployedState, updateTargetState } from "./cdk/deployedState";
import {
probeBootstrap,
Expand All @@ -38,6 +39,7 @@ export type CdkBackendConfig = {
bootstrap?: BootstrapProbe;
resolveAccount?: AccountResolver;
loadBootstrapTemplate?: BootstrapTemplateLoader;
provisionCredentials?: CredentialProvisioner;
};

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

constructor(config: CdkBackendConfig) {
this.logger = config.logger;
Expand All @@ -63,24 +66,30 @@ export class CdkBackend implements ProjectBackend {
this.bootstrap = config.bootstrap ?? probeBootstrap;
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 @@ -101,11 +110,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 stackArtifactId = await stackArtifactIdForTarget(
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