Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 55 additions & 6 deletions e2e-tests/e2e-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
BedrockAgentCoreControlClient,
DeleteApiKeyCredentialProviderCommand,
} from '@aws-sdk/client-bedrock-agentcore-control';
import { CloudFormationClient, DeleteStackCommand, DescribeStacksCommand } from '@aws-sdk/client-cloudformation';
import { execSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { mkdir, rm, writeFile } from 'node:fs/promises';
Expand Down Expand Up @@ -96,20 +97,68 @@ export function createE2ESuite(cfg: E2EConfig) {

afterAll(async () => {
if (projectPath && hasAws) {
await runCLI(['remove', 'all', '--json'], projectPath, false);
const result = await runCLI(['deploy', '--yes', '--json'], projectPath, false);
const region = process.env.AWS_REGION ?? 'us-east-1';
const stackName = `AgentCore-${agentName}-default`;

// Check if the stack is in a failed state that requires direct deletion
let needsDirectDelete = false;
try {
const cfnClient = new CloudFormationClient({ region });
const { Stacks } = await cfnClient.send(new DescribeStacksCommand({ StackName: stackName }));
const status = Stacks?.[0]?.StackStatus;
needsDirectDelete = status === 'ROLLBACK_COMPLETE' || status === 'ROLLBACK_FAILED';
} catch {
// Stack doesn't exist or can't be described — proceed with normal teardown
}

if (result.exitCode !== 0) {
console.log('Teardown stdout:', result.stdout);
console.log('Teardown stderr:', result.stderr);
if (needsDirectDelete) {
// Stack is in a terminal failed state — delete it directly via CloudFormation
try {
const cfnClient = new CloudFormationClient({ region });
await cfnClient.send(new DeleteStackCommand({ StackName: stackName }));
// Wait for deletion (poll every 10s, up to 5 minutes)
for (let i = 0; i < 30; i++) {
await new Promise(resolve => setTimeout(resolve, 10000));
try {
const { Stacks } = await cfnClient.send(new DescribeStacksCommand({ StackName: stackName }));
const status = Stacks?.[0]?.StackStatus;
if (status === 'DELETE_COMPLETE') break;
if (status === 'DELETE_FAILED') {
console.log(`Stack ${stackName} delete failed`);
break;
}
} catch {
// Stack no longer exists — deletion complete
break;
}
}
} catch {
console.log(`Failed to delete stack ${stackName} directly`);
}
} else {
// Normal teardown: remove resources and deploy empty stack
await runCLI(['remove', 'all', '--json'], projectPath, false);
const result = await runCLI(['deploy', '--yes', '--json'], projectPath, false);

if (result.exitCode !== 0) {
console.log('Teardown stdout:', result.stdout);
console.log('Teardown stderr:', result.stderr);

// If the deploy of empty stack also fails, delete the stack directly
try {
const cfnClient = new CloudFormationClient({ region });
await cfnClient.send(new DeleteStackCommand({ StackName: stackName }));
} catch {
console.log(`Fallback stack deletion also failed for ${stackName}`);
}
}
}

// Delete the API key credential provider from the account.
// These are created as a pre-deploy step outside CDK and are not
// cleaned up by stack teardown, so we must delete them explicitly.
if (cfg.modelProvider !== 'Bedrock' && agentName) {
const providerName = `${agentName}${cfg.modelProvider}`;
const region = process.env.AWS_REGION ?? 'us-east-1';
try {
const client = new BedrockAgentCoreControlClient({ region });
await client.send(new DeleteApiKeyCredentialProviderCommand({ name: providerName }));
Expand Down
41 changes: 41 additions & 0 deletions src/cli/cdk/toolkit-lib/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,47 @@ import {
} from '@aws-cdk/toolkit-lib';
import * as path from 'node:path';

/**
* Increase the fast-xml-parser entity expansion limit from 1000 to 100000.
*
* The AWS SDK uses fast-xml-parser to deserialize CloudFormation XML responses.
* Container build stacks create enough CloudFormation resources that the
* DescribeStackEvents response can exceed the default 1000 entity expansion
* limit, causing deploy to fail with "Entity expansion limit exceeded".
*
* The fast-xml-parser CJS bundle marks its exports as non-configurable
* (webpack getter), so we cannot replace XMLParser on the module. Instead
* we patch XMLParser.prototype.parse to raise the limit before every parse.
*/
function patchXmlParser(fxp: { XMLParser: { prototype: { parse: (...args: unknown[]) => unknown } } }) {
const origParse = fxp.XMLParser.prototype.parse;
if ((origParse as { __patched?: boolean }).__patched) return;
const patched = function (
this: { options?: { processEntities?: { maxTotalExpansions?: number } } },
xmlData: unknown,
...args: unknown[]
) {
if (this.options?.processEntities) {
this.options.processEntities.maxTotalExpansions = 100000;
}
return origParse.call(this, xmlData, ...args);
};
(patched as { __patched?: boolean }).__patched = true;
fxp.XMLParser.prototype.parse = patched;
}

// Patch the node_modules copy used by @aws-cdk/toolkit-lib (externalized by esbuild).
// The module name is split to prevent esbuild from resolving/bundling it at build time.
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports, security/detect-non-literal-require
const fxp = require(['fast-xml', 'parser'].join('-')) as {
XMLParser: { prototype: { parse: (...args: unknown[]) => unknown } };
};
patchXmlParser(fxp);
} catch {
// fast-xml-parser not available — skip patching
}

// Type for the assembly returned by synth().produce() - has an async dispose method
interface DisposableAssembly {
cloudAssembly: { directory: string; stacks: { stackName: string }[] };
Expand Down
Loading