From beff600562f6c20f023f20c0641189210ee019c7 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Sun, 22 Mar 2026 18:43:20 +0000 Subject: [PATCH] fix: resolve entity expansion limit for container deploys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fast-xml-parser v5.x library defaults maxTotalExpansions to 1000. Container build stacks create enough CloudFormation resources that DescribeStackEvents XML responses exceed this limit (1015+ entities), causing CDK deploy to fail with "Entity expansion limit exceeded". Patch XMLParser.prototype.parse to raise the limit to 100000 before every parse call. The fast-xml-parser CJS bundle uses non-configurable webpack getters, so we cannot replace the XMLParser constructor — but prototype methods are writable. The module name is split in the require call to prevent esbuild from bundling it, ensuring the patch targets the same node_modules copy that @aws-cdk/toolkit-lib loads at runtime. Also improve e2e test teardown to handle stacks in ROLLBACK_COMPLETE or ROLLBACK_FAILED states by deleting them directly via CloudFormation API, with a fallback for normal teardown failures. Constraint: fast-xml-parser exports are configurable:false (webpack getter) Constraint: @aws-cdk/toolkit-lib is externalized by esbuild, so it uses the node_modules copy of fast-xml-parser Rejected: Monkey-patch module.exports.XMLParser | non-configurable getter prevents override Rejected: Object.defineProperty on exports | configurable:false throws TypeError Rejected: Patching fxp.cjs file on disk | fragile, undone by npm install Confidence: high Scope-risk: narrow Co-Authored-By: Claude Opus 4.6 --- e2e-tests/e2e-helper.ts | 61 +++++++++++++++++++++++++++--- src/cli/cdk/toolkit-lib/wrapper.ts | 41 ++++++++++++++++++++ 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/e2e-tests/e2e-helper.ts b/e2e-tests/e2e-helper.ts index 0ec88fb6..b21cd39b 100644 --- a/e2e-tests/e2e-helper.ts +++ b/e2e-tests/e2e-helper.ts @@ -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'; @@ -96,12 +97,61 @@ 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. @@ -109,7 +159,6 @@ export function createE2ESuite(cfg: E2EConfig) { // 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 })); diff --git a/src/cli/cdk/toolkit-lib/wrapper.ts b/src/cli/cdk/toolkit-lib/wrapper.ts index 4444f279..8060d0f6 100644 --- a/src/cli/cdk/toolkit-lib/wrapper.ts +++ b/src/cli/cdk/toolkit-lib/wrapper.ts @@ -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 }[] };