Skip to content
68 changes: 65 additions & 3 deletions src/core/project/backends/cdk.test.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -194,6 +195,7 @@ function harness(options: HarnessOptions = {}) {
},
};
},
...(options.provisionCredentials && { provisionCredentials: options.provisionCredentials }),
});

return {
Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat: create credential providers before synthesizing a deploy by notgitika · Pull Request #2123 · aws/agentcore-cli · GitHub
Skip to content
68 changes: 65 additions & 3 deletions src/core/project/backends/cdk.test.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -194,6 +195,7 @@ function harness(options: HarnessOptions = {}) {
},
};
},
...(options.provisionCredentials && { provisionCredentials: options.provisionCredentials }),
});

return {
Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: create credential providers before synthesizing a deploy by notgitika · Pull Request #2123 · aws/agentcore-cli · GitHub
Skip to content
68 changes: 65 additions & 3 deletions src/core/project/backends/cdk.test.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -194,6 +195,7 @@ function harness(options: HarnessOptions = {}) {
},
};
},
...(options.provisionCredentials && { provisionCredentials: options.provisionCredentials }),
});

return {
Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: create credential providers before synthesizing a deploy by notgitika · Pull Request #2123 · aws/agentcore-cli · GitHub
Skip to content
68 changes: 65 additions & 3 deletions src/core/project/backends/cdk.test.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -194,6 +195,7 @@ function harness(options: HarnessOptions = {}) {
},
};
},
...(options.provisionCredentials && { provisionCredentials: options.provisionCredentials }),
});

return {
Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat: create credential providers before synthesizing a deploy by notgitika · Pull Request #2123 · aws/agentcore-cli · GitHub
Skip to content
68 changes: 65 additions & 3 deletions src/core/project/backends/cdk.test.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -194,6 +195,7 @@ function harness(options: HarnessOptions = {}) {
},
};
},
...(options.provisionCredentials && { provisionCredentials: options.provisionCredentials }),
});

return {
Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: create credential providers before synthesizing a deploy by notgitika · Pull Request #2123 · aws/agentcore-cli · GitHub
Skip to content
68 changes: 65 additions & 3 deletions src/core/project/backends/cdk.test.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -194,6 +195,7 @@ function harness(options: HarnessOptions = {}) {
},
};
},
...(options.provisionCredentials && { provisionCredentials: options.provisionCredentials }),
});

return {
Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat: create credential providers before synthesizing a deploy by notgitika · Pull Request #2123 · aws/agentcore-cli · GitHub
Skip to content
68 changes: 65 additions & 3 deletions src/core/project/backends/cdk.test.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -194,6 +195,7 @@ function harness(options: HarnessOptions = {}) {
},
};
},
...(options.provisionCredentials && { provisionCredentials: options.provisionCredentials }),
});

return {
Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
ContributorAuthor

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 numberDiff line numberDiff 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