diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts index 6719b3d3a..36e00a616 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts @@ -439,7 +439,12 @@ class FullCloudFormationDeployment { const deploymentMethod = this.deploymentMethod ?? { method: 'change-set' }; // if there is a hotswap cache, clear it when a full Cloudformation of any kind happens - await invalidateHotswapTemplateCache(this.stackArtifact.assembly.directory, this.stackArtifact.stackName); + const deploymentEnv = this.options.resolvedEnvironment; + await invalidateHotswapTemplateCache( + this.stackArtifact.assembly.directory, + this.stackArtifact.stackName, + `${deploymentEnv.account}/${deploymentEnv.region}`, + ); if (deploymentMethod.method === 'direct' && this.options.resourcesToImport) { throw new ToolkitError('ImportRequiresChangeSet', 'Importing resources requires a changeset deployment'); @@ -967,10 +972,12 @@ async function canSkipDeploy( } // treat template in the hotswap cache as the source of truth + const hotswapCacheEnv = deployStackOptions.resolvedEnvironment; const hotswapCache = await readHotswapTemplateCache( deployStackOptions.stack.assembly.directory, deployStackOptions.stack.stackName, deployStackOptions.stack.template, + `${hotswapCacheEnv.account}/${hotswapCacheEnv.region}`, ); if (hotswapCache && diffTemplate(hotswapCache.deployedRootTemplate, deployStackOptions.stack.template).differenceCount > 0) { await ioHelper.defaults.debug(`${deployName}: template has changed in relation to last successful hotswap deployment`); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/hotswap-deployments.ts b/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/hotswap-deployments.ts index 706f06d1e..39199cfe7 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/hotswap-deployments.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/hotswap-deployments.ts @@ -174,7 +174,8 @@ async function hotswapDeployment( // Check for a cached template from a previous hotswap deployment. // Use if available, represents the current state of the resources involved in hotswap. - const hotswapCache = await readHotswapTemplateCache(stack.assembly.directory, stack.stackName, stack.template); + const environmentKey = `${resolvedEnv.account}/${resolvedEnv.region}`; + const hotswapCache = await readHotswapTemplateCache(stack.assembly.directory, stack.stackName, stack.template, environmentKey); const currentTemplate = hotswapCache ?? await loadCurrentTemplateWithNestedStacks(stack, sdk); const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({ @@ -226,7 +227,7 @@ async function hotswapDeployment( try { await applyAllHotswapOperations(sdk, ioSpan, hotswappable); // Cache the synthesized template so the next hotswap diffs against it - await writeHotswapTemplateCache(stack.assembly.directory, stack.stackName, stack.template, currentTemplate.nestedStacks); + await writeHotswapTemplateCache(stack.assembly.directory, stack.stackName, stack.template, currentTemplate.nestedStacks, environmentKey); } catch (e: any) { error = e; } diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/hotswap-template-cache.ts b/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/hotswap-template-cache.ts index 63c471653..5ced24c22 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/hotswap-template-cache.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/hotswap-template-cache.ts @@ -20,8 +20,16 @@ interface CachedHotswapState { readonly nestedStacks: { [logicalId: string]: CachedNestedStack }; } -function cachePath(assemblyDir: string, stackName: string): string { - return path.join(assemblyDir, CACHE_DIR, `${stackName}.json`); +/** + * The cache is only valid for the account/region it was produced against - the + * cached deployedRootTemplate and physical resource names are meaningless (or + * actively misleading) if replayed against a different environment. Fold the + * resolved environment into the cache key so switching accounts/regions + * between hotswap-only sessions can never serve another environment's state. + */ +function cachePath(assemblyDir: string, stackName: string, environment: string): string { + const environmentKey = environment.replace(/[^a-zA-Z0-9_-]/g, '_'); + return path.join(assemblyDir, CACHE_DIR, `${stackName}.${environmentKey}.json`); } /** @@ -33,8 +41,9 @@ export async function readHotswapTemplateCache( assemblyDir: string, stackName: string, newRootTemplate: Template, + environment: string, ): Promise { - const cachedPath = cachePath(assemblyDir, stackName); + const cachedPath = cachePath(assemblyDir, stackName, environment); try { const cached = await fs.readJson(cachedPath); @@ -56,12 +65,13 @@ export async function writeHotswapTemplateCache( stackName: string, rootTemplate: Template, nestedStacks: { [logicalId: string]: NestedStackTemplates }, + environment: string, ): Promise { const state: CachedHotswapState = { deployedRootTemplate: rootTemplate, nestedStacks: toCachedNestedStacks(nestedStacks), }; - const cachedPath = cachePath(assemblyDir, stackName); + const cachedPath = cachePath(assemblyDir, stackName, environment); await fs.ensureDir(path.dirname(cachedPath)); await fs.writeJson(cachedPath, state, { spaces: 2 }); } @@ -69,8 +79,8 @@ export async function writeHotswapTemplateCache( /** * Invalidate the hotswap cache for a stack (e.g. after a full CloudFormation deploy). */ -export async function invalidateHotswapTemplateCache(assemblyDir: string, stackName: string): Promise { - await fs.rm(cachePath(assemblyDir, stackName), { force: true }); +export async function invalidateHotswapTemplateCache(assemblyDir: string, stackName: string, environment: string): Promise { + await fs.rm(cachePath(assemblyDir, stackName, environment), { force: true }); } /** diff --git a/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts index cd86a95ef..7325f66b4 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/deployments/deploy-stack.test.ts @@ -277,7 +277,7 @@ describe('hotswap template cache', () => { test('writeHotswapTemplateCache is called on a successful hotswap deployment', async () => { // GIVEN (tryHotswapDeployment as jest.Mock).mockImplementation(async () => { - await writeHotswapTemplateCache('assembly-dir', 'withouterrors', {}, {}); + await writeHotswapTemplateCache('assembly-dir', 'withouterrors', {}, {}, '123456789012/us-east-1'); return { type: 'did-deploy-stack', noOp: false, stackArn: 'arn:stack', outputs: {}, deleteFailures: [], stabilizingResources: [] }; }); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/hotswap/hotswap-template-cache.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/hotswap/hotswap-template-cache.test.ts index bacacfa47..c58e2e558 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/hotswap/hotswap-template-cache.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/hotswap/hotswap-template-cache.test.ts @@ -18,6 +18,9 @@ afterEach(async () => { }); const STACK_NAME = 'MyStack'; +const ENV_A = '111111111111/us-east-1'; +const ENV_B = '222222222222/us-west-2'; +const ENV_A_KEY = '111111111111_us-east-1'; function nestedStackResource(assetPath: string): any { return { @@ -49,8 +52,8 @@ describe('hotswap-template-cache', () => { }, }; - await writeHotswapTemplateCache(assemblyDir, STACK_NAME, rootTemplate, nestedStacks); - const result = await readHotswapTemplateCache(assemblyDir, STACK_NAME, rootTemplate); + await writeHotswapTemplateCache(assemblyDir, STACK_NAME, rootTemplate, nestedStacks, ENV_A); + const result = await readHotswapTemplateCache(assemblyDir, STACK_NAME, rootTemplate, ENV_A); expect(result).toBeDefined(); expect(result!.deployedRootTemplate).toEqual(rootTemplate); @@ -59,7 +62,7 @@ describe('hotswap-template-cache', () => { }); test('read returns undefined when no cache exists', async () => { - const result = await readHotswapTemplateCache(assemblyDir, 'NoSuchStack', {}); + const result = await readHotswapTemplateCache(assemblyDir, 'NoSuchStack', {}, ENV_A); expect(result).toBeUndefined(); }); @@ -76,13 +79,13 @@ describe('hotswap-template-cache', () => { generatedTemplate: originalGenerated, nestedStackTemplates: {}, }, - }); + }, ENV_A); // Modify the asset file on disk to simulate a new synthesis const updatedGenerated: Template = { Resources: { Fn: { Type: 'AWS::Lambda::Function', Properties: { Code: 'v2' } } } }; writeNestedTemplateAsset('nested.template.json', updatedGenerated); - const result = await readHotswapTemplateCache(assemblyDir, STACK_NAME, rootTemplate); + const result = await readHotswapTemplateCache(assemblyDir, STACK_NAME, rootTemplate, ENV_A); expect(result!.nestedStacks.Nested.generatedTemplate).toEqual(updatedGenerated); }); @@ -98,9 +101,9 @@ describe('hotswap-template-cache', () => { generatedTemplate: generated, nestedStackTemplates: {}, }, - }); + }, ENV_A); - const cachedTemplate = await fs.readJson(path.join(assemblyDir, '.hotswap-cache', `${STACK_NAME}.json`)); + const cachedTemplate = await fs.readJson(path.join(assemblyDir, '.hotswap-cache', `${STACK_NAME}.${ENV_A_KEY}.json`)); // The cached deployedTemplate should be what was the generatedTemplate at write time expect(cachedTemplate.nestedStacks.Nested.deployedTemplate).toEqual(generated); }); @@ -124,9 +127,9 @@ describe('hotswap-template-cache', () => { generatedTemplate: nestedGenerated, nestedStackTemplates: {}, }, - }); + }, ENV_A); - const result = await readHotswapTemplateCache(assemblyDir, STACK_NAME, rootTemplate); + const result = await readHotswapTemplateCache(assemblyDir, STACK_NAME, rootTemplate, ENV_A); expect(Object.keys(result!.nestedStacks)).toEqual(['Nested']); }); @@ -155,9 +158,9 @@ describe('hotswap-template-cache', () => { }, }, }, - }); + }, ENV_A); - const result = await readHotswapTemplateCache(assemblyDir, STACK_NAME, rootTemplate); + const result = await readHotswapTemplateCache(assemblyDir, STACK_NAME, rootTemplate, ENV_A); const level1 = result!.nestedStacks.Level1; expect(level1.generatedTemplate).toEqual(level1Template); expect(level1.physicalName).toBe('phys-l1'); @@ -168,15 +171,42 @@ describe('hotswap-template-cache', () => { }); test('invalidateHotswapTemplateCache removes the cache file', async () => { - await writeHotswapTemplateCache(assemblyDir, STACK_NAME, { Resources: { SomeFunc: { Type: 'AWS::Lambda::Function' } } }, {}); - const cacheFile = path.join(assemblyDir, '.hotswap-cache', `${STACK_NAME}.json`); + await writeHotswapTemplateCache(assemblyDir, STACK_NAME, { Resources: { SomeFunc: { Type: 'AWS::Lambda::Function' } } }, {}, ENV_A); + const cacheFile = path.join(assemblyDir, '.hotswap-cache', `${STACK_NAME}.${ENV_A_KEY}.json`); expect(await fs.pathExists(cacheFile)).toBe(true); - await invalidateHotswapTemplateCache(assemblyDir, STACK_NAME); + await invalidateHotswapTemplateCache(assemblyDir, STACK_NAME, ENV_A); expect(await fs.pathExists(cacheFile)).toBe(false); }); test('invalidateHotswapTemplateCache is a no-op when cache does not exist', async () => { // Should not throw - expect(await invalidateHotswapTemplateCache(assemblyDir, 'NonExistent')).toBe(undefined); + expect(await invalidateHotswapTemplateCache(assemblyDir, 'NonExistent', ENV_A)).toBe(undefined); + }); + + test('cache from one environment is never returned for a different environment (regression)', async () => { + // Regression test: the cache used to be keyed only by assemblyDir + stackName, + // so switching accounts/regions between hotswap-only deployments (no full + // CloudFormation deploy in between to invalidate it) would silently reuse + // stale deployedRootTemplate/physicalName data from a *different* environment. + const rootTemplate: Template = { Resources: { Fn: { Type: 'AWS::Lambda::Function', Properties: { Code: 'account-a-code' } } } }; + const nestedStacks: Record = {}; + + // Simulate a successful hotswap deployment against environment A. + await writeHotswapTemplateCache(assemblyDir, STACK_NAME, rootTemplate, nestedStacks, ENV_A); + + // A hotswap deployment for the *same* stack name and assembly directory, but + // targeting a completely different account/region (e.g. after switching + // AWS_PROFILE), must not see environment A's cached state. + const resultForEnvB = await readHotswapTemplateCache(assemblyDir, STACK_NAME, rootTemplate, ENV_B); + expect(resultForEnvB).toBeUndefined(); + + // Reading back with the original environment must still return the cached state. + const resultForEnvA = await readHotswapTemplateCache(assemblyDir, STACK_NAME, rootTemplate, ENV_A); + expect(resultForEnvA).toBeDefined(); + expect(resultForEnvA!.deployedRootTemplate).toEqual(rootTemplate); + + // Invalidating environment B's (nonexistent) cache must not remove environment A's. + await invalidateHotswapTemplateCache(assemblyDir, STACK_NAME, ENV_B); + expect(await readHotswapTemplateCache(assemblyDir, STACK_NAME, rootTemplate, ENV_A)).toBeDefined(); }); });