diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js index 954ced7ef..0b0465603 100755 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js @@ -33,6 +33,9 @@ if (process.env.PACKAGE_LAYOUT_VERSION === '1') { aws_ecr_assets: docker, aws_appsync: appsync, aws_bedrockagentcore: bedrockagentcore, + aws_events: events, + aws_dynamodb: dynamodb, + aws_bedrock: bedrock, Stack } = require('aws-cdk-lib'); } @@ -711,6 +714,59 @@ class AgentCoreHotswapStack extends cdk.Stack { } } +class CloudControlHotswapStack extends cdk.Stack { + constructor(parent, id, props) { + super(parent, id, props); + + // DynamoDB table — a dependency that other resources reference + const table = new dynamodb.Table(this, 'Table', { + partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING }, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + // ElastiCache serverless cache — another dependency resource + const cache = new cdk.CfnResource(this, 'Cache', { + type: 'AWS::ElastiCache::ServerlessCache', + properties: { + Engine: 'valkey', + ServerlessCacheName: `${cdk.Stack.of(this).stackName}-cache`.substring(0, 40).toLowerCase(), + }, + }); + + // SQS Queue — hotswapped via CCAPI, references the DynamoDB table ARN in its tag + const queue = new sqs.Queue(this, 'Queue', { + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + cdk.Tags.of(queue).add('DynamoTableArn', table.tableArn); + cdk.Tags.of(queue).add('DynamicTag', process.env.DYNAMIC_CC_PROPERTY_VALUE ?? 'original'); + + // Bedrock Agent — hotswapped via CCAPI, references the DynamoDB table name + const agentRole = new iam.Role(this, 'AgentRole', { + assumedBy: new iam.ServicePrincipal('bedrock.amazonaws.com'), + }); + const agent = new bedrock.CfnAgent(this, 'Agent', { + 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`, + foundationModel: 'anthropic.claude-instant-v1', + }); + + // Events Rule — hotswapped via CCAPI, references the ElastiCache cache ARN + const rule = new events.Rule(this, 'Rule', { + schedule: events.Schedule.rate(cdk.Duration.hours(1)), + description: process.env.DYNAMIC_CC_PROPERTY_VALUE + ? `Rule for cache ${cache.getAtt('ARN')}. ${process.env.DYNAMIC_CC_PROPERTY_VALUE}` + : `Rule for cache ${cache.getAtt('ARN')}. original`, + }); + + new cdk.CfnOutput(this, 'QueueUrl', { value: queue.queueUrl }); + new cdk.CfnOutput(this, 'AgentName', { value: agent.ref }); + new cdk.CfnOutput(this, 'RuleName', { value: rule.ruleName }); + } +} + class DockerStack extends cdk.Stack { constructor(parent, id, props) { super(parent, id, props); @@ -990,6 +1046,7 @@ switch (stackSet) { new LambdaHotswapStack(app, `${stackPrefix}-lambda-hotswap`); new EcsHotswapStack(app, `${stackPrefix}-ecs-hotswap`); new AgentCoreHotswapStack(app, `${stackPrefix}-agentcore-hotswap`); + new CloudControlHotswapStack(app, `${stackPrefix}-cc-hotswap`); new AppSyncHotswapStack(app, `${stackPrefix}-appsync-hotswap`); new DockerStack(app, `${stackPrefix}-docker`); new DockerInUseStack(app, `${stackPrefix}-docker-in-use`); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/hotswap/cdk-hotswap-deployment-supports-cloudcontrol-based-resources.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/hotswap/cdk-hotswap-deployment-supports-cloudcontrol-based-resources.integtest.ts new file mode 100644 index 000000000..b09fa7536 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/hotswap/cdk-hotswap-deployment-supports-cloudcontrol-based-resources.integtest.ts @@ -0,0 +1,50 @@ +import { DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; +import { integTest, withDefaultFixture } from '../../../lib'; + +jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime + +integTest( + 'hotswap deployment supports CloudControl-based resources with attribute resolution', + withDefaultFixture(async (fixture) => { + // GIVEN + const stackName = 'cc-hotswap'; + await fixture.cdkDeploy(stackName, { + captureStderr: false, + modEnv: { + DYNAMIC_CC_PROPERTY_VALUE: 'original value', + }, + }); + + // WHEN + const deployOutput = await fixture.cdkDeploy(stackName, { + options: ['--hotswap'], + captureStderr: true, + onlyStderr: true, + modEnv: { + DYNAMIC_CC_PROPERTY_VALUE: 'new value', + }, + }); + + const response = await fixture.aws.cloudFormation.send( + new DescribeStacksCommand({ + StackName: fixture.fullStackName(stackName), + }), + ); + + const queueUrl = response.Stacks?.[0].Outputs?.find((output) => output.OutputKey === 'QueueUrl')?.OutputValue; + const agentName = response.Stacks?.[0].Outputs?.find((output) => output.OutputKey === 'AgentName')?.OutputValue; + const ruleName = response.Stacks?.[0].Outputs?.find((output) => output.OutputKey === 'RuleName')?.OutputValue; + + // THEN + + // The deployment should not trigger a full deployment, thus the stack's status must remain + // "CREATE_COMPLETE" + expect(response.Stacks?.[0].StackStatus).toEqual('CREATE_COMPLETE'); + // Verify hotswap was used + expect(deployOutput).toMatch(/hotswapped!/); + // Verify all three CCAPI-based resources were hotswapped + expect(queueUrl).toBeDefined(); + expect(agentName).toBeDefined(); + expect(ruleName).toBeDefined(); + }), +); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts b/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts index 200630b45..d24e3f062 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts @@ -38,8 +38,12 @@ import type { GetResourceCommandOutput, ListResourcesCommandInput, ListResourcesCommandOutput, + UpdateResourceCommandInput, + UpdateResourceCommandOutput, } from '@aws-sdk/client-cloudcontrol'; import { + UpdateResourceCommand, + CloudControlClient, GetResourceCommand, ListResourcesCommand, @@ -115,6 +119,8 @@ import type { ExecuteStackRefactorCommandOutput, DescribeEventsCommandOutput, DescribeEventsCommandInput, + DescribeTypeCommandInput, + DescribeTypeCommandOutput, GetHookResultCommandInput, GetHookResultCommandOutput, } from '@aws-sdk/client-cloudformation'; @@ -159,6 +165,7 @@ import { DetectStackResourceDriftCommand, waitUntilStackRefactorCreateComplete, waitUntilStackRefactorExecuteComplete, + DescribeTypeCommand, GetHookResultCommand, } from '@aws-sdk/client-cloudformation'; import type { OperationEvent } from '@aws-sdk/client-cloudformation/dist-types/models/models_0'; @@ -450,6 +457,7 @@ export interface IBedrockAgentCoreControlClient { export interface ICloudControlClient { listResources(input: ListResourcesCommandInput): Promise; getResource(input: GetResourceCommandInput): Promise; + updateResource(input: UpdateResourceCommandInput): Promise; } export interface ICloudFormationClient { @@ -472,6 +480,7 @@ export interface ICloudFormationClient { describeStackResources(input: DescribeStackResourcesCommandInput): Promise; detectStackDrift(input: DetectStackDriftCommandInput): Promise; detectStackResourceDrift(input: DetectStackResourceDriftCommandInput): Promise; + describeType(input: DescribeTypeCommandInput): Promise; executeChangeSet(input: ExecuteChangeSetCommandInput): Promise; getGeneratedTemplate(input: GetGeneratedTemplateCommandInput): Promise; getTemplate(input: GetTemplateCommandInput): Promise; @@ -578,7 +587,7 @@ export interface ILambdaClient { input: UpdateFunctionConfigurationCommandInput, ): Promise; // Waiters - waitUntilFunctionUpdated(delaySeconds: number, input: UpdateFunctionConfigurationCommandInput): Promise; + waitUntilFunctionUpdated(minDelaySeconds: number, maxDelaySeconds: number, input: UpdateFunctionConfigurationCommandInput): Promise; } export interface IRoute53Client { @@ -719,6 +728,8 @@ export class SDK { client.send(new ListResourcesCommand(input)), getResource: (input: GetResourceCommandInput): Promise => client.send(new GetResourceCommand(input)), + updateResource: (input: UpdateResourceCommandInput): Promise => + client.send(new UpdateResourceCommand(input)), }; } @@ -766,6 +777,8 @@ export class SDK { client.send(new DescribeStacksCommand(input)), describeStackResources: (input: DescribeStackResourcesCommandInput): Promise => client.send(new DescribeStackResourcesCommand(input)), + describeType: (input: DescribeTypeCommandInput): Promise => + client.send(new DescribeTypeCommand(input)), executeChangeSet: (input: ExecuteChangeSetCommandInput): Promise => client.send(new ExecuteChangeSetCommand(input)), getGeneratedTemplate: (input: GetGeneratedTemplateCommandInput): Promise => @@ -1020,15 +1033,16 @@ export class SDK { client.send(new UpdateFunctionConfigurationCommand(input)), // Waiters waitUntilFunctionUpdated: ( - delaySeconds: number, + minDelaySeconds: number, + maxDelaySeconds: number, input: UpdateFunctionConfigurationCommandInput, ): Promise => { return waitUntilFunctionUpdatedV2( { client, - maxDelay: delaySeconds, - minDelay: delaySeconds, - maxWaitTime: delaySeconds * 60, + maxDelay: maxDelaySeconds, + minDelay: minDelaySeconds, + maxWaitTime: maxDelaySeconds * 60, }, input, ); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/evaluate-cloudformation-template.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/evaluate-cloudformation-template.ts index 4eb541276..4f2310b47 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/evaluate-cloudformation-template.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/evaluate-cloudformation-template.ts @@ -3,7 +3,7 @@ import type { Export, ListExportsCommandOutput, StackResourceSummary } from '@aw import type { NestedStackTemplates } from './nested-stack-helpers'; import type { Template } from './stack-helpers'; import { ToolkitError } from '../../toolkit/toolkit-error'; -import type { SDK } from '../aws-auth/private'; +import type { ICloudControlClient, SDK } from '../aws-auth/private'; import type { ResourceMetadata } from '../resource-metadata'; import { resourceMetadata } from '../resource-metadata'; @@ -118,6 +118,7 @@ export class EvaluateCloudFormationTemplate { private readonly lookupExport: LookupExport; private cachedUrlSuffix: string | undefined; + private _cloudControl: ICloudControlClient | undefined; constructor(props: EvaluateCloudFormationTemplateProps) { this.stackArtifact = props.stackArtifact; @@ -146,6 +147,13 @@ export class EvaluateCloudFormationTemplate { this.lookupExport = new LazyLookupExport(this.sdk); } + private get cloudControl(): ICloudControlClient { + if (!this._cloudControl) { + this._cloudControl = this.sdk.cloudControl(); + } + return this._cloudControl; + } + // clones current EvaluateCloudFormationTemplate object, but updates the stack name public async createNestedEvaluateCloudFormationTemplate( stackName: string, @@ -316,6 +324,44 @@ export class EvaluateCloudFormationTemplate { return cfnExpression; } + /** + * Best-effort attempt to construct the Cloud Control API primary identifier for a resource. + * + * Cloud Control compound identifiers are pipe-delimited, ordered by the `primaryIdentifier` + * array in the resource type schema (e.g. `DatabaseName|TableName` for `AWS::Glue::Table`). + * + * CloudFormation's `PhysicalResourceId` (the `physicalId` parameter here) is the `Ref` return + * value for the resource, which is resource-type-specific and may not correspond to the Cloud + * Control Primary Identifier. + * + * For properties present in the template, we resolve them directly. For properties NOT in the + * template (e.g. service-generated read-only values), we fall back to `physicalId`. This is + * only correct when: + * + * - Exactly one property in the identifier is missing from the template, AND + * - That property happens to be the one that `Ref` returns for this resource type. + * + * If multiple properties are missing, `physicalId` is reused for all of them. + */ + public async evaluateCloudControlIdentifier(logicalId: string, resourceType: string, physicalId: string): Promise { + if (resourceType in RESOURCE_TYPE_PRIMARY_IDENTIFIERS) { + const primaryProps = RESOURCE_TYPE_PRIMARY_IDENTIFIERS[resourceType]; + const parts: string[] = []; + + for (const propName of primaryProps) { + const templateVal = this.getResourceProperty(logicalId, propName); + if (templateVal) { + parts.push(await this.evaluateCfnExpression(templateVal)); + } else { + // Not in template — assume this is the Ref return value. See doc comment for caveats. + parts.push(physicalId); + } + } + return parts.join('|'); + } + return physicalId; + } + public getResourceProperty(logicalId: string, propertyName: string): any { return this.template.Resources?.[logicalId]?.Properties?.[propertyName]; } @@ -435,7 +481,7 @@ export class EvaluateCloudFormationTemplate { return undefined; } - private formatResourceAttribute(resource: StackResourceSummary, attribute: string | undefined): string | undefined { + private async formatResourceAttribute(resource: StackResourceSummary, attribute: string | undefined): Promise { const physicalId = resource.PhysicalResourceId; // no attribute means Ref expression, for which we use the physical ID directly @@ -444,29 +490,43 @@ export class EvaluateCloudFormationTemplate { } const resourceTypeFormats = RESOURCE_TYPE_ATTRIBUTES_FORMATS[resource.ResourceType!]; - if (!resourceTypeFormats) { - throw new CfnEvaluationException( - `We don't support attributes of the '${resource.ResourceType}' resource. This is a CDK limitation. ` + - 'Please report it at https://github.com/aws/aws-cdk/issues/new/choose', - ); + if (resourceTypeFormats) { + const attributeFormatFunc = resourceTypeFormats[attribute]; + if (attributeFormatFunc) { + const service = this.getServiceOfResource(resource); + const resourceTypeArnPart = this.getResourceTypeArnPartOfResource(resource); + return attributeFormatFunc({ + partition: this.partition, + service, + region: this.region, + account: this.account, + resourceType: resourceTypeArnPart, + resourceName: physicalId!, + }); + } } - const attributeFmtFunc = resourceTypeFormats[attribute]; - if (!attributeFmtFunc) { - throw new CfnEvaluationException( - `We don't support the '${attribute}' attribute of the '${resource.ResourceType}' resource. This is a CDK limitation. ` + - 'Please report it at https://github.com/aws/aws-cdk/issues/new/choose', - ); + + try { + const identifier = await this.evaluateCloudControlIdentifier(resource.LogicalResourceId!, resource.ResourceType!, physicalId!); + + const response = await this.cloudControl.getResource({ + TypeName: resource.ResourceType!, + Identifier: identifier, + }); + const props = JSON.parse(response.ResourceDescription?.Properties ?? '{}'); + if (attribute in props) { + return props[attribute]; + } + } catch (error: any) { + // Cloud Control lookup failed — fall through to the error below + throw new CfnEvaluationException(`Could not find '${attribute}' attribute of the '${resource.ResourceType}' resource because` + + `an error occured while attempting to retrieve the information: '${error.name}:${error.message}'`); } - const service = this.getServiceOfResource(resource); - const resourceTypeArnPart = this.getResourceTypeArnPartOfResource(resource); - return attributeFmtFunc({ - partition: this.partition, - service, - region: this.region, - account: this.account, - resourceType: resourceTypeArnPart, - resourceName: physicalId!, - }); + + throw new CfnEvaluationException( + `We don't support the '${attribute}' attribute of the '${resource.ResourceType}' resource. This is a CDK limitation. ` + + 'Please report it at https://github.com/aws/aws-cdk/issues/new/choose', + ); } private getServiceOfResource(resource: StackResourceSummary): string { @@ -492,6 +552,84 @@ interface ArnParts { readonly resourceName: string; } +/** + * Resources where the CloudFormation physical resource ID ({ Ref }) disagrees + * with the Cloud Control API primary identifier. + * + * Maps resource type to the CC primary identifier property names. + * For resources NOT in this map, the physical resource ID can be used + * directly as the CC primary identifier. + */ +export const RESOURCE_TYPE_PRIMARY_IDENTIFIERS: { [type: string]: string[] } = { + 'AWS::ApiGateway::Authorizer': ['RestApiId', 'AuthorizerId'], + 'AWS::ApiGateway::Deployment': ['DeploymentId', 'RestApiId'], + 'AWS::ApiGateway::Model': ['RestApiId', 'Name'], + 'AWS::ApiGateway::RequestValidator': ['RestApiId', 'RequestValidatorId'], + 'AWS::ApiGateway::DocumentationPart': ['DocumentationPartId', 'RestApiId'], + 'AWS::ApiGateway::Resource': ['RestApiId', 'ResourceId'], + 'AWS::ApiGateway::Stage': ['RestApiId', 'StageName'], + 'AWS::ApiGatewayV2::ApiMapping': ['ApiMappingId', 'DomainName'], + 'AWS::ApiGatewayV2::Authorizer': ['AuthorizerId', 'ApiId'], + 'AWS::ApiGatewayV2::Deployment': ['ApiId', 'DeploymentId'], + 'AWS::ApiGatewayV2::Integration': ['ApiId', 'IntegrationId'], + 'AWS::ApiGatewayV2::IntegrationResponse': ['ApiId', 'IntegrationId', 'IntegrationResponseId'], + 'AWS::ApiGatewayV2::Model': ['ApiId', 'ModelId'], + 'AWS::ApiGatewayV2::Route': ['ApiId', 'RouteId'], + 'AWS::ApiGatewayV2::RouteResponse': ['ApiId', 'RouteId', 'RouteResponseId'], + 'AWS::ApiGatewayV2::Stage': ['Id'], + 'AWS::AppConfig::ConfigurationProfile': ['ApplicationId', 'ConfigurationProfileId'], + 'AWS::AppConfig::Environment': ['ApplicationId', 'EnvironmentId'], + 'AWS::AppConfig::HostedConfigurationVersion': ['ApplicationId', 'ConfigurationProfileId', 'VersionNumber'], + 'AWS::AppMesh::GatewayRoute': ['Id'], + 'AWS::AppMesh::Mesh': ['Id'], + 'AWS::AppMesh::Route': ['Id'], + 'AWS::AppMesh::VirtualGateway': ['Id'], + 'AWS::AppMesh::VirtualNode': ['Id'], + 'AWS::AppMesh::VirtualRouter': ['Id'], + 'AWS::AppMesh::VirtualService': ['Id'], + 'AWS::AppSync::ApiKey': ['ApiKeyId'], + 'AWS::AppSync::GraphQLApi': ['ApiId'], + 'AWS::Batch::JobDefinition': ['JobDefinitionName'], + 'AWS::CloudWatch::InsightRule': ['Id'], + 'AWS::CodeBuild::Project': ['Id'], + 'AWS::CodeBuild::ReportGroup': ['Id'], + 'AWS::CodeDeploy::DeploymentGroup': ['ApplicationName', 'DeploymentGroupName'], + 'AWS::CodePipeline::Webhook': ['Id'], + 'AWS::Cognito::UserPoolClient': ['UserPoolId', 'ClientId'], + 'AWS::Cognito::UserPoolDomain': ['UserPoolId', 'Domain'], + 'AWS::Cognito::UserPoolGroup': ['UserPoolId', 'GroupName'], + 'AWS::Cognito::UserPoolIdentityProvider': ['UserPoolId', 'ProviderName'], + 'AWS::Cognito::UserPoolResourceServer': ['UserPoolId', 'Identifier'], + 'AWS::Cognito::UserPoolUser': ['UserPoolId', 'Username'], + 'AWS::DAX::Cluster': ['Id'], + 'AWS::DAX::ParameterGroup': ['Id'], + 'AWS::DAX::SubnetGroup': ['Id'], + 'AWS::DMS::EventSubscription': ['Id'], + 'AWS::EC2::EIP': ['PublicIp', 'AllocationId'], + 'AWS::ECS::Service': ['ServiceArn', 'Cluster'], + 'AWS::ElastiCache::CacheCluster': ['Id'], + 'AWS::ElasticLoadBalancing::LoadBalancer': ['Id'], + 'AWS::ElastiCache::User': ['UserId'], + 'AWS::Elasticsearch::Domain': ['Id'], + 'AWS::Events::Rule': ['Arn'], + 'AWS::EventSchemas::RegistryPolicy': ['Id'], + 'AWS::Glue::DevEndpoint': ['Id'], + 'AWS::Glue::Workflow': ['Id'], + 'AWS::IoT::Policy': ['Id'], + 'AWS::Logs::LogStream': ['LogGroupName', 'LogStreamName'], + 'AWS::Logs::SubscriptionFilter': ['FilterName', 'LogGroupName'], + 'AWS::MediaConvert::JobTemplate': ['Id'], + 'AWS::MediaConvert::Preset': ['Id'], + 'AWS::MediaConvert::Queue': ['Id'], + 'AWS::Route53::RecordSet': ['Id'], + 'AWS::SageMaker::Device': ['Device/DeviceName'], + 'AWS::SecretsManager::ResourcePolicy': ['Id'], + 'AWS::SecretsManager::SecretTargetAttachment': ['Id'], + 'AWS::SES::ReceiptRuleSet': ['Id'], + 'AWS::SSM::MaintenanceWindowTarget': ['WindowId', 'WindowTargetId'], + 'AWS::SSM::MaintenanceWindowTask': ['WindowId', 'WindowTaskId'], +}; + /** * Usually, we deduce the names of the service and the resource type used to format the ARN from the CloudFormation resource type. * For a CFN type like AWS::Service::ResourceType, the second segment becomes the service name, and the third the resource type diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/bedrock-agentcore-runtimes.ts b/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/bedrock-agentcore-runtimes.ts deleted file mode 100644 index 21379c11a..000000000 --- a/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/bedrock-agentcore-runtimes.ts +++ /dev/null @@ -1,237 +0,0 @@ -import type { PropertyDifference } from '@aws-cdk/cloudformation-diff'; -import type { - AgentRuntimeArtifact as SdkAgentRuntimeArtifact, - AgentManagedRuntimeType, -} from '@aws-sdk/client-bedrock-agentcore-control'; -import type { HotswapChange } from './common'; -import { classifyChanges } from './common'; -import type { ResourceChange } from '../../payloads/hotswap'; -import { ToolkitError } from '../../toolkit/toolkit-error'; -import type { SDK } from '../aws-auth/private'; -import type { EvaluateCloudFormationTemplate } from '../cloudformation'; - -export async function isHotswappableBedrockAgentCoreRuntimeChange( - logicalId: string, - change: ResourceChange, - evaluateCfnTemplate: EvaluateCloudFormationTemplate, -): Promise { - if (change.newValue.Type !== 'AWS::BedrockAgentCore::Runtime') { - return []; - } - - const ret: HotswapChange[] = []; - const classifiedChanges = classifyChanges(change, [ - 'AgentRuntimeArtifact', - 'EnvironmentVariables', - 'Description', - ]); - classifiedChanges.reportNonHotswappablePropertyChanges(ret); - - const namesOfHotswappableChanges = Object.keys(classifiedChanges.hotswappableProps); - if (namesOfHotswappableChanges.length === 0) { - return ret; - } - - const agentRuntimeId = await evaluateCfnTemplate.findPhysicalNameFor(logicalId); - if (!agentRuntimeId) { - return ret; - } - - const runtimeChange = await evaluateBedrockAgentCoreRuntimeProps( - classifiedChanges.hotswappableProps, - evaluateCfnTemplate, - ); - - ret.push({ - change: { - cause: change, - resources: [{ - logicalId, - resourceType: change.newValue.Type, - physicalName: agentRuntimeId, - metadata: evaluateCfnTemplate.metadataFor(logicalId), - }], - }, - hotswappable: true, - service: 'bedrock-agentcore', - apply: async (sdk: SDK) => { - const bedrockAgentCore = sdk.bedrockAgentCoreControl(); - - const currentRuntime = await bedrockAgentCore.getAgentRuntime({ - agentRuntimeId, - }); - - // While UpdateAgentRuntimeRequest type allows undefined, - // the API will fail at runtime if these required properties are not provided. - if (!currentRuntime.agentRuntimeArtifact) { - throw new ToolkitError('RuntimeMissingArtifact', 'Current runtime does not have an artifact'); - } - if (!currentRuntime.roleArn) { - throw new ToolkitError('RuntimeMissingRoleArn', 'Current runtime does not have a roleArn'); - } - if (!currentRuntime.networkConfiguration) { - throw new ToolkitError('RuntimeMissingNetworkConfig', 'Current runtime does not have a networkConfiguration'); - } - - // All properties must be explicitly specified, otherwise they will be reset to - // default values. We pass all properties from the current runtime and override - // only the ones that have changed. - await bedrockAgentCore.updateAgentRuntime({ - agentRuntimeId, - agentRuntimeArtifact: runtimeChange.artifact - ? toSdkAgentRuntimeArtifact(runtimeChange.artifact) - : currentRuntime.agentRuntimeArtifact, - roleArn: currentRuntime.roleArn, - networkConfiguration: currentRuntime.networkConfiguration, - description: runtimeChange.description ?? currentRuntime.description, - authorizerConfiguration: currentRuntime.authorizerConfiguration, - requestHeaderConfiguration: currentRuntime.requestHeaderConfiguration, - protocolConfiguration: currentRuntime.protocolConfiguration, - lifecycleConfiguration: currentRuntime.lifecycleConfiguration, - environmentVariables: runtimeChange.environmentVariables ?? currentRuntime.environmentVariables, - }); - }, - }); - - return ret; -} - -async function evaluateBedrockAgentCoreRuntimeProps( - hotswappablePropChanges: Record>, - evaluateCfnTemplate: EvaluateCloudFormationTemplate, -): Promise { - const runtimeChange: BedrockAgentCoreRuntimeChange = {}; - - for (const updatedPropName in hotswappablePropChanges) { - const updatedProp = hotswappablePropChanges[updatedPropName]; - - switch (updatedPropName) { - case 'AgentRuntimeArtifact': - runtimeChange.artifact = await evaluateAgentRuntimeArtifact( - updatedProp.newValue as CfnAgentRuntimeArtifact, - evaluateCfnTemplate, - ); - break; - - case 'Description': - runtimeChange.description = await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue); - break; - - case 'EnvironmentVariables': - runtimeChange.environmentVariables = await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue); - break; - - default: - // never reached - throw new ToolkitError( - 'UnexpectedHotswapProperty', - 'Unexpected hotswappable property for BedrockAgentCore Runtime. Please report this at github.com/aws/aws-cdk/issues/new/choose', - ); - } - } - - return runtimeChange; -} - -async function evaluateAgentRuntimeArtifact( - artifactValue: CfnAgentRuntimeArtifact, - evaluateCfnTemplate: EvaluateCloudFormationTemplate, -): Promise { - if (artifactValue.CodeConfiguration) { - const codeConfig = artifactValue.CodeConfiguration; - const code = codeConfig.Code; - - const s3Location = code.S3 ? { - bucket: await evaluateCfnTemplate.evaluateCfnExpression(code.S3.Bucket), - prefix: await evaluateCfnTemplate.evaluateCfnExpression(code.S3.Prefix), - versionId: code.S3.VersionId - ? await evaluateCfnTemplate.evaluateCfnExpression(code.S3.VersionId) - : undefined, - } : undefined; - - return { - codeConfiguration: { - code: s3Location ? { s3: s3Location } : {}, - runtime: await evaluateCfnTemplate.evaluateCfnExpression(codeConfig.Runtime), - entryPoint: await evaluateCfnTemplate.evaluateCfnExpression(codeConfig.EntryPoint), - }, - }; - } - - if (artifactValue.ContainerConfiguration) { - return { - containerConfiguration: { - containerUri: await evaluateCfnTemplate.evaluateCfnExpression( - artifactValue.ContainerConfiguration.ContainerUri, - ), - }, - }; - } - - return undefined; -} - -function toSdkAgentRuntimeArtifact(artifact: AgentRuntimeArtifact): SdkAgentRuntimeArtifact { - if (artifact.codeConfiguration) { - const code = artifact.codeConfiguration.code.s3 - ? { s3: artifact.codeConfiguration.code.s3 } - : undefined; - - return { - codeConfiguration: { - code, - runtime: artifact.codeConfiguration.runtime as AgentManagedRuntimeType, - entryPoint: artifact.codeConfiguration.entryPoint, - }, - }; - } - - if (artifact.containerConfiguration) { - return { - containerConfiguration: artifact.containerConfiguration, - }; - } - - // never reached - throw new ToolkitError('RuntimeArtifactMissingConfig', 'AgentRuntimeArtifact must have either codeConfiguration or containerConfiguration'); -} - -interface CfnAgentRuntimeArtifact { - readonly CodeConfiguration?: { - readonly Code: { - readonly S3?: { - readonly Bucket: unknown; - readonly Prefix: unknown; - readonly VersionId?: unknown; - }; - }; - readonly Runtime: unknown; - readonly EntryPoint: unknown; - }; - readonly ContainerConfiguration?: { - readonly ContainerUri: unknown; - }; -} - -interface AgentRuntimeArtifact { - readonly codeConfiguration?: { - readonly code: { - readonly s3?: { - readonly bucket: string; - readonly prefix: string; - readonly versionId?: string; - }; - }; - readonly runtime: string; - readonly entryPoint: string[]; - }; - readonly containerConfiguration?: { - readonly containerUri: string; - }; -} - -interface BedrockAgentCoreRuntimeChange { - artifact?: AgentRuntimeArtifact; - description?: string; - environmentVariables?: Record; -} diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/cloud-control-resource.ts b/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/cloud-control-resource.ts new file mode 100644 index 000000000..988803774 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/cloud-control-resource.ts @@ -0,0 +1,126 @@ +import type { HotswapChange } from './common'; +import { classifyChanges, nonHotswappableChange } from './common'; +import { NonHotswappableReason } from '../../payloads'; +import type { ResourceChange } from '../../payloads/hotswap'; +import type { SDK } from '../aws-auth/private'; +import { CfnEvaluationException, type EvaluateCloudFormationTemplate } from '../cloudformation'; + +export async function isHotswappableCloudControlChange( + logicalId: string, + change: ResourceChange, + evaluateCfnTemplate: EvaluateCloudFormationTemplate, + _hotswapPropertyOverrides: unknown, +): Promise { + const ret: HotswapChange[] = []; + + const changedPropNames = Object.keys(change.propertyUpdates); + if (changedPropNames.length === 0) { + return ret; + } + const classifiedChanges = classifyChanges(change, changedPropNames); + classifiedChanges.reportNonHotswappablePropertyChanges(ret); + + if (classifiedChanges.namesOfHotswappableProps.length === 0) { + return ret; + } + + const resourceType = change.newValue.Type; + + const identifier = await resolveCloudControlIdentifier(logicalId, resourceType, evaluateCfnTemplate); + if (!identifier) { + ret.push(nonHotswappableChange( + change, + NonHotswappableReason.RESOURCE_UNSUPPORTED, + 'Could not determine the physical name or primary identifier of the resource, so Cloud Control API cannot hotswap it.', + )); + return ret; + } + + // Eagerly evaluate property values so that unresolvable references + // are caught here and the resource is classified as non-hotswappable + // instead of failing at apply time. This is for resources that depend + // on resources where an update means replacement. + const evaluatedProps: Record = {}; + for (const propName of classifiedChanges.namesOfHotswappableProps) { + try { + evaluatedProps[propName] = await evaluateCfnTemplate.evaluateCfnExpression( + change.propertyUpdates[propName].newValue, + ); + } catch (e) { + if (e instanceof CfnEvaluationException) { + ret.push(nonHotswappableChange( + change, + NonHotswappableReason.RESOURCE_UNSUPPORTED, + `Property '${propName}' of resource '${logicalId}' has been replaced and could not be resolved: ${e.message}`, + )); + return ret; + } + throw e; + } + } + + ret.push({ + change: { + cause: change, + resources: [{ + logicalId, + resourceType, + physicalName: identifier, + metadata: evaluateCfnTemplate.metadataFor(logicalId), + }], + }, + hotswappable: true, + service: 'cloudcontrol', + apply: async (sdk: SDK) => { + const cloudControl = sdk.cloudControl(); + + const patchOps: Array<{ op: string; path: string; value?: any }> = []; + for (const propName of classifiedChanges.namesOfHotswappableProps) { + const diff = change.propertyUpdates[propName]; + const newValue = evaluatedProps[propName]; + if (diff.isRemoval) { + patchOps.push({ op: 'remove', path: `/${propName}` }); + } else if (diff.isAddition) { + patchOps.push({ op: 'add', path: `/${propName}`, value: newValue }); + } else { + patchOps.push({ op: 'replace', path: `/${propName}`, value: newValue }); + } + } + + // nothing to hotswap + if (patchOps.length === 0) { + return; + } + + await cloudControl.updateResource({ + TypeName: resourceType, + Identifier: identifier, + PatchDocument: JSON.stringify(patchOps), + }); + }, + }); + + return ret; +} + +/** + * Resolves the Cloud Control API identifier for a resource. + * + * CCAPI resources with compound primary identifiers need their identifiers to be + * built by joining each component with "|". CloudFormation's PhysicalResourceId + * only returns a single value, which doesn't work for compound keys. + * + * Falls back to the CloudFormation physical resource ID for when the schema cannot be retrieved. + */ +async function resolveCloudControlIdentifier( + logicalId: string, + resourceType: string, + evaluateCfnTemplate: EvaluateCloudFormationTemplate, +): Promise { + const cfnPhysicalId = await evaluateCfnTemplate.findPhysicalNameFor(logicalId); + if (!cfnPhysicalId) { + return undefined; + } + + return evaluateCfnTemplate.evaluateCloudControlIdentifier(logicalId, resourceType, cfnPhysicalId); +} 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 1b9ecd441..26569839c 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 @@ -10,7 +10,6 @@ import type { SDK, SdkProvider } from '../aws-auth/private'; import type { CloudFormationStack, NestedStackTemplates } from '../cloudformation'; import { loadCurrentTemplateWithNestedStacks, EvaluateCloudFormationTemplate } from '../cloudformation'; import { isHotswappableAppSyncChange } from './appsync-mapping-templates'; -import { isHotswappableBedrockAgentCoreRuntimeChange } from './bedrock-agentcore-runtimes'; import { isHotswappableCodeBuildProjectChange } from './code-build-projects'; import type { HotswapChange, @@ -28,12 +27,12 @@ import { skipChangeForS3DeployCustomResourcePolicy, isHotswappableS3BucketDeploymentChange, } from './s3-bucket-deployments'; -import { isHotswappableStateMachineChange } from './stepfunctions-state-machines'; import { ToolkitError } from '../../toolkit/toolkit-error'; import type { SuccessfulDeployStackResult } from '../deployments'; import { IO, SPAN } from '../io/private'; import type { IMessageSpan, IoHelper } from '../io/private'; import { Mode } from '../plugin'; +import { isHotswappableCloudControlChange } from './cloud-control-resource'; // Must use a require() otherwise esbuild complains about calling a namespace // eslint-disable-next-line @typescript-eslint/no-require-imports,@typescript-eslint/consistent-type-imports @@ -60,10 +59,8 @@ const RESOURCE_DETECTORS: { [key: string]: HotswapDetector } = { 'AWS::AppSync::GraphQLSchema': isHotswappableAppSyncChange, 'AWS::AppSync::ApiKey': isHotswappableAppSyncChange, - 'AWS::BedrockAgentCore::Runtime': isHotswappableBedrockAgentCoreRuntimeChange, 'AWS::ECS::TaskDefinition': isHotswappableEcsServiceChange, 'AWS::CodeBuild::Project': isHotswappableCodeBuildProjectChange, - 'AWS::StepFunctions::StateMachine': isHotswappableStateMachineChange, 'Custom::CDKBucketDeployment': isHotswappableS3BucketDeploymentChange, 'AWS::IAM::Policy': async ( logicalId: string, @@ -79,6 +76,23 @@ const RESOURCE_DETECTORS: { [key: string]: HotswapDetector } = { }, 'AWS::CDK::Metadata': async () => [], + + // Resources that use CCAPIS + 'AWS::ApiGateway::RestApi': isHotswappableCloudControlChange, + 'AWS::ApiGateway::Deployment': isHotswappableCloudControlChange, + 'AWS::ApiGateway::Method': isHotswappableCloudControlChange, + 'AWS::ApiGatewayV2::Api': isHotswappableCloudControlChange, + 'AWS::ApiGatewayV2::Integration': isHotswappableCloudControlChange, + 'AWS::Bedrock::Agent': isHotswappableCloudControlChange, + 'AWS::Events::Rule': isHotswappableCloudControlChange, + 'AWS::DynamoDB::Table': isHotswappableCloudControlChange, + 'AWS::DynamoDB::GlobalTable': isHotswappableCloudControlChange, + 'AWS::SQS::Queue': isHotswappableCloudControlChange, + 'AWS::CloudWatch::Alarm': isHotswappableCloudControlChange, + 'AWS::CloudWatch::CompositeAlarm': isHotswappableCloudControlChange, + 'AWS::CloudWatch::Dashboard': isHotswappableCloudControlChange, + 'AWS::StepFunctions::StateMachine': isHotswappableCloudControlChange, + 'AWS::BedrockAgentCore::Runtime': isHotswappableCloudControlChange, }; /** @@ -232,6 +246,9 @@ async function classifyResourceChanges( const promises: Array<() => Promise> = []; const hotswappableResources = new Array(); const nonHotswappableResources = new Array(); + const nestedStackTasks: Array> = []; + const limit = pLimit(10); + for (const logicalId of Object.keys(stackChanges.outputs.changes)) { nonHotswappableResources.push({ hotswappable: false, @@ -249,17 +266,14 @@ async function classifyResourceChanges( // gather the results of the detector functions for (const [logicalId, change] of Object.entries(resourceDifferences)) { if (change.newValue?.Type === 'AWS::CloudFormation::Stack' && change.oldValue?.Type === 'AWS::CloudFormation::Stack') { - const nestedHotswappableResources = await findNestedHotswappableChanges( + nestedStackTasks.push(limit(() => findNestedHotswappableChanges( logicalId, change, nestedStackNames, evaluateCfnTemplate, sdk, hotswapPropertyOverrides, - ); - hotswappableResources.push(...nestedHotswappableResources.hotswappable); - nonHotswappableResources.push(...nestedHotswappableResources.nonHotswappable); - + ))); continue; } @@ -284,12 +298,18 @@ async function classifyResourceChanges( } } - // resolve all detector results + // resolve all nested stack and detector results in parallel + // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism + const nestedResults = await Promise.all(nestedStackTasks); + for (const nested of nestedResults) { + hotswappableResources.push(...nested.hotswappable); + nonHotswappableResources.push(...nested.nonHotswappable); + } + const changesDetectionResults: Array = []; - for (const detectorResultPromises of promises) { - // Constant set of promises per resource - // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism - const hotswapDetectionResults = await Promise.all(await detectorResultPromises()); + // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism + const detectorResults = await Promise.all(promises.map((fn) => limit(fn))); + for (const hotswapDetectionResults of detectorResults) { changesDetectionResults.push(hotswapDetectionResults); } diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/lambda-functions.ts b/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/lambda-functions.ts index 6b15728b7..fbb04bf16 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/lambda-functions.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/lambda-functions.ts @@ -1,6 +1,6 @@ import { Writable } from 'stream'; import type { PropertyDifference } from '@aws-cdk/cloudformation-diff'; -import type { FunctionConfiguration, UpdateFunctionConfigurationCommandInput } from '@aws-sdk/client-lambda'; +import type { UpdateFunctionConfigurationCommandInput } from '@aws-sdk/client-lambda'; import type { HotswapChange } from './common'; import { classifyChanges } from './common'; import type { AffectedResource, ResourceChange } from '../../payloads/hotswap'; @@ -79,7 +79,7 @@ export async function isHotswappableLambdaFunctionChange( if (lambdaCodeChange.code !== undefined || lambdaCodeChange.configurations !== undefined) { if (lambdaCodeChange.code !== undefined) { - const updateFunctionCodeResponse = await lambda.updateFunctionCode({ + await lambda.updateFunctionCode({ FunctionName: functionName, S3Bucket: lambdaCodeChange.code.s3Bucket, S3Key: lambdaCodeChange.code.s3Key, @@ -88,7 +88,7 @@ export async function isHotswappableLambdaFunctionChange( S3ObjectVersion: lambdaCodeChange.code.s3ObjectVersion, }); - await waitForLambdasPropertiesUpdateToFinish(updateFunctionCodeResponse, lambda, functionName); + await waitForLambdasPropertiesUpdateToFinish(lambda, functionName); } if (lambdaCodeChange.configurations !== undefined) { @@ -101,8 +101,8 @@ export async function isHotswappableLambdaFunctionChange( if (lambdaCodeChange.configurations.environment !== undefined) { updateRequest.Environment = lambdaCodeChange.configurations.environment; } - const updateFunctionCodeResponse = await lambda.updateFunctionConfiguration(updateRequest); - await waitForLambdasPropertiesUpdateToFinish(updateFunctionCodeResponse, lambda, functionName); + await lambda.updateFunctionConfiguration(updateRequest); + await waitForLambdasPropertiesUpdateToFinish(lambda, functionName); } // only if the code changed is there any point in publishing a new Version @@ -305,19 +305,13 @@ function zipString(fileName: string, rawString: string): Promise { * or Container functions can take ~25 seconds (and 'idle' VPC functions can take minutes). */ async function waitForLambdasPropertiesUpdateToFinish( - currentFunctionConfiguration: FunctionConfiguration, lambda: ILambdaClient, functionName: string, ): Promise { - const functionIsInVpcOrUsesDockerForCode = - currentFunctionConfiguration.VpcConfig?.VpcId || currentFunctionConfiguration.PackageType === 'Image'; + const minDelaySeconds = 1; + const maxDelaySeconds = 10; - // if the function is deployed in a VPC or if it is a container image function - // then the update will take much longer and we can wait longer between checks - // otherwise, the update will be quick, so a 1-second delay is fine - const delaySeconds = functionIsInVpcOrUsesDockerForCode ? 5 : 1; - - await lambda.waitUntilFunctionUpdated(delaySeconds, { + await lambda.waitUntilFunctionUpdated(minDelaySeconds, maxDelaySeconds, { FunctionName: functionName, }); } diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/stepfunctions-state-machines.ts b/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/stepfunctions-state-machines.ts deleted file mode 100644 index faeddf08b..000000000 --- a/packages/@aws-cdk/toolkit-lib/lib/api/hotswap/stepfunctions-state-machines.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { type HotswapChange, classifyChanges } from './common'; -import type { ResourceChange } from '../../payloads/hotswap'; -import type { SDK } from '../aws-auth/private'; -import type { EvaluateCloudFormationTemplate } from '../cloudformation'; - -export async function isHotswappableStateMachineChange( - logicalId: string, - change: ResourceChange, - evaluateCfnTemplate: EvaluateCloudFormationTemplate, -): Promise { - if (change.newValue.Type !== 'AWS::StepFunctions::StateMachine') { - return []; - } - const ret: HotswapChange[] = []; - const classifiedChanges = classifyChanges(change, ['DefinitionString']); - classifiedChanges.reportNonHotswappablePropertyChanges(ret); - - const namesOfHotswappableChanges = Object.keys(classifiedChanges.hotswappableProps); - if (namesOfHotswappableChanges.length > 0) { - const stateMachineNameInCfnTemplate = change.newValue?.Properties?.StateMachineName; - const stateMachineArn = stateMachineNameInCfnTemplate - ? await evaluateCfnTemplate.evaluateCfnExpression({ - 'Fn::Sub': - 'arn:${AWS::Partition}:states:${AWS::Region}:${AWS::AccountId}:stateMachine:' + - stateMachineNameInCfnTemplate, - }) - : await evaluateCfnTemplate.findPhysicalNameFor(logicalId); - - // nothing to do - if (!stateMachineArn) { - return ret; - } - - ret.push({ - change: { - cause: change, - resources: [{ - logicalId, - resourceType: change.newValue.Type, - physicalName: stateMachineArn?.split(':')[6], - metadata: evaluateCfnTemplate.metadataFor(logicalId), - }], - }, - hotswappable: true, - service: 'stepfunctions-service', - apply: async (sdk: SDK) => { - // not passing the optional properties leaves them unchanged - await sdk.stepFunctions().updateStateMachine({ - stateMachineArn, - definition: await evaluateCfnTemplate.evaluateCfnExpression(change.propertyUpdates.DefinitionString.newValue), - }); - }, - }); - } - - return ret; -} diff --git a/packages/@aws-cdk/toolkit-lib/test/api/cloudformation/evaluate-cloudformation-template.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/cloudformation/evaluate-cloudformation-template.test.ts index 31fe41ea5..2770b01c2 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/cloudformation/evaluate-cloudformation-template.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/cloudformation/evaluate-cloudformation-template.test.ts @@ -1,10 +1,11 @@ -import { ListExportsCommand } from '@aws-sdk/client-cloudformation'; +import { GetResourceCommand } from '@aws-sdk/client-cloudcontrol'; +import { ListExportsCommand, ListStackResourcesCommand } from '@aws-sdk/client-cloudformation'; import type { Template } from '../../../lib/api/cloudformation'; import { CfnEvaluationException, EvaluateCloudFormationTemplate, } from '../../../lib/api/cloudformation'; -import { MockSdk, mockCloudFormationClient, restoreSdkMocksToDefault } from '../../_helpers/mock-sdk'; +import { MockSdk, mockCloudControlClient, mockCloudFormationClient, restoreSdkMocksToDefault } from '../../_helpers/mock-sdk'; const sdk = new MockSdk(); @@ -64,6 +65,182 @@ describe('evaluateCfnExpression', () => { }); }); + describe('Fn::GetAtt with Cloud Control API fallback', () => { + test('falls back to CCAPI for unsupported resource type', async () => { + const template: Template = { + Resources: { + MyCustom: { + Type: 'AWS::Custom::Thing', + Properties: { + Foo: { 'Fn::GetAtt': ['MyCustom', 'Bar'] }, + }, + }, + }, + }; + const evaluator = createEvaluateCloudFormationTemplate(template); + mockCloudFormationClient.on(ListStackResourcesCommand).resolves({ + StackResourceSummaries: [{ + LogicalResourceId: 'MyCustom', + PhysicalResourceId: 'phys-id', + ResourceType: 'AWS::Custom::Thing', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }], + }); + mockCloudControlClient.on(GetResourceCommand).resolves({ + ResourceDescription: { + Properties: JSON.stringify({ Bar: 'resolved-bar-value' }), + }, + }); + + const result = await evaluator.evaluateCfnExpression({ 'Fn::GetAtt': ['MyCustom', 'Bar'] }); + expect(result).toEqual('resolved-bar-value'); + }); + + test('falls back to CCAPI for unsupported attribute on known resource type', async () => { + const template: Template = { + Resources: { + MyBucket: { + Type: 'AWS::S3::Bucket', + Properties: { + Tag: { 'Fn::GetAtt': ['MyBucket', 'WebsiteURL'] }, + }, + }, + }, + }; + const evaluator = createEvaluateCloudFormationTemplate(template); + mockCloudFormationClient.on(ListStackResourcesCommand).resolves({ + StackResourceSummaries: [{ + LogicalResourceId: 'MyBucket', + PhysicalResourceId: 'my-bucket', + ResourceType: 'AWS::S3::Bucket', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }], + }); + mockCloudControlClient.on(GetResourceCommand).resolves({ + ResourceDescription: { + Properties: JSON.stringify({ WebsiteURL: 'http://my-bucket.s3-website.ap-south-east-2.amazonaws.com' }), + }, + }); + + const result = await evaluator.evaluateCfnExpression({ 'Fn::GetAtt': ['MyBucket', 'WebsiteURL'] }); + expect(result).toEqual('http://my-bucket.s3-website.ap-south-east-2.amazonaws.com'); + }); + + test('throws CfnEvaluationException when CCAPI returns no matching attribute', async () => { + const template: Template = { + Resources: { + MyCustom: { + Type: 'AWS::Custom::Thing', + Properties: {}, + }, + }, + }; + const evaluator = createEvaluateCloudFormationTemplate(template); + mockCloudFormationClient.on(ListStackResourcesCommand).resolves({ + StackResourceSummaries: [{ + LogicalResourceId: 'MyCustom', + PhysicalResourceId: 'phys-id', + ResourceType: 'AWS::Custom::Thing', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }], + }); + mockCloudControlClient.on(GetResourceCommand).resolves({ + ResourceDescription: { + Properties: JSON.stringify({ SomethingElse: 'value' }), + }, + }); + + await expect( + evaluator.evaluateCfnExpression({ 'Fn::GetAtt': ['MyCustom', 'Missing'] }), + ).rejects.toBeInstanceOf(CfnEvaluationException); + }); + + test('throws CfnEvaluationException when CCAPI call fails', async () => { + const template: Template = { + Resources: { + MyCustom: { + Type: 'AWS::Custom::Thing', + Properties: {}, + }, + }, + }; + const evaluator = createEvaluateCloudFormationTemplate(template); + mockCloudFormationClient.on(ListStackResourcesCommand).resolves({ + StackResourceSummaries: [{ + LogicalResourceId: 'MyCustom', + PhysicalResourceId: 'phys-id', + ResourceType: 'AWS::Custom::Thing', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }], + }); + mockCloudControlClient.on(GetResourceCommand).rejects(new Error('Resource not found')); + + await expect( + evaluator.evaluateCfnExpression({ 'Fn::GetAtt': ['MyCustom', 'Bar'] }), + ).rejects.toBeInstanceOf(CfnEvaluationException); + }); + + test('resolves Fn::GetAtt via CCAPI for attribute on unsupported resource', async () => { + const template: Template = { + Resources: { + MyCustom: { + Type: 'AWS::Custom::Thing', + Properties: { + Output: { 'Fn::GetAtt': ['MyCustom', 'Output'] }, + }, + }, + }, + }; + const evaluator = createEvaluateCloudFormationTemplate(template); + mockCloudFormationClient.on(ListStackResourcesCommand).resolves({ + StackResourceSummaries: [{ + LogicalResourceId: 'MyCustom', + PhysicalResourceId: 'phys-id', + ResourceType: 'AWS::Custom::Thing', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }], + }); + mockCloudControlClient.on(GetResourceCommand).resolves({ + ResourceDescription: { + Properties: JSON.stringify({ Output: 'the-output' }), + }, + }); + + const result = await evaluator.evaluateCfnExpression({ 'Fn::GetAtt': ['MyCustom', 'Output'] }); + expect(result).toEqual('the-output'); + }); + + test('still uses hardcoded format when resource type is supported', async () => { + // Lambda Arn is in the hardcoded map — should NOT fall back to CCAPI + const template: Template = { + Resources: { + MyFunc: { + Type: 'AWS::Lambda::Function', + Properties: {}, + }, + }, + }; + const evaluator = createEvaluateCloudFormationTemplate(template); + mockCloudFormationClient.on(ListStackResourcesCommand).resolves({ + StackResourceSummaries: [{ + LogicalResourceId: 'MyFunc', + PhysicalResourceId: 'my-func', + ResourceType: 'AWS::Lambda::Function', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }], + }); + + const result = await evaluator.evaluateCfnExpression({ 'Fn::GetAtt': ['MyFunc', 'Arn'] }); + expect(result).toEqual('arn:aws:lambda:ap-south-east-2:0123456789:function:my-func'); + }); + }); + describe('resolving Fn::ImportValue', () => { const template: Template = {}; const evaluateCfnTemplate = createEvaluateCloudFormationTemplate(template); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/hotswap/bedrock-agentcore-runtimes-hotswap-deployments.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/hotswap/bedrock-agentcore-runtimes-hotswap-deployments.test.ts deleted file mode 100644 index b5de65a4d..000000000 --- a/packages/@aws-cdk/toolkit-lib/test/api/hotswap/bedrock-agentcore-runtimes-hotswap-deployments.test.ts +++ /dev/null @@ -1,651 +0,0 @@ -import { GetAgentRuntimeCommand, UpdateAgentRuntimeCommand } from '@aws-sdk/client-bedrock-agentcore-control'; -import { HotswapMode } from '../../../lib/api/hotswap'; -import { mockBedrockAgentCoreControlClient } from '../../_helpers/mock-sdk'; -import * as setup from '../_helpers/hotswap-test-setup'; - -let hotswapMockSdkProvider: setup.HotswapMockSdkProvider; - -beforeEach(() => { - hotswapMockSdkProvider = setup.setupHotswapTests(); - mockBedrockAgentCoreControlClient.on(GetAgentRuntimeCommand).resolves({ - agentRuntimeId: 'my-runtime', - roleArn: 'arn:aws:iam::123456789012:role/MyRole', - networkConfiguration: { - networkMode: 'VPC', - networkModeConfig: { - subnets: ['subnet-1', 'subnet-2'], - securityGroups: ['sg-1'], - }, - }, - agentRuntimeArtifact: { - codeConfiguration: { - code: { - s3: { - bucket: 'my-bucket', - prefix: 'code.zip', - }, - }, - runtime: 'PYTHON_3_13', - entryPoint: ['app.py'], - }, - }, - }); -}); - -describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hotswapMode) => { - test('calls the updateAgentRuntime() API when it receives only an S3 code difference in a Runtime', async () => { - // GIVEN - setup.setCurrentCfnStackTemplate({ - Resources: { - Runtime: { - Type: 'AWS::BedrockAgentCore::Runtime', - Properties: { - RuntimeName: 'my-runtime', - RoleArn: 'arn:aws:iam::123456789012:role/MyRole', - NetworkConfiguration: { - NetworkMode: 'VPC', - NetworkModeConfig: { - Subnets: ['subnet-1', 'subnet-2'], - SecurityGroups: ['sg-1'], - }, - }, - AgentRuntimeArtifact: { - CodeConfiguration: { - Code: { - S3: { - Bucket: 'my-bucket', - Prefix: 'old-code.zip', - }, - }, - Runtime: 'PYTHON_3_13', - EntryPoint: ['app.py'], - }, - }, - }, - }, - }, - }); - setup.pushStackResourceSummaries( - setup.stackSummaryOf('Runtime', 'AWS::BedrockAgentCore::Runtime', 'my-runtime'), - ); - const cdkStackArtifact = setup.cdkStackArtifactOf({ - template: { - Resources: { - Runtime: { - Type: 'AWS::BedrockAgentCore::Runtime', - Properties: { - RuntimeName: 'my-runtime', - RoleArn: 'arn:aws:iam::123456789012:role/MyRole', - NetworkConfiguration: { - NetworkMode: 'VPC', - NetworkModeConfig: { - Subnets: ['subnet-1', 'subnet-2'], - SecurityGroups: ['sg-1'], - }, - }, - AgentRuntimeArtifact: { - CodeConfiguration: { - Code: { - S3: { - Bucket: 'my-bucket', - Prefix: 'new-code.zip', - }, - }, - Runtime: 'PYTHON_3_13', - EntryPoint: ['app.py'], - }, - }, - }, - }, - }, - }, - }); - - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN - expect(deployStackResult).not.toBeUndefined(); - expect(mockBedrockAgentCoreControlClient).toHaveReceivedCommandWith(UpdateAgentRuntimeCommand, { - agentRuntimeId: 'my-runtime', - agentRuntimeArtifact: { - codeConfiguration: { - code: { - s3: { - bucket: 'my-bucket', - prefix: 'new-code.zip', - }, - }, - runtime: 'PYTHON_3_13', - entryPoint: ['app.py'], - }, - }, - roleArn: 'arn:aws:iam::123456789012:role/MyRole', - networkConfiguration: { - networkMode: 'VPC', - networkModeConfig: { - subnets: ['subnet-1', 'subnet-2'], - securityGroups: ['sg-1'], - }, - }, - }); - }); - - test('calls the updateAgentRuntime() API when it receives only a container image difference in a Runtime', async () => { - // GIVEN - setup.setCurrentCfnStackTemplate({ - Resources: { - Runtime: { - Type: 'AWS::BedrockAgentCore::Runtime', - Properties: { - RuntimeName: 'my-runtime', - RoleArn: 'arn:aws:iam::123456789012:role/MyRole', - NetworkConfiguration: { - NetworkMode: 'VPC', - NetworkModeConfig: { - Subnets: ['subnet-1', 'subnet-2'], - SecurityGroups: ['sg-1'], - }, - }, - AgentRuntimeArtifact: { - ContainerConfiguration: { - ContainerUri: '123456789012.dkr.ecr.us-east-1.amazonaws.com/my-repo:old-tag', - }, - }, - }, - }, - }, - }); - setup.pushStackResourceSummaries( - setup.stackSummaryOf('Runtime', 'AWS::BedrockAgentCore::Runtime', 'my-runtime'), - ); - const cdkStackArtifact = setup.cdkStackArtifactOf({ - template: { - Resources: { - Runtime: { - Type: 'AWS::BedrockAgentCore::Runtime', - Properties: { - RuntimeName: 'my-runtime', - RoleArn: 'arn:aws:iam::123456789012:role/MyRole', - NetworkConfiguration: { - NetworkMode: 'VPC', - NetworkModeConfig: { - Subnets: ['subnet-1', 'subnet-2'], - SecurityGroups: ['sg-1'], - }, - }, - AgentRuntimeArtifact: { - ContainerConfiguration: { - ContainerUri: '123456789012.dkr.ecr.us-east-1.amazonaws.com/my-repo:new-tag', - }, - }, - }, - }, - }, - }, - }); - - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN - expect(deployStackResult).not.toBeUndefined(); - expect(mockBedrockAgentCoreControlClient).toHaveReceivedCommandWith(UpdateAgentRuntimeCommand, { - agentRuntimeId: 'my-runtime', - agentRuntimeArtifact: { - containerConfiguration: { - containerUri: '123456789012.dkr.ecr.us-east-1.amazonaws.com/my-repo:new-tag', - }, - }, - roleArn: 'arn:aws:iam::123456789012:role/MyRole', - networkConfiguration: { - networkMode: 'VPC', - networkModeConfig: { - subnets: ['subnet-1', 'subnet-2'], - securityGroups: ['sg-1'], - }, - }, - }); - }); - - test('calls the updateAgentRuntime() API when it receives only a description change', async () => { - // GIVEN - setup.setCurrentCfnStackTemplate({ - Resources: { - Runtime: { - Type: 'AWS::BedrockAgentCore::Runtime', - Properties: { - RuntimeName: 'my-runtime', - RoleArn: 'arn:aws:iam::123456789012:role/MyRole', - NetworkConfiguration: { - NetworkMode: 'VPC', - NetworkModeConfig: { - Subnets: ['subnet-1', 'subnet-2'], - SecurityGroups: ['sg-1'], - }, - }, - AgentRuntimeArtifact: { - CodeConfiguration: { - Code: { - S3: { - Bucket: 'my-bucket', - Prefix: 'code.zip', - }, - }, - Runtime: 'PYTHON_3_13', - EntryPoint: ['app.py'], - }, - }, - Description: 'Old description', - }, - }, - }, - }); - setup.pushStackResourceSummaries( - setup.stackSummaryOf('Runtime', 'AWS::BedrockAgentCore::Runtime', 'my-runtime'), - ); - mockBedrockAgentCoreControlClient.on(GetAgentRuntimeCommand).resolves({ - agentRuntimeId: 'my-runtime', - roleArn: 'arn:aws:iam::123456789012:role/MyRole', - networkConfiguration: { - networkMode: 'VPC', - networkModeConfig: { - subnets: ['subnet-1', 'subnet-2'], - securityGroups: ['sg-1'], - }, - }, - agentRuntimeArtifact: { - codeConfiguration: { - code: { - s3: { - bucket: 'my-bucket', - prefix: 'code.zip', - }, - }, - runtime: 'PYTHON_3_13', - entryPoint: ['app.py'], - }, - }, - description: 'Old description', - }); - const cdkStackArtifact = setup.cdkStackArtifactOf({ - template: { - Resources: { - Runtime: { - Type: 'AWS::BedrockAgentCore::Runtime', - Properties: { - RuntimeName: 'my-runtime', - RoleArn: 'arn:aws:iam::123456789012:role/MyRole', - NetworkConfiguration: { - NetworkMode: 'VPC', - NetworkModeConfig: { - Subnets: ['subnet-1', 'subnet-2'], - SecurityGroups: ['sg-1'], - }, - }, - AgentRuntimeArtifact: { - CodeConfiguration: { - Code: { - S3: { - Bucket: 'my-bucket', - Prefix: 'code.zip', - }, - }, - Runtime: 'PYTHON_3_13', - EntryPoint: ['app.py'], - }, - }, - Description: 'New description', - }, - }, - }, - }, - }); - - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN - expect(deployStackResult).not.toBeUndefined(); - expect(mockBedrockAgentCoreControlClient).toHaveReceivedCommandWith(UpdateAgentRuntimeCommand, { - agentRuntimeId: 'my-runtime', - agentRuntimeArtifact: { - codeConfiguration: { - code: { - s3: { - bucket: 'my-bucket', - prefix: 'code.zip', - }, - }, - runtime: 'PYTHON_3_13', - entryPoint: ['app.py'], - }, - }, - roleArn: 'arn:aws:iam::123456789012:role/MyRole', - networkConfiguration: { - networkMode: 'VPC', - networkModeConfig: { - subnets: ['subnet-1', 'subnet-2'], - securityGroups: ['sg-1'], - }, - }, - description: 'New description', - }); - }); - - test('calls the updateAgentRuntime() API when it receives only environment variables changes', async () => { - // GIVEN - setup.setCurrentCfnStackTemplate({ - Resources: { - Runtime: { - Type: 'AWS::BedrockAgentCore::Runtime', - Properties: { - RuntimeName: 'my-runtime', - RoleArn: 'arn:aws:iam::123456789012:role/MyRole', - NetworkConfiguration: { - NetworkMode: 'VPC', - NetworkModeConfig: { - Subnets: ['subnet-1', 'subnet-2'], - SecurityGroups: ['sg-1'], - }, - }, - AgentRuntimeArtifact: { - CodeConfiguration: { - Code: { - S3: { - Bucket: 'my-bucket', - Prefix: 'code.zip', - }, - }, - Runtime: 'PYTHON_3_13', - EntryPoint: ['app.py'], - }, - }, - EnvironmentVariables: { - KEY1: 'value1', - }, - }, - }, - }, - }); - setup.pushStackResourceSummaries( - setup.stackSummaryOf('Runtime', 'AWS::BedrockAgentCore::Runtime', 'my-runtime'), - ); - mockBedrockAgentCoreControlClient.on(GetAgentRuntimeCommand).resolves({ - agentRuntimeId: 'my-runtime', - roleArn: 'arn:aws:iam::123456789012:role/MyRole', - networkConfiguration: { - networkMode: 'VPC', - networkModeConfig: { - subnets: ['subnet-1', 'subnet-2'], - securityGroups: ['sg-1'], - }, - }, - agentRuntimeArtifact: { - codeConfiguration: { - code: { - s3: { - bucket: 'my-bucket', - prefix: 'code.zip', - }, - }, - runtime: 'PYTHON_3_13', - entryPoint: ['app.py'], - }, - }, - environmentVariables: { - KEY1: 'value1', - }, - }); - const cdkStackArtifact = setup.cdkStackArtifactOf({ - template: { - Resources: { - Runtime: { - Type: 'AWS::BedrockAgentCore::Runtime', - Properties: { - RuntimeName: 'my-runtime', - RoleArn: 'arn:aws:iam::123456789012:role/MyRole', - NetworkConfiguration: { - NetworkMode: 'VPC', - NetworkModeConfig: { - Subnets: ['subnet-1', 'subnet-2'], - SecurityGroups: ['sg-1'], - }, - }, - AgentRuntimeArtifact: { - CodeConfiguration: { - Code: { - S3: { - Bucket: 'my-bucket', - Prefix: 'code.zip', - }, - }, - Runtime: 'PYTHON_3_13', - EntryPoint: ['app.py'], - }, - }, - EnvironmentVariables: { - KEY1: 'value1', - KEY2: 'value2', - }, - }, - }, - }, - }, - }); - - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN - expect(deployStackResult).not.toBeUndefined(); - expect(mockBedrockAgentCoreControlClient).toHaveReceivedCommandWith(UpdateAgentRuntimeCommand, { - agentRuntimeId: 'my-runtime', - agentRuntimeArtifact: { - codeConfiguration: { - code: { - s3: { - bucket: 'my-bucket', - prefix: 'code.zip', - }, - }, - runtime: 'PYTHON_3_13', - entryPoint: ['app.py'], - }, - }, - roleArn: 'arn:aws:iam::123456789012:role/MyRole', - networkConfiguration: { - networkMode: 'VPC', - networkModeConfig: { - subnets: ['subnet-1', 'subnet-2'], - securityGroups: ['sg-1'], - }, - }, - environmentVariables: { - KEY1: 'value1', - KEY2: 'value2', - }, - }); - }); - - test('does not call the updateAgentRuntime() API when a non-hotswappable property changes', async () => { - // GIVEN - setup.setCurrentCfnStackTemplate({ - Resources: { - Runtime: { - Type: 'AWS::BedrockAgentCore::Runtime', - Properties: { - RuntimeName: 'my-runtime', - RoleArn: 'arn:aws:iam::123456789012:role/MyRole', - NetworkConfiguration: { - NetworkMode: 'VPC', - NetworkModeConfig: { - Subnets: ['subnet-1', 'subnet-2'], - SecurityGroups: ['sg-1'], - }, - }, - AgentRuntimeArtifact: { - CodeConfiguration: { - Code: { - S3: { - Bucket: 'my-bucket', - Prefix: 'code.zip', - }, - }, - Runtime: 'PYTHON_3_13', - EntryPoint: ['app.py'], - }, - }, - }, - }, - }, - }); - setup.pushStackResourceSummaries( - setup.stackSummaryOf('Runtime', 'AWS::BedrockAgentCore::Runtime', 'my-runtime'), - ); - const cdkStackArtifact = setup.cdkStackArtifactOf({ - template: { - Resources: { - Runtime: { - Type: 'AWS::BedrockAgentCore::Runtime', - Properties: { - RuntimeName: 'my-runtime', - RoleArn: 'arn:aws:iam::123456789012:role/DifferentRole', // non-hotswappable change - NetworkConfiguration: { - NetworkMode: 'VPC', - NetworkModeConfig: { - Subnets: ['subnet-1', 'subnet-2'], - SecurityGroups: ['sg-1'], - }, - }, - AgentRuntimeArtifact: { - CodeConfiguration: { - Code: { - S3: { - Bucket: 'my-bucket', - Prefix: 'code.zip', - }, - }, - Runtime: 'PYTHON_3_13', - EntryPoint: ['app.py'], - }, - }, - }, - }, - }, - }, - }); - - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN - if (hotswapMode === HotswapMode.FALL_BACK) { - expect(deployStackResult).toBeUndefined(); - } else { - expect(deployStackResult).not.toBeUndefined(); - } - expect(mockBedrockAgentCoreControlClient).not.toHaveReceivedCommand(UpdateAgentRuntimeCommand); - }); - - test('calls the updateAgentRuntime() API with S3 versionId when specified', async () => { - // GIVEN - setup.setCurrentCfnStackTemplate({ - Resources: { - Runtime: { - Type: 'AWS::BedrockAgentCore::Runtime', - Properties: { - RuntimeName: 'my-runtime', - RoleArn: 'arn:aws:iam::123456789012:role/MyRole', - NetworkConfiguration: { - NetworkMode: 'VPC', - NetworkModeConfig: { - Subnets: ['subnet-1', 'subnet-2'], - SecurityGroups: ['sg-1'], - }, - }, - AgentRuntimeArtifact: { - CodeConfiguration: { - Code: { - S3: { - Bucket: 'my-bucket', - Prefix: 'code.zip', - VersionId: 'v1', - }, - }, - Runtime: 'PYTHON_3_13', - EntryPoint: ['app.py'], - }, - }, - }, - }, - }, - }); - setup.pushStackResourceSummaries( - setup.stackSummaryOf('Runtime', 'AWS::BedrockAgentCore::Runtime', 'my-runtime'), - ); - const cdkStackArtifact = setup.cdkStackArtifactOf({ - template: { - Resources: { - Runtime: { - Type: 'AWS::BedrockAgentCore::Runtime', - Properties: { - RuntimeName: 'my-runtime', - RoleArn: 'arn:aws:iam::123456789012:role/MyRole', - NetworkConfiguration: { - NetworkMode: 'VPC', - NetworkModeConfig: { - Subnets: ['subnet-1', 'subnet-2'], - SecurityGroups: ['sg-1'], - }, - }, - AgentRuntimeArtifact: { - CodeConfiguration: { - Code: { - S3: { - Bucket: 'my-bucket', - Prefix: 'code.zip', - VersionId: 'v2', - }, - }, - Runtime: 'PYTHON_3_13', - EntryPoint: ['app.py'], - }, - }, - }, - }, - }, - }, - }); - - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN - expect(deployStackResult).not.toBeUndefined(); - expect(mockBedrockAgentCoreControlClient).toHaveReceivedCommandWith(UpdateAgentRuntimeCommand, { - agentRuntimeId: 'my-runtime', - agentRuntimeArtifact: { - codeConfiguration: { - code: { - s3: { - bucket: 'my-bucket', - prefix: 'code.zip', - versionId: 'v2', - }, - }, - runtime: 'PYTHON_3_13', - entryPoint: ['app.py'], - }, - }, - roleArn: 'arn:aws:iam::123456789012:role/MyRole', - networkConfiguration: { - networkMode: 'VPC', - networkModeConfig: { - subnets: ['subnet-1', 'subnet-2'], - securityGroups: ['sg-1'], - }, - }, - }); - }); -}); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/hotswap/cloud-control-hotswap-deployments.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/hotswap/cloud-control-hotswap-deployments.test.ts new file mode 100644 index 000000000..dbce37a46 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/api/hotswap/cloud-control-hotswap-deployments.test.ts @@ -0,0 +1,634 @@ +import { UpdateResourceCommand } from '@aws-sdk/client-cloudcontrol'; +import { DescribeTypeCommand } from '@aws-sdk/client-cloudformation'; +import { HotswapMode } from '../../../lib/api/hotswap'; +import { mockCloudControlClient, mockCloudFormationClient } from '../../_helpers/mock-sdk'; +import * as setup from '../_helpers/hotswap-test-setup'; + +let hotswapMockSdkProvider: setup.HotswapMockSdkProvider; + +beforeEach(() => { + hotswapMockSdkProvider = setup.setupHotswapTests(); + + mockCloudFormationClient.on(DescribeTypeCommand).resolves({ + Schema: JSON.stringify({ + primaryIdentifier: ['/properties/Id'], + }), + }); + + mockCloudControlClient.on(UpdateResourceCommand).resolves({}); +}); + +describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hotswapMode) => { + test('returns undefined when a new CCAPI resource is added to the Stack', async () => { + // GIVEN + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + MyApi: { + Type: 'AWS::ApiGateway::RestApi', + }, + }, + }, + }); + + if (hotswapMode === HotswapMode.FALL_BACK) { + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + expect(deployStackResult).toBeUndefined(); + expect(mockCloudControlClient).not.toHaveReceivedCommand(UpdateResourceCommand); + } else { + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + expect(deployStackResult).not.toBeUndefined(); + expect(deployStackResult?.noOp).toEqual(true); + expect(mockCloudControlClient).not.toHaveReceivedCommand(UpdateResourceCommand); + } + }); + + test('calls Cloud Control updateResource when a property changes', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + MyApi: { + Type: 'AWS::ApiGateway::RestApi', + Properties: { + Id: 'res-123', + Description: 'old description', + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('MyApi', 'AWS::ApiGateway::RestApi', 'res-123'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + MyApi: { + Type: 'AWS::ApiGateway::RestApi', + Properties: { + Id: 'res-123', + Description: 'new description', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockCloudControlClient).toHaveReceivedCommandWith(UpdateResourceCommand, { + TypeName: 'AWS::ApiGateway::RestApi', + Identifier: 'res-123', + PatchDocument: JSON.stringify([ + { op: 'replace', path: '/Description', value: 'new description' }, + ]), + }); + }); + + test('uses "add" op for properties not present in the current resource', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + MyApi: { + Type: 'AWS::ApiGateway::RestApi', + Properties: { Id: 'res-123', Name: 'my-api' }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('MyApi', 'AWS::ApiGateway::RestApi', 'res-123'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + MyApi: { + Type: 'AWS::ApiGateway::RestApi', + Properties: { Id: 'res-123', Name: 'my-api', Description: 'brand new' }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockCloudControlClient).toHaveReceivedCommandWith(UpdateResourceCommand, { + TypeName: 'AWS::ApiGateway::RestApi', + Identifier: 'res-123', + PatchDocument: JSON.stringify([ + { op: 'add', path: '/Description', value: 'brand new' }, + ]), + }); + }); + + test('skips updateResource when property values are already the same', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + MyApi: { + Type: 'AWS::ApiGateway::RestApi', + Properties: { Id: 'res-123', Description: 'old' }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('MyApi', 'AWS::ApiGateway::RestApi', 'res-123'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + MyApi: { + Type: 'AWS::ApiGateway::RestApi', + Properties: { Id: 'res-123', Description: 'old' }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockCloudControlClient).not.toHaveReceivedCommand(UpdateResourceCommand); + }); + + test('resolves compound primary identifiers joined with |', async () => { + // GIVEN + mockCloudFormationClient.on(DescribeTypeCommand).resolves({ + Schema: JSON.stringify({ + primaryIdentifier: ['/properties/ApiId', '/properties/IntegrationId'], + }), + }); + setup.setCurrentCfnStackTemplate({ + Resources: { + MyIntegration: { + Type: 'AWS::ApiGatewayV2::Integration', + Properties: { ApiId: 'api-123', IntegrationId: 'integ-456', TimeoutInMillis: 29000 }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('MyIntegration', 'AWS::ApiGatewayV2::Integration', 'integ-456'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + MyIntegration: { + Type: 'AWS::ApiGatewayV2::Integration', + Properties: { ApiId: 'api-123', IntegrationId: 'integ-456', TimeoutInMillis: 15000 }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockCloudControlClient).toHaveReceivedCommandWith(UpdateResourceCommand, { + TypeName: 'AWS::ApiGatewayV2::Integration', + Identifier: 'api-123|integ-456', + PatchDocument: JSON.stringify([{ op: 'replace', path: '/TimeoutInMillis', value: 15000 }]), + }); + }); + + test('resolves compound identifier when one property is read-only and absent from template', async () => { + // GIVEN + mockCloudFormationClient.on(DescribeTypeCommand).resolves({ + Schema: JSON.stringify({ + primaryIdentifier: ['/properties/ApiId', '/properties/IntegrationId'], + }), + }); + setup.setCurrentCfnStackTemplate({ + Resources: { + MyIntegration: { + Type: 'AWS::ApiGatewayV2::Integration', + Properties: { ApiId: 'api-123', IntegrationType: 'AWS_PROXY', TimeoutInMillis: 29000 }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('MyIntegration', 'AWS::ApiGatewayV2::Integration', 'integ-456'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + MyIntegration: { + Type: 'AWS::ApiGatewayV2::Integration', + Properties: { ApiId: 'api-123', IntegrationType: 'AWS_PROXY', TimeoutInMillis: 15000 }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockCloudControlClient).toHaveReceivedCommandWith(UpdateResourceCommand, { + TypeName: 'AWS::ApiGatewayV2::Integration', + Identifier: 'api-123|integ-456', + PatchDocument: JSON.stringify([{ op: 'replace', path: '/TimeoutInMillis', value: 15000 }]), + }); + }); + + test('falls back to CFN physical resource ID when schema has no primaryIdentifier', async () => { + // GIVEN + mockCloudFormationClient.on(DescribeTypeCommand).resolves({ + Schema: JSON.stringify({}), + }); + setup.setCurrentCfnStackTemplate({ + Resources: { + MyRule: { + Type: 'AWS::Events::Rule', + Properties: { Description: 'old' }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('MyRule', 'AWS::Events::Rule', 'my-rule-physical-id'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + MyRule: { + Type: 'AWS::Events::Rule', + Properties: { Description: 'new' }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockCloudControlClient).toHaveReceivedCommandWith(UpdateResourceCommand, { + TypeName: 'AWS::Events::Rule', + Identifier: 'my-rule-physical-id', + PatchDocument: JSON.stringify([{ op: 'replace', path: '/Description', value: 'new' }]), + }); + }); + + test('returns non-hotswappable when physical name cannot be determined', async () => { + // GIVEN – no stack resource summaries pushed + setup.setCurrentCfnStackTemplate({ + Resources: { + MyApi: { + Type: 'AWS::ApiGateway::RestApi', + Properties: { Description: 'old' }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + MyApi: { + Type: 'AWS::ApiGateway::RestApi', + Properties: { Description: 'new' }, + }, + }, + }, + }); + + if (hotswapMode === HotswapMode.FALL_BACK) { + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + expect(deployStackResult).toBeUndefined(); + expect(mockCloudControlClient).not.toHaveReceivedCommand(UpdateResourceCommand); + } else { + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + expect(deployStackResult).not.toBeUndefined(); + expect(deployStackResult?.noOp).toEqual(true); + expect(mockCloudControlClient).not.toHaveReceivedCommand(UpdateResourceCommand); + } + }); + + test('returns non-hotswappable when a property references an unresolvable parameter', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Parameters: { Param1: { Type: 'String' } }, + Resources: { + MyApi: { + Type: 'AWS::ApiGateway::RestApi', + Properties: { Id: 'res-123', Description: { Ref: 'Param1' } }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('MyApi', 'AWS::ApiGateway::RestApi', 'res-123'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Parameters: { Param1: { Type: 'String' } }, + Resources: { + MyApi: { + Type: 'AWS::ApiGateway::RestApi', + Properties: { Id: 'res-123', Description: { Ref: 'Param1' } }, + }, + }, + }, + }); + + // Templates are identical so there are no changes — both modes return a noOp result + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + expect(deployStackResult).not.toBeUndefined(); + expect(deployStackResult?.noOp).toEqual(true); + expect(mockCloudControlClient).not.toHaveReceivedCommand(UpdateResourceCommand); + }); + + test('evaluates Ref expressions in property values', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + Bucket: { Type: 'AWS::S3::Bucket' }, + MyApi: { + Type: 'AWS::ApiGateway::RestApi', + Properties: { Id: 'res-123', Description: 'old' }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('Bucket', 'AWS::S3::Bucket', 'my-bucket'), + setup.stackSummaryOf('MyApi', 'AWS::ApiGateway::RestApi', 'res-123'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + Bucket: { Type: 'AWS::S3::Bucket' }, + MyApi: { + Type: 'AWS::ApiGateway::RestApi', + Properties: { + Id: 'res-123', + Description: { 'Fn::Join': ['-', [{ Ref: 'Bucket' }, 'desc']] }, + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockCloudControlClient).toHaveReceivedCommandWith(UpdateResourceCommand, { + TypeName: 'AWS::ApiGateway::RestApi', + Identifier: 'res-123', + PatchDocument: JSON.stringify([{ op: 'replace', path: '/Description', value: 'my-bucket-desc' }]), + }); + }); + + test('does not hotswap when there are no property changes', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + MyApi: { + Type: 'AWS::ApiGateway::RestApi', + Properties: { Id: 'res-123', Description: 'same' }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + MyApi: { + Type: 'AWS::ApiGateway::RestApi', + Properties: { Id: 'res-123', Description: 'same' }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(deployStackResult?.noOp).toEqual(true); + expect(mockCloudControlClient).not.toHaveReceivedCommand(UpdateResourceCommand); + }); +}); + +// Sanity check: each CCAPI-registered resource type can be hotswapped +describe.each([ + 'AWS::ApiGateway::RestApi', + 'AWS::ApiGateway::Method', + 'AWS::ApiGatewayV2::Api', + 'AWS::Bedrock::Agent', + 'AWS::Events::Rule', + 'AWS::DynamoDB::Table', + 'AWS::DynamoDB::GlobalTable', + 'AWS::SQS::Queue', + 'AWS::CloudWatch::Alarm', + 'AWS::CloudWatch::CompositeAlarm', + 'AWS::CloudWatch::Dashboard', + 'AWS::StepFunctions::StateMachine', + 'AWS::BedrockAgentCore::Runtime', +])('CCAPI sanity check for resources where Primary Identifier matches Physical ID %s', (resourceType) => { + beforeEach(() => { + hotswapMockSdkProvider = setup.setupHotswapTests(); + + mockCloudFormationClient.on(DescribeTypeCommand).resolves({ + Schema: JSON.stringify({ primaryIdentifier: ['/properties/Id'] }), + }); + mockCloudControlClient.on(UpdateResourceCommand).resolves({}); + }); + + test('hotswaps a property change via Cloud Control API', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + MyResource: { + Type: resourceType, + Properties: { Id: 'res-123', SomeProp: 'old' }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('MyResource', resourceType, 'res-123'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + MyResource: { + Type: resourceType, + Properties: { Id: 'res-123', SomeProp: 'new' }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(HotswapMode.HOTSWAP_ONLY, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockCloudControlClient).toHaveReceivedCommandWith(UpdateResourceCommand, { + TypeName: resourceType, + Identifier: 'res-123', + PatchDocument: JSON.stringify([{ op: 'replace', path: '/SomeProp', value: 'new' }]), + }); + }); +}); + +// Sanity check: each CCAPI-registered resource type can be hotswapped +describe.each([ + 'AWS::ApiGateway::Deployment', + 'AWS::ApiGatewayV2::Integration', +])('CCAPI sanity check for resources where Primary Identifier does not match Physical ID %s', (resourceType) => { + beforeEach(() => { + hotswapMockSdkProvider = setup.setupHotswapTests(); + + mockCloudFormationClient.on(DescribeTypeCommand).resolves({ + Schema: JSON.stringify({ primaryIdentifier: ['/properties/Id'] }), + }); + mockCloudControlClient.on(UpdateResourceCommand).resolves({}); + }); + + test('hotswaps a property change via Cloud Control API', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + MyResource: { + Type: resourceType, + Properties: { Id: 'res-123', SomeProp: 'old' }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('MyResource', resourceType, 'res-123'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + MyResource: { + Type: resourceType, + Properties: { Id: 'res-123', SomeProp: 'new' }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(HotswapMode.HOTSWAP_ONLY, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockCloudControlClient).toHaveReceivedCommandWith(UpdateResourceCommand, { + TypeName: resourceType, + Identifier: 'res-123|res-123', + PatchDocument: JSON.stringify([{ op: 'replace', path: '/SomeProp', value: 'new' }]), + }); + }); +}); + +describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('Property removal and addition in %p mode', (hotswapMode) => { + beforeEach(() => { + hotswapMockSdkProvider = setup.setupHotswapTests(); + + mockCloudFormationClient.on(DescribeTypeCommand).resolves({ + Schema: JSON.stringify({ primaryIdentifier: ['/properties/TableName'] }), + }); + mockCloudControlClient.on(UpdateResourceCommand).resolves({}); + }); + + test('uses remove op when a property is deleted from the new template', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + Table: { + Type: 'AWS::DynamoDB::Table', + Properties: { + TableName: 'my-table', + BillingMode: 'PROVISIONED', + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('Table', 'AWS::DynamoDB::Table', 'my-table'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + Table: { + Type: 'AWS::DynamoDB::Table', + Properties: { + TableName: 'my-table', + BillingMode: 'PAY_PER_REQUEST', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockCloudControlClient).toHaveReceivedCommandWith(UpdateResourceCommand, { + TypeName: 'AWS::DynamoDB::Table', + Identifier: 'my-table', + PatchDocument: JSON.stringify([ + { op: 'replace', path: '/BillingMode', value: 'PAY_PER_REQUEST' }, + { op: 'remove', path: '/ProvisionedThroughput' }, + ]), + }); + }); + + test('uses add op when a new property is introduced in the new template', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + Table: { + Type: 'AWS::DynamoDB::Table', + Properties: { + TableName: 'my-table', + BillingMode: 'PAY_PER_REQUEST', + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('Table', 'AWS::DynamoDB::Table', 'my-table'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + Table: { + Type: 'AWS::DynamoDB::Table', + Properties: { + TableName: 'my-table', + BillingMode: 'PROVISIONED', + ProvisionedThroughput: { ReadCapacityUnits: 10, WriteCapacityUnits: 10 }, + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockCloudControlClient).toHaveReceivedCommandWith(UpdateResourceCommand, { + TypeName: 'AWS::DynamoDB::Table', + Identifier: 'my-table', + PatchDocument: JSON.stringify([ + { op: 'replace', path: '/BillingMode', value: 'PROVISIONED' }, + { op: 'add', path: '/ProvisionedThroughput', value: { ReadCapacityUnits: 10, WriteCapacityUnits: 10 } }, + ]), + }); + }); +}); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/hotswap/lambda-functions-docker-hotswap-deployments.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/hotswap/lambda-functions-docker-hotswap-deployments.test.ts index e21d321e7..154b14e01 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/hotswap/lambda-functions-docker-hotswap-deployments.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/hotswap/lambda-functions-docker-hotswap-deployments.test.ts @@ -118,9 +118,9 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot // THEN expect(waitUntilFunctionUpdatedV2).toHaveBeenCalledWith( expect.objectContaining({ - minDelay: 5, - maxDelay: 5, - maxWaitTime: 5 * 60, + minDelay: 1, + maxDelay: 10, + maxWaitTime: 10 * 60, }), { FunctionName: 'my-function' }, ); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/hotswap/lambda-functions-hotswap-deployments.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/hotswap/lambda-functions-hotswap-deployments.test.ts index 8308f6a5e..3fa49be22 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/hotswap/lambda-functions-hotswap-deployments.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/hotswap/lambda-functions-hotswap-deployments.test.ts @@ -618,8 +618,8 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot expect(waitUntilFunctionUpdatedV2).toHaveBeenCalledWith( expect.objectContaining({ minDelay: 1, - maxDelay: 1, - maxWaitTime: 1 * 60, + maxDelay: 10, + maxWaitTime: 10 * 60, }), { FunctionName: 'my-function' }, ); @@ -676,8 +676,8 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot expect(waitUntilFunctionUpdatedV2).toHaveBeenCalledWith( expect.objectContaining({ minDelay: 1, - maxDelay: 1, - maxWaitTime: 1 * 60, + maxDelay: 10, + maxWaitTime: 10 * 60, }), { FunctionName: 'my-function' }, ); @@ -733,9 +733,9 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot // THEN expect(waitUntilFunctionUpdatedV2).toHaveBeenCalledWith( expect.objectContaining({ - minDelay: 5, - maxDelay: 5, - maxWaitTime: 5 * 60, + minDelay: 1, + maxDelay: 10, + maxWaitTime: 10 * 60, }), { FunctionName: 'my-function' }, ); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/hotswap/state-machine-hotswap-deployments.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/hotswap/state-machine-hotswap-deployments.test.ts index 67215d2a4..471776843 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/hotswap/state-machine-hotswap-deployments.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/hotswap/state-machine-hotswap-deployments.test.ts @@ -1,12 +1,31 @@ -import { UpdateStateMachineCommand } from '@aws-sdk/client-sfn'; +import { GetResourceCommand, UpdateResourceCommand } from '@aws-sdk/client-cloudcontrol'; +import { DescribeTypeCommand } from '@aws-sdk/client-cloudformation'; import { HotswapMode } from '../../../lib/api/hotswap'; -import { mockStepFunctionsClient } from '../../_helpers/mock-sdk'; +import { mockCloudControlClient, mockCloudFormationClient } from '../../_helpers/mock-sdk'; import * as setup from '../_helpers/hotswap-test-setup'; let hotswapMockSdkProvider: setup.HotswapMockSdkProvider; beforeEach(() => { hotswapMockSdkProvider = setup.setupHotswapTests(); + + mockCloudFormationClient.on(DescribeTypeCommand).resolves({ + Schema: JSON.stringify({ + primaryIdentifier: ['/properties/Arn'], + }), + }); + + mockCloudControlClient.on(GetResourceCommand).resolves({ + ResourceDescription: { + Properties: JSON.stringify({ + Arn: 'arn:swa:states:here:123456789012:stateMachine:my-machine', + StateMachineName: 'my-machine', + DefinitionString: '{ Prop: "old-value" }', + }), + }, + }); + + mockCloudControlClient.on(UpdateResourceCommand).resolves({}); }); describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hotswapMode) => { @@ -23,300 +42,219 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }); if (hotswapMode === HotswapMode.FALL_BACK) { - // WHEN const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN expect(deployStackResult).toBeUndefined(); - expect(mockStepFunctionsClient).not.toHaveReceivedCommand(UpdateStateMachineCommand); - } else if (hotswapMode === HotswapMode.HOTSWAP_ONLY) { - // WHEN + expect(mockCloudControlClient).not.toHaveReceivedCommand(UpdateResourceCommand); + } else { const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN expect(deployStackResult).not.toBeUndefined(); expect(deployStackResult?.noOp).toEqual(true); - expect(mockStepFunctionsClient).not.toHaveReceivedCommand(UpdateStateMachineCommand); + expect(mockCloudControlClient).not.toHaveReceivedCommand(UpdateResourceCommand); } }); - test( - 'calls the updateStateMachine() API when it receives only a definitionString change without Fn::Join in a state machine', - async () => { - // GIVEN - setup.setCurrentCfnStackTemplate({ + test('calls Cloud Control updateResource when it receives only a definitionString change', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + Machine: { + Type: 'AWS::StepFunctions::StateMachine', + Properties: { + DefinitionString: '{ Prop: "old-value" }', + StateMachineName: 'my-machine', + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('Machine', 'AWS::StepFunctions::StateMachine', 'arn:swa:states:here:123456789012:stateMachine:my-machine'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { Resources: { Machine: { Type: 'AWS::StepFunctions::StateMachine', Properties: { - DefinitionString: '{ Prop: "old-value" }', + DefinitionString: '{ Prop: "new-value" }', StateMachineName: 'my-machine', }, }, }, - }); - const cdkStackArtifact = setup.cdkStackArtifactOf({ - template: { - Resources: { - Machine: { - Type: 'AWS::StepFunctions::StateMachine', - Properties: { - DefinitionString: '{ Prop: "new-value" }', - StateMachineName: 'my-machine', - }, - }, - }, - }, - }); + }, + }); - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - // THEN - expect(deployStackResult).not.toBeUndefined(); - expect(mockStepFunctionsClient).toHaveReceivedCommandWith(UpdateStateMachineCommand, { - definition: '{ Prop: "new-value" }', - stateMachineArn: 'arn:swa:states:here:123456789012:stateMachine:my-machine', - }); - }, - ); + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockCloudControlClient).toHaveReceivedCommandWith(UpdateResourceCommand, { + TypeName: 'AWS::StepFunctions::StateMachine', + Identifier: 'arn:swa:states:here:123456789012:stateMachine:my-machine', + PatchDocument: JSON.stringify([{ + op: 'replace', + path: '/DefinitionString', + value: '{ Prop: "new-value" }', + }]), + }); + }); - test( - 'calls the updateStateMachine() API when it receives only a definitionString change with Fn::Join in a state machine', - async () => { - // GIVEN - setup.setCurrentCfnStackTemplate({ + test('calls Cloud Control updateResource when it receives a definitionString change with Fn::Join', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + Machine: { + Type: 'AWS::StepFunctions::StateMachine', + Properties: { + DefinitionString: { + 'Fn::Join': ['\n', ['{', ' "StartAt" : "SuccessState"', '}']], + }, + StateMachineName: 'my-machine', + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('Machine', 'AWS::StepFunctions::StateMachine', 'arn:swa:states:here:123456789012:stateMachine:my-machine'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { Resources: { Machine: { Type: 'AWS::StepFunctions::StateMachine', Properties: { DefinitionString: { - 'Fn::Join': [ - '\n', - [ - '{', - ' "StartAt" : "SuccessState"', - ' "States" : {', - ' "SuccessState": {', - ' "Type": "Pass"', - ' "Result": "Success"', - ' "End": true', - ' }', - ' }', - '}', - ], - ], + 'Fn::Join': ['\n', ['{', ' "StartAt": "FailState"', '}']], }, StateMachineName: 'my-machine', }, }, }, - }); - const cdkStackArtifact = setup.cdkStackArtifactOf({ - template: { - Resources: { - Machine: { - Type: 'AWS::StepFunctions::StateMachine', - Properties: { - DefinitionString: { - 'Fn::Join': [ - '\n', - [ - '{', - ' "StartAt": "SuccessState",', - ' "States": {', - ' "SuccessState": {', - ' "Type": "Succeed"', - ' }', - ' }', - '}', - ], - ], - }, - StateMachineName: 'my-machine', - }, - }, - }, - }, - }); + }, + }); - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - // THEN - expect(deployStackResult).not.toBeUndefined(); - expect(mockStepFunctionsClient).toHaveReceivedCommandWith(UpdateStateMachineCommand, { - definition: JSON.stringify( - { - StartAt: 'SuccessState', - States: { - SuccessState: { - Type: 'Succeed', - }, - }, - }, - null, - 2, - ), - stateMachineArn: 'arn:swa:states:here:123456789012:stateMachine:my-machine', - }); - }, - ); + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockCloudControlClient).toHaveReceivedCommand(UpdateResourceCommand); + }); - test( - 'calls the updateStateMachine() API when it receives a change to the definitionString in a state machine that has no name', - async () => { - // GIVEN - setup.setCurrentCfnStackTemplate({ + test('calls Cloud Control updateResource when the state machine has no name', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + Machine: { + Type: 'AWS::StepFunctions::StateMachine', + Properties: { + DefinitionString: '{ "Prop" : "old-value" }', + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('Machine', 'AWS::StepFunctions::StateMachine', 'arn:swa:states:here:123456789012:stateMachine:my-machine'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { Resources: { Machine: { Type: 'AWS::StepFunctions::StateMachine', Properties: { - DefinitionString: '{ "Prop" : "old-value" }', + DefinitionString: '{ "Prop" : "new-value" }', }, }, }, - }); - const cdkStackArtifact = setup.cdkStackArtifactOf({ - template: { - Resources: { - Machine: { - Type: 'AWS::StepFunctions::StateMachine', - Properties: { - DefinitionString: '{ "Prop" : "new-value" }', - }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockCloudControlClient).toHaveReceivedCommand(UpdateResourceCommand); + }); + + test('hotswaps a non-DefinitionString property change via Cloud Control API (all properties are hotswappable via CCAPI)', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + Machine: { + Type: 'AWS::StepFunctions::StateMachine', + Properties: { + DefinitionString: '{ "Prop" : "old-value" }', + LoggingConfiguration: { + IncludeExecutionData: true, }, }, }, - }); - - // WHEN - setup.pushStackResourceSummaries( - setup.stackSummaryOf( - 'Machine', - 'AWS::StepFunctions::StateMachine', - 'arn:swa:states:here:123456789012:stateMachine:my-machine', - ), - ); - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN - expect(deployStackResult).not.toBeUndefined(); - expect(mockStepFunctionsClient).toHaveReceivedCommandWith(UpdateStateMachineCommand, { - definition: '{ "Prop" : "new-value" }', - stateMachineArn: 'arn:swa:states:here:123456789012:stateMachine:my-machine', - }); - }, - ); - - test( - `does not call the updateStateMachine() API when it receives a change to a property that is not the definitionString in a state machine - alongside a hotswappable change in CLASSIC mode but does in HOTSWAP_ONLY mode`, - async () => { - // GIVEN - setup.setCurrentCfnStackTemplate({ + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('Machine', 'AWS::StepFunctions::StateMachine', 'arn:swa:states:here:123456789012:stateMachine:my-machine'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { Resources: { Machine: { Type: 'AWS::StepFunctions::StateMachine', Properties: { - DefinitionString: '{ "Prop" : "old-value" }', + DefinitionString: '{ "Prop" : "new-value" }', LoggingConfiguration: { - // non-definitionString property - IncludeExecutionData: true, + IncludeExecutionData: false, }, }, }, }, - }); - const cdkStackArtifact = setup.cdkStackArtifactOf({ - template: { - Resources: { - Machine: { - Type: 'AWS::StepFunctions::StateMachine', - Properties: { - DefinitionString: '{ "Prop" : "new-value" }', - LoggingConfiguration: { - IncludeExecutionData: false, - }, - }, - }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockCloudControlClient).toHaveReceivedCommand(UpdateResourceCommand); + }); + + test('does not call Cloud Control when a resource with a non-StateMachine type is changed', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + Machine: { + Type: 'AWS::NotStepFunctions::NotStateMachine', + Properties: { + DefinitionString: '{ Prop: "old-value" }', }, }, - }); - - setup.pushStackResourceSummaries( - setup.stackSummaryOf( - 'Machine', - 'AWS::StepFunctions::StateMachine', - 'arn:swa:states:here:123456789012:stateMachine:my-machine', - ), - ); - if (hotswapMode === HotswapMode.FALL_BACK) { - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN - expect(deployStackResult).toBeUndefined(); - expect(mockStepFunctionsClient).not.toHaveReceivedCommand(UpdateStateMachineCommand); - } else if (hotswapMode === HotswapMode.HOTSWAP_ONLY) { - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN - expect(deployStackResult).not.toBeUndefined(); - expect(mockStepFunctionsClient).toHaveReceivedCommandWith(UpdateStateMachineCommand, { - definition: '{ "Prop" : "new-value" }', - stateMachineArn: 'arn:swa:states:here:123456789012:stateMachine:my-machine', - }); - } - }, - ); - - test( - 'does not call the updateStateMachine() API when a resource has a DefinitionString property but is not an AWS::StepFunctions::StateMachine is changed', - async () => { - // GIVEN - setup.setCurrentCfnStackTemplate({ + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { Resources: { Machine: { Type: 'AWS::NotStepFunctions::NotStateMachine', Properties: { - DefinitionString: '{ Prop: "old-value" }', - }, - }, - }, - }); - const cdkStackArtifact = setup.cdkStackArtifactOf({ - template: { - Resources: { - Machine: { - Type: 'AWS::NotStepFunctions::NotStateMachine', - Properties: { - DefinitionString: '{ Prop: "new-value" }', - }, + DefinitionString: '{ Prop: "new-value" }', }, }, }, - }); - - if (hotswapMode === HotswapMode.FALL_BACK) { - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN - expect(deployStackResult).toBeUndefined(); - expect(mockStepFunctionsClient).not.toHaveReceivedCommand(UpdateStateMachineCommand); - } else if (hotswapMode === HotswapMode.HOTSWAP_ONLY) { - // WHEN - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN - expect(deployStackResult).not.toBeUndefined(); - expect(deployStackResult?.noOp).toEqual(true); - expect(mockStepFunctionsClient).not.toHaveReceivedCommand(UpdateStateMachineCommand); - } - }, - ); + }, + }); + + if (hotswapMode === HotswapMode.FALL_BACK) { + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + expect(deployStackResult).toBeUndefined(); + expect(mockCloudControlClient).not.toHaveReceivedCommand(UpdateResourceCommand); + } else { + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + expect(deployStackResult).not.toBeUndefined(); + expect(deployStackResult?.noOp).toEqual(true); + expect(mockCloudControlClient).not.toHaveReceivedCommand(UpdateResourceCommand); + } + }); test('can correctly hotswap old style synth changes', async () => { // GIVEN @@ -332,6 +270,9 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }, }, }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('Machine', 'AWS::StepFunctions::StateMachine', 'arn:swa:states:here:123456789012:stateMachine:machine-name'), + ); const cdkStackArtifact = setup.cdkStackArtifactOf({ template: { Parameters: { AssetParam2: { Type: String } }, @@ -348,212 +289,172 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }); // WHEN - setup.pushStackResourceSummaries( - setup.stackSummaryOf( - 'Machine', - 'AWS::StepFunctions::StateMachine', - 'arn:swa:states:here:123456789012:stateMachine:my-machine', - ), - ); const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact, { AssetParam2: 'asset-param-2', }); // THEN expect(deployStackResult).not.toBeUndefined(); - expect(mockStepFunctionsClient).toHaveReceivedCommandWith(UpdateStateMachineCommand, { - definition: 'asset-param-2', - stateMachineArn: 'arn:swa:states:here:123456789012:stateMachine:machine-name', + expect(mockCloudControlClient).toHaveReceivedCommandWith(UpdateResourceCommand, { + TypeName: 'AWS::StepFunctions::StateMachine', + Identifier: 'arn:swa:states:here:123456789012:stateMachine:machine-name', + PatchDocument: JSON.stringify([{ + op: 'replace', + path: '/DefinitionString', + value: 'asset-param-2', + }]), }); }); - test( - 'calls the updateStateMachine() API when it receives a change to the definitionString that uses Attributes in a state machine', - async () => { - // GIVEN - setup.setCurrentCfnStackTemplate({ - Resources: { - Func: { - Type: 'AWS::Lambda::Function', + test('calls Cloud Control updateResource when definitionString uses Fn::GetAtt', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + Func: { Type: 'AWS::Lambda::Function' }, + Machine: { + Type: 'AWS::StepFunctions::StateMachine', + Properties: { + DefinitionString: { + 'Fn::Join': ['\n', ['{', ' "StartAt" : "SuccessState"', '}']], + }, + StateMachineName: 'my-machine', }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('Machine', 'AWS::StepFunctions::StateMachine', 'arn:swa:states:here:123456789012:stateMachine:my-machine'), + setup.stackSummaryOf('Func', 'AWS::Lambda::Function', 'my-func'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + Func: { Type: 'AWS::Lambda::Function' }, Machine: { Type: 'AWS::StepFunctions::StateMachine', Properties: { DefinitionString: { - 'Fn::Join': [ - '\n', - [ - '{', - ' "StartAt" : "SuccessState"', - ' "States" : {', - ' "SuccessState": {', - ' "Type": "Succeed"', - ' }', - ' }', - '}', - ], - ], + 'Fn::Join': ['', ['"Resource": ', { 'Fn::GetAtt': ['Func', 'Arn'] }]], }, StateMachineName: 'my-machine', }, }, }, - }); - const cdkStackArtifact = setup.cdkStackArtifactOf({ - template: { - Resources: { - Func: { - Type: 'AWS::Lambda::Function', - }, - Machine: { - Type: 'AWS::StepFunctions::StateMachine', - Properties: { - DefinitionString: { - 'Fn::Join': ['', ['"Resource": ', { 'Fn::GetAtt': ['Func', 'Arn'] }]], - }, - StateMachineName: 'my-machine', - }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockCloudControlClient).toHaveReceivedCommandWith(UpdateResourceCommand, { + TypeName: 'AWS::StepFunctions::StateMachine', + Identifier: 'arn:swa:states:here:123456789012:stateMachine:my-machine', + PatchDocument: JSON.stringify([{ + op: 'replace', + path: '/DefinitionString', + value: '"Resource": arn:swa:lambda:here:123456789012:function:my-func', + }]), + }); + }); + + test('will not perform a hotswap deployment if it cannot find a Ref target', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Parameters: { Param1: { Type: 'String' } }, + Resources: { + Machine: { + Type: 'AWS::StepFunctions::StateMachine', + Properties: { + DefinitionString: { + 'Fn::Join': ['', ['{ Prop: "old-value" }, ', '{ "Param" : ', { 'Fn::Sub': '${Param1}' }, ' }']], }, }, }, - }); - - // WHEN - setup.pushStackResourceSummaries( - setup.stackSummaryOf( - 'Machine', - 'AWS::StepFunctions::StateMachine', - 'arn:swa:states:here:123456789012:stateMachine:my-machine', - ), - setup.stackSummaryOf('Func', 'AWS::Lambda::Function', 'my-func'), - ); - const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); - - // THEN - expect(deployStackResult).not.toBeUndefined(); - expect(mockStepFunctionsClient).toHaveReceivedCommandWith(UpdateStateMachineCommand, { - definition: '"Resource": arn:swa:lambda:here:123456789012:function:my-func', - stateMachineArn: 'arn:swa:states:here:123456789012:stateMachine:my-machine', - }); - }, - ); - - test( - "will not perform a hotswap deployment if it cannot find a Ref target (outside the state machine's name)", - async () => { - // GIVEN - setup.setCurrentCfnStackTemplate({ - Parameters: { - Param1: { Type: 'String' }, - }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('Machine', 'AWS::StepFunctions::StateMachine', 'arn:swa:states:here:123456789012:stateMachine:my-machine'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Parameters: { Param1: { Type: 'String' } }, Resources: { Machine: { Type: 'AWS::StepFunctions::StateMachine', Properties: { DefinitionString: { - 'Fn::Join': ['', ['{ Prop: "old-value" }, ', '{ "Param" : ', { 'Fn::Sub': '${Param1}' }, ' }']], + 'Fn::Join': ['', ['{ Prop: "new-value" }, ', '{ "Param" : ', { 'Fn::Sub': '${Param1}' }, ' }']], }, }, }, }, - }); - setup.pushStackResourceSummaries( - setup.stackSummaryOf('Machine', 'AWS::StepFunctions::StateMachine', 'my-machine'), - ); - const cdkStackArtifact = setup.cdkStackArtifactOf({ - template: { - Parameters: { - Param1: { Type: 'String' }, - }, - Resources: { - Machine: { - Type: 'AWS::StepFunctions::StateMachine', - Properties: { - DefinitionString: { - 'Fn::Join': ['', ['{ Prop: "new-value" }, ', '{ "Param" : ', { 'Fn::Sub': '${Param1}' }, ' }']], - }, - }, + }, + }); + + if (hotswapMode === HotswapMode.FALL_BACK) { + // THEN – falls back because the property can't be resolved + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + expect(deployStackResult).toBeUndefined(); + expect(mockCloudControlClient).not.toHaveReceivedCommand(UpdateResourceCommand); + } else { + // THEN – marked non-hotswappable, noOp + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + expect(deployStackResult).not.toBeUndefined(); + expect(deployStackResult?.noOp).toEqual(true); + expect(mockCloudControlClient).not.toHaveReceivedCommand(UpdateResourceCommand); + } + }); + + test("will not perform a hotswap deployment if it doesn't know how to handle a specific attribute", async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + Bucket: { Type: 'AWS::S3::Bucket' }, + Machine: { + Type: 'AWS::StepFunctions::StateMachine', + Properties: { + DefinitionString: { + 'Fn::Join': ['', ['{ Prop: "old-value" }, ', '{ "S3Bucket" : ', { 'Fn::GetAtt': ['Bucket', 'UnknownAttribute'] }, ' }']], }, + StateMachineName: 'my-machine', }, }, - }); - - // THEN - await expect(() => hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact)).rejects.toThrow( - /Parameter or resource 'Param1' could not be found for evaluation/, - ); - }, - ); - - test( - "will not perform a hotswap deployment if it doesn't know how to handle a specific attribute (outside the state machines's name)", - async () => { - // GIVEN - setup.setCurrentCfnStackTemplate({ + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('Machine', 'AWS::StepFunctions::StateMachine', 'arn:swa:states:here:123456789012:stateMachine:my-machine'), + setup.stackSummaryOf('Bucket', 'AWS::S3::Bucket', 'my-bucket'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - }, + Bucket: { Type: 'AWS::S3::Bucket' }, Machine: { Type: 'AWS::StepFunctions::StateMachine', Properties: { DefinitionString: { - 'Fn::Join': [ - '', - [ - '{ Prop: "old-value" }, ', - '{ "S3Bucket" : ', - { 'Fn::GetAtt': ['Bucket', 'UnknownAttribute'] }, - ' }', - ], - ], + 'Fn::Join': ['', ['{ Prop: "new-value" }, ', '{ "S3Bucket" : ', { 'Fn::GetAtt': ['Bucket', 'UnknownAttribute'] }, ' }']], }, StateMachineName: 'my-machine', }, }, }, - }); - setup.pushStackResourceSummaries( - setup.stackSummaryOf( - 'Machine', - 'AWS::StepFunctions::StateMachine', - 'arn:swa:states:here:123456789012:stateMachine:my-machine', - ), - setup.stackSummaryOf('Bucket', 'AWS::S3::Bucket', 'my-bucket'), - ); - const cdkStackArtifact = setup.cdkStackArtifactOf({ - template: { - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - }, - Machine: { - Type: 'AWS::StepFunctions::StateMachine', - Properties: { - DefinitionString: { - 'Fn::Join': [ - '', - [ - '{ Prop: "new-value" }, ', - '{ "S3Bucket" : ', - { 'Fn::GetAtt': ['Bucket', 'UnknownAttribute'] }, - ' }', - ], - ], - }, - StateMachineName: 'my-machine', - }, - }, - }, - }, - }); + }, + }); - // THEN - await expect(() => hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact)).rejects.toThrow( - "We don't support the 'UnknownAttribute' attribute of the 'AWS::S3::Bucket' resource. This is a CDK limitation. Please report it at https://github.com/aws/aws-cdk/issues/new/choose", - ); - }, - ); + if (hotswapMode === HotswapMode.FALL_BACK) { + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + expect(deployStackResult).toBeUndefined(); + expect(mockCloudControlClient).not.toHaveReceivedCommand(UpdateResourceCommand); + } else { + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + expect(deployStackResult).not.toBeUndefined(); + expect(deployStackResult?.noOp).toEqual(true); + expect(mockCloudControlClient).not.toHaveReceivedCommand(UpdateResourceCommand); + } + }); test('knows how to handle attributes of the AWS::Events::EventBus resource', async () => { // GIVEN @@ -561,58 +462,43 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot Resources: { EventBus: { Type: 'AWS::Events::EventBus', - Properties: { - Name: 'my-event-bus', - }, + Properties: { Name: 'my-event-bus' }, }, Machine: { Type: 'AWS::StepFunctions::StateMachine', Properties: { DefinitionString: { - 'Fn::Join': [ - '', - [ - '{"EventBus1Arn":"', - { 'Fn::GetAtt': ['EventBus', 'Arn'] }, - '","EventBus1Name":"', - { 'Fn::GetAtt': ['EventBus', 'Name'] }, - '","EventBus1Ref":"', - { Ref: 'EventBus' }, - '"}', - ], - ], + 'Fn::Join': ['', [ + '{"EventBus1Arn":"', { 'Fn::GetAtt': ['EventBus', 'Arn'] }, + '","EventBus1Name":"', { 'Fn::GetAtt': ['EventBus', 'Name'] }, + '","EventBus1Ref":"', { Ref: 'EventBus' }, '"}', + ]], }, StateMachineName: 'my-machine', }, }, }, }); - setup.pushStackResourceSummaries(setup.stackSummaryOf('EventBus', 'AWS::Events::EventBus', 'my-event-bus')); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('EventBus', 'AWS::Events::EventBus', 'my-event-bus'), + setup.stackSummaryOf('Machine', 'AWS::StepFunctions::StateMachine', 'arn:swa:states:here:123456789012:stateMachine:my-machine'), + ); const cdkStackArtifact = setup.cdkStackArtifactOf({ template: { Resources: { EventBus: { Type: 'AWS::Events::EventBus', - Properties: { - Name: 'my-event-bus', - }, + Properties: { Name: 'my-event-bus' }, }, Machine: { Type: 'AWS::StepFunctions::StateMachine', Properties: { DefinitionString: { - 'Fn::Join': [ - '', - [ - '{"EventBus2Arn":"', - { 'Fn::GetAtt': ['EventBus', 'Arn'] }, - '","EventBus2Name":"', - { 'Fn::GetAtt': ['EventBus', 'Name'] }, - '","EventBus2Ref":"', - { Ref: 'EventBus' }, - '"}', - ], - ], + 'Fn::Join': ['', [ + '{"EventBus2Arn":"', { 'Fn::GetAtt': ['EventBus', 'Arn'] }, + '","EventBus2Name":"', { 'Fn::GetAtt': ['EventBus', 'Name'] }, + '","EventBus2Ref":"', { Ref: 'EventBus' }, '"}', + ]], }, StateMachineName: 'my-machine', }, @@ -621,17 +507,23 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }, }); - // THEN + // WHEN const result = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + // THEN expect(result).not.toBeUndefined(); - expect(mockStepFunctionsClient).toHaveReceivedCommandWith(UpdateStateMachineCommand, { - stateMachineArn: 'arn:swa:states:here:123456789012:stateMachine:my-machine', - definition: JSON.stringify({ - EventBus2Arn: 'arn:swa:events:here:123456789012:event-bus/my-event-bus', - EventBus2Name: 'my-event-bus', - EventBus2Ref: 'my-event-bus', - }), + expect(mockCloudControlClient).toHaveReceivedCommandWith(UpdateResourceCommand, { + TypeName: 'AWS::StepFunctions::StateMachine', + Identifier: 'arn:swa:states:here:123456789012:stateMachine:my-machine', + PatchDocument: JSON.stringify([{ + op: 'replace', + path: '/DefinitionString', + value: JSON.stringify({ + EventBus2Arn: 'arn:swa:events:here:123456789012:event-bus/my-event-bus', + EventBus2Name: 'my-event-bus', + EventBus2Ref: 'my-event-bus', + }), + }]), }); }); @@ -642,18 +534,8 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot Table: { Type: 'AWS::DynamoDB::Table', Properties: { - KeySchema: [ - { - AttributeName: 'name', - KeyType: 'HASH', - }, - ], - AttributeDefinitions: [ - { - AttributeName: 'name', - AttributeType: 'S', - }, - ], + KeySchema: [{ AttributeName: 'name', KeyType: 'HASH' }], + AttributeDefinitions: [{ AttributeName: 'name', AttributeType: 'S' }], BillingMode: 'PAY_PER_REQUEST', }, }, @@ -666,25 +548,18 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }, }, }); - setup.pushStackResourceSummaries(setup.stackSummaryOf('Table', 'AWS::DynamoDB::Table', 'my-dynamodb-table')); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('Table', 'AWS::DynamoDB::Table', 'my-dynamodb-table'), + setup.stackSummaryOf('Machine', 'AWS::StepFunctions::StateMachine', 'arn:swa:states:here:123456789012:stateMachine:my-machine'), + ); const cdkStackArtifact = setup.cdkStackArtifactOf({ template: { Resources: { Table: { Type: 'AWS::DynamoDB::Table', Properties: { - KeySchema: [ - { - AttributeName: 'name', - KeyType: 'HASH', - }, - ], - AttributeDefinitions: [ - { - AttributeName: 'name', - AttributeType: 'S', - }, - ], + KeySchema: [{ AttributeName: 'name', KeyType: 'HASH' }], + AttributeDefinitions: [{ AttributeName: 'name', AttributeType: 'S' }], BillingMode: 'PAY_PER_REQUEST', }, }, @@ -692,10 +567,10 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot Type: 'AWS::StepFunctions::StateMachine', Properties: { DefinitionString: { - 'Fn::Join': [ - '', - ['{"TableName":"', { Ref: 'Table' }, '","TableArn":"', { 'Fn::GetAtt': ['Table', 'Arn'] }, '"}'], - ], + 'Fn::Join': ['', [ + '{"TableName":"', { Ref: 'Table' }, + '","TableArn":"', { 'Fn::GetAtt': ['Table', 'Arn'] }, '"}', + ]], }, StateMachineName: 'my-machine', }, @@ -704,16 +579,22 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }, }); - // THEN + // WHEN const result = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + // THEN expect(result).not.toBeUndefined(); - expect(mockStepFunctionsClient).toHaveReceivedCommandWith(UpdateStateMachineCommand, { - stateMachineArn: 'arn:swa:states:here:123456789012:stateMachine:my-machine', - definition: JSON.stringify({ - TableName: 'my-dynamodb-table', - TableArn: 'arn:swa:dynamodb:here:123456789012:table/my-dynamodb-table', - }), + expect(mockCloudControlClient).toHaveReceivedCommandWith(UpdateResourceCommand, { + TypeName: 'AWS::StepFunctions::StateMachine', + Identifier: 'arn:swa:states:here:123456789012:stateMachine:my-machine', + PatchDocument: JSON.stringify([{ + op: 'replace', + path: '/DefinitionString', + value: JSON.stringify({ + TableName: 'my-dynamodb-table', + TableArn: 'arn:swa:dynamodb:here:123456789012:table/my-dynamodb-table', + }), + }]), }); }); @@ -723,9 +604,7 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot Resources: { Key: { Type: 'AWS::KMS::Key', - Properties: { - Description: 'magic-key', - }, + Properties: { Description: 'magic-key' }, }, Machine: { Type: 'AWS::StepFunctions::StateMachine', @@ -736,24 +615,25 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }, }, }); - setup.pushStackResourceSummaries(setup.stackSummaryOf('Key', 'AWS::KMS::Key', 'a-key')); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('Key', 'AWS::KMS::Key', 'a-key'), + setup.stackSummaryOf('Machine', 'AWS::StepFunctions::StateMachine', 'arn:swa:states:here:123456789012:stateMachine:my-machine'), + ); const cdkStackArtifact = setup.cdkStackArtifactOf({ template: { Resources: { Key: { Type: 'AWS::KMS::Key', - Properties: { - Description: 'magic-key', - }, + Properties: { Description: 'magic-key' }, }, Machine: { Type: 'AWS::StepFunctions::StateMachine', Properties: { DefinitionString: { - 'Fn::Join': [ - '', - ['{"KeyId":"', { Ref: 'Key' }, '","KeyArn":"', { 'Fn::GetAtt': ['Key', 'Arn'] }, '"}'], - ], + 'Fn::Join': ['', [ + '{"KeyId":"', { Ref: 'Key' }, + '","KeyArn":"', { 'Fn::GetAtt': ['Key', 'Arn'] }, '"}', + ]], }, StateMachineName: 'my-machine', }, @@ -762,16 +642,22 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }, }); - // THEN + // WHEN const result = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + // THEN expect(result).not.toBeUndefined(); - expect(mockStepFunctionsClient).toHaveReceivedCommandWith(UpdateStateMachineCommand, { - stateMachineArn: 'arn:swa:states:here:123456789012:stateMachine:my-machine', - definition: JSON.stringify({ - KeyId: 'a-key', - KeyArn: 'arn:swa:kms:here:123456789012:key/a-key', - }), + expect(mockCloudControlClient).toHaveReceivedCommandWith(UpdateResourceCommand, { + TypeName: 'AWS::StepFunctions::StateMachine', + Identifier: 'arn:swa:states:here:123456789012:stateMachine:my-machine', + PatchDocument: JSON.stringify([{ + op: 'replace', + path: '/DefinitionString', + value: JSON.stringify({ + KeyId: 'a-key', + KeyArn: 'arn:swa:kms:here:123456789012:key/a-key', + }), + }]), }); }); @@ -810,6 +696,6 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot // THEN expect(deployStackResult).not.toBeUndefined(); expect(deployStackResult?.noOp).toEqual(true); - expect(mockStepFunctionsClient).not.toHaveReceivedCommand(UpdateStateMachineCommand); + expect(mockCloudControlClient).not.toHaveReceivedCommand(UpdateResourceCommand); }); });