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
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}

/**
Expand All @@ -33,8 +41,9 @@ export async function readHotswapTemplateCache(
assemblyDir: string,
stackName: string,
newRootTemplate: Template,
environment: string,
): Promise<RootTemplateWithNestedStacks | undefined> {
const cachedPath = cachePath(assemblyDir, stackName);
const cachedPath = cachePath(assemblyDir, stackName, environment);
try {
const cached = await fs.readJson(cachedPath);

Expand All @@ -56,21 +65,22 @@ export async function writeHotswapTemplateCache(
stackName: string,
rootTemplate: Template,
nestedStacks: { [logicalId: string]: NestedStackTemplates },
environment: string,
): Promise<void> {
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 });
}

/**
* Invalidate the hotswap cache for a stack (e.g. after a full CloudFormation deploy).
*/
export async function invalidateHotswapTemplateCache(assemblyDir: string, stackName: string): Promise<void> {
await fs.rm(cachePath(assemblyDir, stackName), { force: true });
export async function invalidateHotswapTemplateCache(assemblyDir: string, stackName: string, environment: string): Promise<void> {
await fs.rm(cachePath(assemblyDir, stackName, environment), { force: true });
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] };
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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();
});

Expand All @@ -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);
});

Expand All @@ -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);
});
Expand All @@ -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']);
});

Expand Down Expand Up @@ -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');
Expand All @@ -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<string, NestedStackTemplates> = {};

// 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();
});
});
Loading