Skip to content
Merged
18 changes: 17 additions & 1 deletion packages/@aws-cdk-testing/cli-integ/lib/regions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,26 @@ export function allBut(exclude: string[]): string[] {
}

/**
* Regions that support CloudFormation Stack Refactoring
* Regions that don't support CloudFormation Stack Refactoring
*/
export const STACK_REFACTORING_REGIONS = allBut([
'ap-southeast-5',
'ap-southeast-7',
'mx-central-1',
]);

/**
* Regions that don't support AWS::Bedrock::Agent
*/
export const BEDROCK_AGENT_REGIONS = allBut([
'af-south-1',
'ap-east-1',
'ap-southeast-3',
'ap-southeast-4',
'ap-southeast-5',
'ap-southeast-7',
'ca-west-1',
'il-central-1',
'mx-central-1',
'us-west-1',
]);
Original file line number Diff line number Diff line change
Expand Up @@ -748,8 +748,8 @@ class CloudControlHotswapStack extends cdk.Stack {
agentName: `${cdk.Stack.of(this).stackName}-agent`.substring(0, 40),
agentResourceRoleArn: agentRole.roleArn,
instruction: process.env.DYNAMIC_CC_PROPERTY_VALUE
? `You help query the table ${table.tableName}. ${process.env.DYNAMIC_CC_PROPERTY_VALUE}`
: `You help query the table ${table.tableName}. original`,
? `You help query the table ${table.tableName}. ${process.env.DYNAMIC_CC_PROPERTY_VALUE}. ${process.env.DYNAMIC_CC_PROPERTY_VALUE_2 ?? 'original'}`
: `You help query the table ${table.tableName}. original. original`,
foundationModel: 'anthropic.claude-instant-v1',
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import * as fs from 'fs';
import * as path from 'path';
import { integTest, withDefaultFixture } from '../../../lib';
import { BEDROCK_AGENT_REGIONS } from '../../../lib/regions';

jest.setTimeout(5 * 60 * 1000);

integTest(
'hotswap deployment caches template and uses it for subsequent hotswaps',
withDefaultFixture(async (fixture) => {
const stackName = 'cc-hotswap';

// GIVEN - initial full deploy
await fixture.cdkDeploy(stackName, {
captureStderr: false,
modEnv: {
DYNAMIC_CC_PROPERTY_VALUE: 'v1',
DYNAMIC_CC_PROPERTY_VALUE_2: 'v1',
},
});

// WHEN - first hotswap changes ALL resources, creates the cache
await fixture.cdkDeploy(stackName, {
options: ['--hotswap'],
captureStderr: false,
modEnv: {
DYNAMIC_CC_PROPERTY_VALUE: 'v2',
DYNAMIC_CC_PROPERTY_VALUE_2: 'v2',
},
});

const fullStackName = fixture.fullStackName(stackName);
const cacheFile = path.join(fixture.integTestDir, 'cdk.out', '.hotswap-cache', `${fullStackName}.json`);
expect(fs.existsSync(cacheFile)).toBe(true);

// THEN - second hotswap changes only the Agent (via DYNAMIC_CC_PROPERTY_VALUE_2).
// If the cache is used, the diff is against the cached template, only 1 resource should be hotswapped.
const deployOutput = await fixture.cdkDeploy(stackName, {
options: ['--hotswap'],
captureStderr: true,
onlyStderr: true,
modEnv: {
DYNAMIC_CC_PROPERTY_VALUE: 'v2', // unchanged from first hotswap
DYNAMIC_CC_PROPERTY_VALUE_2: 'v3',
},
});

// should only see one hotswapped message in output
const hotswapCount = (deployOutput.match(/hotswapped!/g) || []).length;
expect(hotswapCount).toBe(1);
}, { aws: { regions: BEDROCK_AGENT_REGIONS } }),
);

integTest(
'hotswap cache is invalidated after a full CloudFormation deployment',
withDefaultFixture(async (fixture) => {
// GIVEN - deploy then hotswap to create cache
await fixture.cdkDeploy('lambda-hotswap', {
captureStderr: false,
modEnv: {
DYNAMIC_LAMBDA_PROPERTY_VALUE: 'v1',
},
});

await fixture.cdkDeploy('lambda-hotswap', {
options: ['--hotswap'],
captureStderr: false,
modEnv: {
DYNAMIC_LAMBDA_PROPERTY_VALUE: 'v2',
},
});

const stackName = fixture.fullStackName('lambda-hotswap');
const cacheFile = path.join(fixture.integTestDir, 'cdk.out', '.hotswap-cache', `${stackName}.json`);
expect(fs.existsSync(cacheFile)).toBe(true);

// WHEN - full CFN deploy
await fixture.cdkDeploy('lambda-hotswap', {
captureStderr: false,
modEnv: {
DYNAMIC_LAMBDA_PROPERTY_VALUE: 'v3',
},
});

// THEN - cache should be invalidated
expect(fs.existsSync(cacheFile)).toBe(false);
}),
);
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DescribeStacksCommand } from '@aws-sdk/client-cloudformation';
import { integTest, withDefaultFixture } from '../../../lib';
import { BEDROCK_AGENT_REGIONS } from '../../../lib/regions';

jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime

Expand Down Expand Up @@ -46,5 +47,5 @@ integTest(
expect(queueUrl).toBeDefined();
expect(agentName).toBeDefined();
expect(ruleName).toBeDefined();
}),
}, { aws: { regions: BEDROCK_AGENT_REGIONS } }),
);
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type { EnvironmentResources, StringWithoutPlaceholders } from '../environ
import { EnvironmentResourcesRegistry } from '../environment';
import { HotswapPropertyOverrides, ICON, createHotswapPropertyOverrides } from '../hotswap/common';
import { tryHotswapDeployment } from '../hotswap/hotswap-deployments';
import { invalidateHotswapTemplateCache } from '../hotswap/hotswap-template-cache';
import type { IoHelper } from '../io/private';
import type { ResourcesToImport } from '../resource-import';
import { StackActivityMonitor } from '../stack-events';
Expand Down Expand Up @@ -379,6 +380,9 @@ class FullCloudFormationDeployment {
public async performDeployment(): Promise<DeployStackResult> {
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);

if (deploymentMethod.method === 'direct' && this.options.resourcesToImport) {
throw new ToolkitError('ImportRequiresChangeSet', 'Importing resources requires a changeset deployment');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import type * as cxapi from '@aws-cdk/cloud-assembly-api';
import * as cfn_diff from '@aws-cdk/cloudformation-diff';
import type { WaiterResult } from '@smithy/util-waiter';
import * as chalk from 'chalk';
import type { AffectedResource, HotswapResult, ResourceSubject, ResourceChange, NonHotswappableChange } from '../../payloads';
import { NonHotswappableReason } from '../../payloads';
import { formatErrorMessage } from '../../util';
import type { SDK, SdkProvider } from '../aws-auth/private';
Expand All @@ -22,11 +21,13 @@ import {
nonHotswappableResource,
} from './common';
import { isHotswappableEcsServiceChange } from './ecs-services';
import { readHotswapTemplateCache, writeHotswapTemplateCache } from './hotswap-template-cache';
import { isHotswappableLambdaFunctionChange } from './lambda-functions';
import {
skipChangeForS3DeployCustomResourcePolicy,
isHotswappableS3BucketDeploymentChange,
} from './s3-bucket-deployments';
import type { AffectedResource, HotswapResult, ResourceSubject, ResourceChange, NonHotswappableChange } from '../../payloads';
import { ToolkitError } from '../../toolkit/toolkit-error';
import type { SuccessfulDeployStackResult } from '../deployments';
import { IO, SPAN } from '../io/private';
Expand Down Expand Up @@ -162,7 +163,10 @@ async function hotswapDeployment(
// it assumes the bootstrap deploy Role, which doesn't have permissions to update Lambda functions
const sdk = (await sdkProvider.forEnvironment(resolvedEnv, Mode.ForWriting)).sdk;

const currentTemplate = await loadCurrentTemplateWithNestedStacks(stack, sdk);
// 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 currentTemplate = hotswapCache ?? await loadCurrentTemplateWithNestedStacks(stack, sdk);

const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({
stackArtifact: stack,
Expand Down Expand Up @@ -211,6 +215,8 @@ async function hotswapDeployment(
let error: Error | undefined;
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);
} catch (e: any) {
error = e;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import * as path from 'path';
import * as fs from 'fs-extra';
import type { NestedStackTemplates, RootTemplateWithNestedStacks, Template } from '../cloudformation';

const CACHE_DIR = '.hotswap-cache';

/**
* Hotswap state we persist to disk.
* Cache deployed templates and physical names - information CFN would have provided.
* The generatedTemplate is always read fresh from the cloud assembly.
*/
interface CachedNestedStack {
readonly physicalName: string | undefined;
readonly deployedTemplate: Template;
readonly nestedStacks: { [logicalId: string]: CachedNestedStack };
}

interface CachedHotswapState {
readonly deployedRootTemplate: Template;
readonly nestedStacks: { [logicalId: string]: CachedNestedStack };
}

function cachePath(assemblyDir: string, stackName: string): string {
return path.join(assemblyDir, CACHE_DIR, `${stackName}.json`);
}

/**
* Read the cached hotswap state and hydrate it into a full
* RootTemplateWithNestedStacks by reading fresh generatedTemplates from disk.
* Returns undefined if no cache exists.
*/
export async function readHotswapTemplateCache(
assemblyDir: string,
stackName: string,
newRootTemplate: Template,
): Promise<RootTemplateWithNestedStacks | undefined> {
const cachedPath = cachePath(assemblyDir, stackName);
try {
const cached = await fs.readJson(cachedPath);

return {
deployedRootTemplate: cached.deployedRootTemplate,
nestedStacks: hydrateNestedStacks(assemblyDir, newRootTemplate, cached.nestedStacks),
};
} catch {
return undefined;
}
}

/**
* Cache the current hotswap state after a successful deployment.
* The synthesized templates become the new "deployed" baseline.
*/
export async function writeHotswapTemplateCache(
assemblyDir: string,
stackName: string,
rootTemplate: Template,
nestedStacks: { [logicalId: string]: NestedStackTemplates },
): Promise<void> {
const state: CachedHotswapState = {
deployedRootTemplate: rootTemplate,
nestedStacks: toCachedNestedStacks(nestedStacks),
};
const cachedPath = cachePath(assemblyDir, stackName);
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 });
}

/**
* Convert NestedStackTemplates to the minimal cached form.
* After a successful hotswap, generatedTemplate is considered the deployed state.
*/
function toCachedNestedStacks(
nestedStacks: { [logicalId: string]: NestedStackTemplates },
): { [logicalId: string]: CachedNestedStack } {
const result: { [logicalId: string]: CachedNestedStack } = {};
for (const [logicalId, ns] of Object.entries(nestedStacks)) {
result[logicalId] = {
physicalName: ns.physicalName,
deployedTemplate: ns.generatedTemplate,
nestedStacks: toCachedNestedStacks(ns.nestedStackTemplates),
};
}
return result;
}

/**
* Hydrate cached nested stacks into full NestedStackTemplates by reading
* the freshly synthesized generatedTemplate from the cloud assembly on disk.
*
* Only nested stacks present in the cache are hydrated. New nested stacks
* (not in cache) are left out so the root-level diff sees them as resource
* additions and routes them through the normal non-hotswappable path.
*/
function hydrateNestedStacks(
assemblyDir: string,
parentTemplate: Template,
cachedNestedStacks: { [logicalId: string]: CachedNestedStack },
): { [logicalId: string]: NestedStackTemplates } {
const result: { [logicalId: string]: NestedStackTemplates } = {};

for (const [logicalId, resource] of Object.entries(parentTemplate.Resources ?? {})) {
const res = resource as any;
const assetPath = res?.Metadata?.['aws:asset:path'];
// we only care about surfacing nested stacks, skip other resource types
if (res?.Type !== 'AWS::CloudFormation::Stack' || !assetPath) {
continue;
}

const cached = cachedNestedStacks[logicalId];
if (!cached) {
// New nested stack — skip so it's treated as a resource addition
continue;
}

const generatedTemplate: Template = JSON.parse(
fs.readFileSync(path.join(assemblyDir, assetPath), 'utf-8'),
);

result[logicalId] = {
physicalName: cached.physicalName,
deployedTemplate: cached.deployedTemplate,
generatedTemplate,
nestedStackTemplates: hydrateNestedStacks(assemblyDir, generatedTemplate, cached.nestedStacks ?? {}),
};
}

return result;
}
Loading
Loading