diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextIntegDefaultTestDeployAssert9187EC06.assets.json b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextIntegDefaultTestDeployAssert9187EC06.assets.json new file mode 100644 index 0000000000000..1d21bfdcf6340 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextIntegDefaultTestDeployAssert9187EC06.assets.json @@ -0,0 +1,20 @@ +{ + "version": "54.0.0", + "files": { + "21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22": { + "displayName": "MetadataContextIntegDefaultTestDeployAssert9187EC06 Template", + "source": { + "path": "MetadataContextIntegDefaultTestDeployAssert9187EC06.template.json", + "packaging": "file" + }, + "destinations": { + "current_account-current_region-d8d86b35": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22.json", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextIntegDefaultTestDeployAssert9187EC06.metadata.json b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextIntegDefaultTestDeployAssert9187EC06.metadata.json new file mode 100644 index 0000000000000..fcbc4b73587ee --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextIntegDefaultTestDeployAssert9187EC06.metadata.json @@ -0,0 +1,14 @@ +{ + "/MetadataContextInteg/DefaultTest/DeployAssert/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/MetadataContextInteg/DefaultTest/DeployAssert/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ] +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextIntegDefaultTestDeployAssert9187EC06.template.json b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextIntegDefaultTestDeployAssert9187EC06.template.json new file mode 100644 index 0000000000000..ad9d0fb73d1dd --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextIntegDefaultTestDeployAssert9187EC06.template.json @@ -0,0 +1,36 @@ +{ + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextTestStack.assets.json b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextTestStack.assets.json new file mode 100644 index 0000000000000..60bb38c7a1a1b --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextTestStack.assets.json @@ -0,0 +1,20 @@ +{ + "version": "54.0.0", + "files": { + "5c79dae7d802ed0f71a89fba0f78e8060710c1188920589e08449635f0ed2d0e": { + "displayName": "MetadataContextTestStack Template", + "source": { + "path": "MetadataContextTestStack.template.json", + "packaging": "file" + }, + "destinations": { + "current_account-current_region-9a054d3c": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "5c79dae7d802ed0f71a89fba0f78e8060710c1188920589e08449635f0ed2d0e.json", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextTestStack.metadata.json b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextTestStack.metadata.json new file mode 100644 index 0000000000000..e950796def390 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextTestStack.metadata.json @@ -0,0 +1,40 @@ +{ + "/MetadataContextTestStack/OrderQueue": [ + { + "type": "aws:cdk:analytics:construct", + "data": { + "encryption": "SQS_MANAGED" + } + } + ], + "/MetadataContextTestStack/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/MetadataContextTestStack/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ], + "/MetadataContextTestStack/OrderQueue/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "OrderQueue39B99167" + } + ], + "/MetadataContextTestStack/Notifications/AlertsTopic": [ + { + "type": "aws:cdk:analytics:construct", + "data": "*" + } + ], + "/MetadataContextTestStack/Notifications/AlertsTopic/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "NotificationsAlertsTopicDFE3487E" + } + ] +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextTestStack.template.json b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextTestStack.template.json new file mode 100644 index 0000000000000..af4edfd050000 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextTestStack.template.json @@ -0,0 +1,87 @@ +{ + "Description": "integ test stack for MetadataContext; exercises resource + template level context", + "Metadata": { + "com.aws.cloudformation.Context": { + "arch": "SQS buffer -> consumer; DLQ for poison msgs", + "must": [ + "all queues encrypted w/ SSE" + ], + "ref": [ + { + "at": "context/shared/encryption.ctx.yaml", + "has": "org CMK + tagging rules", + "scope": "shared" + } + ], + "owner": "framework-integ-team" + } + }, + "Resources": { + "OrderQueue39B99167": { + "Type": "AWS::SQS::Queue", + "Properties": { + "SqsManagedSseEnabled": true + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "com.aws.cloudformation.Context": { + "why": "buffer order events async; std queue (throughput > ordering)", + "must": [ + "VisTimeout >= 6x consumer timeout, else dup on retry" + ], + "mutable": "change-with-constraints", + "mutability": { + "QueueName": "must-never-change" + }, + "trust": { + "src": "authored", + "conf": "high" + } + } + } + }, + "NotificationsAlertsTopicDFE3487E": { + "Type": "AWS::SNS::Topic", + "Metadata": { + "com.aws.cloudformation.Context": { + "why": "fan-out of alert events to oncall channels" + } + } + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/cdk.out b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/cdk.out new file mode 100644 index 0000000000000..433ef06634165 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/cdk.out @@ -0,0 +1 @@ +{"version":"54.0.0"} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/integ.json b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/integ.json new file mode 100644 index 0000000000000..bb23ab4d63646 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/integ.json @@ -0,0 +1,14 @@ +{ + "version": "54.0.0", + "testCases": { + "MetadataContextInteg/DefaultTest": { + "stacks": [ + "MetadataContextTestStack" + ], + "assertionStack": "MetadataContextInteg/DefaultTest/DeployAssert", + "assertionStackName": "MetadataContextIntegDefaultTestDeployAssert9187EC06" + } + }, + "enableLookups": true, + "minimumCliVersion": "2.1131.0" +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/manifest.json b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/manifest.json new file mode 100644 index 0000000000000..3559fb0884b30 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/manifest.json @@ -0,0 +1,624 @@ +{ + "version": "54.0.0", + "artifacts": { + "MetadataContextTestStack.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "MetadataContextTestStack.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "MetadataContextTestStack": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "MetadataContextTestStack.template.json", + "terminationProtection": false, + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/5c79dae7d802ed0f71a89fba0f78e8060710c1188920589e08449635f0ed2d0e.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "MetadataContextTestStack.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "MetadataContextTestStack.assets" + ], + "additionalMetadataFile": "MetadataContextTestStack.metadata.json", + "displayName": "MetadataContextTestStack" + }, + "MetadataContextIntegDefaultTestDeployAssert9187EC06.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "MetadataContextIntegDefaultTestDeployAssert9187EC06.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "MetadataContextIntegDefaultTestDeployAssert9187EC06": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "MetadataContextIntegDefaultTestDeployAssert9187EC06.template.json", + "terminationProtection": false, + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "MetadataContextIntegDefaultTestDeployAssert9187EC06.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "MetadataContextIntegDefaultTestDeployAssert9187EC06.assets" + ], + "additionalMetadataFile": "MetadataContextIntegDefaultTestDeployAssert9187EC06.metadata.json", + "displayName": "MetadataContextInteg/DefaultTest/DeployAssert" + }, + "Tree": { + "type": "cdk:tree", + "properties": { + "file": "tree.json" + } + }, + "aws-cdk-lib/feature-flag-report": { + "type": "cdk:feature-flag-report", + "properties": { + "module": "aws-cdk-lib", + "flags": { + "@aws-cdk/aws-signer:signingProfileNamePassedToCfn": { + "userValue": true, + "recommendedValue": true, + "explanation": "Pass signingProfileName to CfnSigningProfile" + }, + "@aws-cdk/core:newStyleStackSynthesis": { + "recommendedValue": true, + "explanation": "Switch to new stack synthesis method which enables CI/CD", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/core:stackRelativeExports": { + "recommendedValue": true, + "explanation": "Name exports based on the construct paths relative to the stack, rather than the global construct path", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": { + "userValue": true, + "recommendedValue": true, + "explanation": "Disable implicit openListener when custom security groups are provided" + }, + "@aws-cdk/aws-rds:lowercaseDbIdentifier": { + "recommendedValue": true, + "explanation": "Force lowercasing of RDS Cluster names in CDK", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": { + "recommendedValue": true, + "explanation": "Allow adding/removing multiple UsagePlanKeys independently", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-lambda:recognizeVersionProps": { + "recommendedValue": true, + "explanation": "Enable this feature flag to opt in to the updated logical id calculation for Lambda Version created using the `fn.currentVersion`.", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-lambda:recognizeLayerVersion": { + "userValue": true, + "recommendedValue": true, + "explanation": "Enable this feature flag to opt in to the updated logical id calculation for Lambda Version created using the `fn.currentVersion`." + }, + "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": { + "recommendedValue": true, + "explanation": "Enable this feature flag to have cloudfront distributions use the security policy TLSv1.2_2021 by default.", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/core:checkSecretUsage": { + "userValue": true, + "recommendedValue": true, + "explanation": "Enable this flag to make it impossible to accidentally use SecretValues in unsafe locations" + }, + "@aws-cdk/core:target-partitions": { + "recommendedValue": [ + "aws", + "aws-cn" + ], + "explanation": "What regions to include in lookup tables of environment agnostic stacks" + }, + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": { + "userValue": true, + "recommendedValue": true, + "explanation": "ECS extensions will automatically add an `awslogs` driver if no logging is specified" + }, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": { + "userValue": true, + "recommendedValue": true, + "explanation": "Enable this feature flag to have Launch Templates generated by the `InstanceRequireImdsv2Aspect` use unique names." + }, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": { + "userValue": true, + "recommendedValue": true, + "explanation": "ARN format used by ECS. In the new ARN format, the cluster name is part of the resource ID." + }, + "@aws-cdk/aws-iam:minimizePolicies": { + "userValue": true, + "recommendedValue": true, + "explanation": "Minimize IAM policies by combining Statements" + }, + "@aws-cdk/core:validateSnapshotRemovalPolicy": { + "userValue": true, + "recommendedValue": true, + "explanation": "Error on snapshot removal policies on resources that do not support it." + }, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": { + "userValue": true, + "recommendedValue": true, + "explanation": "Generate key aliases that include the stack name" + }, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": { + "userValue": true, + "recommendedValue": true, + "explanation": "Enable this feature flag to create an S3 bucket policy by default in cases where an AWS service would automatically create the Policy if one does not exist." + }, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": { + "userValue": true, + "recommendedValue": true, + "explanation": "Restrict KMS key policy for encrypted Queues a bit more" + }, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": { + "userValue": true, + "recommendedValue": true, + "explanation": "Make default CloudWatch Role behavior safe for multiple API Gateways in one environment" + }, + "@aws-cdk/core:enablePartitionLiterals": { + "userValue": true, + "recommendedValue": true, + "explanation": "Make ARNs concrete if AWS partition is known" + }, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": { + "userValue": true, + "recommendedValue": true, + "explanation": "Event Rules may only push to encrypted SQS queues in the same account" + }, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": { + "userValue": true, + "recommendedValue": true, + "explanation": "Avoid setting the \"ECS\" deployment controller when adding a circuit breaker" + }, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": { + "userValue": true, + "recommendedValue": true, + "explanation": "Enable this feature to create default policy names for imported roles that depend on the stack the role is in." + }, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": { + "userValue": true, + "recommendedValue": true, + "explanation": "Use S3 Bucket Policy instead of ACLs for Server Access Logging" + }, + "@aws-cdk/aws-route53-patters:useCertificate": { + "userValue": true, + "recommendedValue": true, + "explanation": "Use the official `Certificate` resource instead of `DnsValidatedCertificate`" + }, + "@aws-cdk/customresources:installLatestAwsSdkDefault": { + "userValue": false, + "recommendedValue": false, + "explanation": "Whether to install the latest SDK by default in AwsCustomResource" + }, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": { + "userValue": true, + "recommendedValue": true, + "explanation": "Use unique resource name for Database Proxy" + }, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": { + "userValue": true, + "recommendedValue": true, + "explanation": "Remove CloudWatch alarms from deployment group" + }, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": { + "userValue": true, + "recommendedValue": true, + "explanation": "Include authorizer configuration in the calculation of the API deployment logical ID." + }, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": { + "userValue": true, + "recommendedValue": true, + "explanation": "Define user data for a launch template by default when a machine image is provided." + }, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": { + "userValue": true, + "recommendedValue": true, + "explanation": "SecretTargetAttachments uses the ResourcePolicy of the attached Secret." + }, + "@aws-cdk/aws-redshift:columnId": { + "userValue": true, + "recommendedValue": true, + "explanation": "Whether to use an ID to track Redshift column changes" + }, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": { + "userValue": true, + "recommendedValue": true, + "explanation": "Enable AmazonEMRServicePolicy_v2 managed policies" + }, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": { + "userValue": true, + "recommendedValue": true, + "explanation": "Restrict access to the VPC default security group" + }, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": { + "userValue": true, + "recommendedValue": true, + "explanation": "Generate a unique id for each RequestValidator added to a method" + }, + "@aws-cdk/aws-kms:aliasNameRef": { + "userValue": true, + "recommendedValue": true, + "explanation": "KMS Alias name and keyArn will have implicit reference to KMS Key" + }, + "@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal": { + "userValue": true, + "recommendedValue": true, + "explanation": "Enable grant methods on Aliases imported by name to use kms:ResourceAliases condition" + }, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": { + "userValue": true, + "recommendedValue": true, + "explanation": "Generate a launch template when creating an AutoScalingGroup" + }, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": { + "userValue": true, + "recommendedValue": true, + "explanation": "Include the stack prefix in the stack name generation process" + }, + "@aws-cdk/aws-efs:denyAnonymousAccess": { + "userValue": true, + "recommendedValue": true, + "explanation": "EFS denies anonymous clients accesses" + }, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": { + "userValue": true, + "recommendedValue": true, + "explanation": "Enables support for Multi-AZ with Standby deployment for opensearch domains" + }, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": { + "userValue": true, + "recommendedValue": true, + "explanation": "Enables aws-lambda-nodejs.Function to use the latest available NodeJs runtime as the default" + }, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, mount targets will have a stable logicalId that is linked to the associated subnet." + }, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, a scope of InstanceParameterGroup for AuroraClusterInstance with each parameters will change." + }, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, will always use the arn for identifiers for CfnSourceApiAssociation in the GraphqlApi construct rather than id." + }, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, creating an RDS database cluster from a snapshot will only render credentials for snapshot credentials." + }, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, the CodeCommit source action is using the default branch name 'main'." + }, + "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, the logical ID of a Lambda permission for a Lambda action includes an alarm ID." + }, + "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": { + "userValue": true, + "recommendedValue": true, + "explanation": "Enables Pipeline to set the default value for crossAccountKeys to false." + }, + "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": { + "userValue": true, + "recommendedValue": true, + "explanation": "Enables Pipeline to set the default pipeline type to V2." + }, + "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, IAM Policy created from KMS key grant will reduce the resource scope to this key only." + }, + "@aws-cdk/pipelines:reduceAssetRoleTrustScope": { + "recommendedValue": true, + "explanation": "Remove the root account principal from PipelineAssetsFileRole trust policy", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-eks:nodegroupNameAttribute": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, nodegroupName attribute of the provisioned EKS NodeGroup will not have the cluster name prefix." + }, + "@aws-cdk/aws-eks:useNativeOidcProvider": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, EKS V2 clusters will use the native OIDC provider resource AWS::IAM::OIDCProvider instead of creating the OIDCProvider with a custom resource (iam.OpenIDConnectProvider)." + }, + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, the default volume type of the EBS volume will be GP3" + }, + "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, remove default deployment alarm settings" + }, + "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": { + "userValue": false, + "recommendedValue": false, + "explanation": "When enabled, the custom resource used for `AwsCustomResource` will configure the `logApiResponseData` property as true by default" + }, + "@aws-cdk/aws-s3:keepNotificationInImportedBucket": { + "userValue": false, + "recommendedValue": false, + "explanation": "When enabled, Adding notifications to a bucket in the current stack will not remove notification from imported stack." + }, + "@aws-cdk/aws-stepfunctions-tasks:useNewS3UriParametersForBedrockInvokeModelTask": { + "recommendedValue": true, + "explanation": "When enabled, use new props for S3 URI field in task definition of state machine for bedrock invoke model.", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/core:explicitStackTags": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, stack tags need to be assigned explicitly on a Stack." + }, + "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, we will only grant the necessary permissions when users specify cloudwatch log group through logConfiguration" + }, + "@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled will allow you to specify a resource policy per replica, and not copy the source table policy to all replicas" + }, + "@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, initOptions.timeout and resourceSignalTimeout values will be summed together." + }, + "@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, a Lambda authorizer Permission created when using GraphqlApi will be properly scoped with a SourceArn." + }, + "@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, the value of property `instanceResourceId` in construct `DatabaseInstanceReadReplica` will be set to the correct value which is `DbiResourceId` instead of currently `DbInstanceArn`" + }, + "@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, CFN templates added with `cfn-include` will error if the template contains Resource Update or Create policies with CFN Intrinsics that include non-primitive values." + }, + "@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, both `@aws-sdk` and `@smithy` packages will be excluded from the Lambda Node.js 18.x runtime to prevent version mismatches in bundled applications." + }, + "@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, the resource of IAM Run Ecs policy generated by SFN EcsRunTask will reference the definition, instead of constructing ARN." + }, + "@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, the BastionHost construct will use the latest Amazon Linux 2023 AMI, instead of Amazon Linux 2." + }, + "@aws-cdk/core:aspectStabilization": { + "recommendedValue": true, + "explanation": "When enabled, a stabilization loop will be run when invoking Aspects during synthesis.", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, use a new method for DNS Name of user pool domain target without creating a custom resource." + }, + "@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, the default security group ingress rules will allow IPv6 ingress from anywhere" + }, + "@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, the default behaviour of OIDC provider will reject unauthorized connections" + }, + "@aws-cdk/core:enableAdditionalMetadataCollection": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, CDK will expand the scope of usage data collected to better inform CDK development and improve communication for security concerns and emerging issues." + }, + "@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": { + "userValue": false, + "recommendedValue": false, + "explanation": "[Deprecated] When enabled, Lambda will create new inline policies with AddToRolePolicy instead of adding to the Default Policy Statement" + }, + "@aws-cdk/aws-s3:setUniqueReplicationRoleName": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, CDK will automatically generate a unique role name that is used for s3 object replication." + }, + "@aws-cdk/pipelines:reduceStageRoleTrustScope": { + "recommendedValue": true, + "explanation": "Remove the root account principal from Stage addActions trust policy", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-events:requireEventBusPolicySid": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, grantPutEventsTo() will use resource policies with Statement IDs for service principals." + }, + "@aws-cdk/core:aspectPrioritiesMutating": { + "userValue": true, + "recommendedValue": true, + "explanation": "When set to true, Aspects added by the construct library on your behalf will be given a priority of MUTATING." + }, + "@aws-cdk/aws-dynamodb:retainTableReplica": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, table replica will be default to the removal policy of source table unless specified otherwise." + }, + "@aws-cdk/cognito:logUserPoolClientSecretValue": { + "recommendedValue": false, + "explanation": "When disabled, the value of the user pool client secret will not be logged in the custom resource lambda function logs." + }, + "@aws-cdk/pipelines:reduceCrossAccountActionRoleTrustScope": { + "recommendedValue": true, + "explanation": "When enabled, scopes down the trust policy for the cross-account action role", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, the resultWriterV2 property of DistributedMap will be used insted of resultWriter" + }, + "@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions": { + "userValue": true, + "recommendedValue": true, + "explanation": "Add an S3 trust policy to a KMS key resource policy for SNS subscriptions." + }, + "@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, the EgressOnlyGateway resource is only created if private subnets are defined in the dual-stack VPC." + }, + "@aws-cdk/aws-ec2-alpha:useResourceIdForVpcV2Migration": { + "recommendedValue": false, + "explanation": "When enabled, use resource IDs for VPC V2 migration" + }, + "@aws-cdk/aws-s3:publicAccessBlockedByDefault": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, setting any combination of options for BlockPublicAccess will automatically set true for any options not defined." + }, + "@aws-cdk/aws-lambda:useCdkManagedLogGroup": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, CDK creates and manages loggroup for the lambda function" + }, + "@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, Network Load Balancer will be created with a security group by default." + }, + "@aws-cdk/aws-stepfunctions-tasks:httpInvokeDynamicJsonPathEndpoint": { + "recommendedValue": true, + "explanation": "When enabled, allows using a dynamic apiEndpoint with JSONPath format in HttpInvoke tasks.", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-ecs-patterns:uniqueTargetGroupId": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, ECS patterns will generate unique target group IDs to prevent conflicts during load balancer replacement" + }, + "@aws-cdk/aws-route53-patterns:useDistribution": { + "userValue": true, + "recommendedValue": true, + "explanation": "Use the `Distribution` resource instead of `CloudFrontWebDistribution`" + }, + "@aws-cdk/aws-cloudfront:defaultFunctionRuntimeV2_0": { + "userValue": true, + "recommendedValue": true, + "explanation": "Use cloudfront-js-2.0 as the default runtime for CloudFront Functions" + }, + "@aws-cdk/aws-elasticloadbalancingv2:usePostQuantumTlsPolicy": { + "userValue": true, + "recommendedValue": true, + "explanation": "When enabled, HTTPS/TLS listeners use post-quantum TLS policy by default" + }, + "@aws-cdk/core:automaticL1Traits": { + "recommendedValue": true, + "explanation": "Automatically use the default L1 traits for L1 constructs`", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-batch:defaultToAL2023": { + "userValue": true, + "recommendedValue": true, + "explanation": "Use AL2023 as the default imageType for EC2 Batch compute environments instead of the deprecated AL2" + }, + "@aws-cdk/aws-eks:defaultToAL2023": { + "recommendedValue": true, + "explanation": "Use AL2023 as the default AMI type for EKS managed node groups using non-GPU instance types instead of the deprecated AL2" + }, + "@aws-cdk/core:annotationsInValidationReport": { + "recommendedValue": true, + "explanation": "Include construct annotations (warnings and errors) in the policy validation report" + }, + "@aws-cdk/core:defaultCrossStackReferences": { + "recommendedValue": "weak", + "explanation": "Controls whether cross-stack references are strong, weak, or both", + "unconfiguredBehavesLike": { + "v2": "strong" + } + }, + "@aws-cdk/core:validateAgainstDefaultRules": { + "recommendedValue": true, + "explanation": "Treat CloudFormation Validate findings as errors" + }, + "@aws-cdk/aws-ecs:removeEmptyLoadBalancers": { + "recommendedValue": true, + "explanation": "Render an empty `LoadBalancers` array on an ECS service that has no target groups" + } + } + } + } + }, + "minimumCliVersion": "2.1131.0" +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/tree.json b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/tree.json new file mode 100644 index 0000000000000..de7b7e1c7ec26 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/tree.json @@ -0,0 +1 @@ +{"version":"tree-0.1","tree":{"id":"App","path":"","constructInfo":{"fqn":"aws-cdk-lib.App","version":"0.0.0"},"children":{"MetadataContextTestStack":{"id":"MetadataContextTestStack","path":"MetadataContextTestStack","constructInfo":{"fqn":"aws-cdk-lib.Stack","version":"0.0.0"},"children":{"OrderQueue":{"id":"OrderQueue","path":"MetadataContextTestStack/OrderQueue","constructInfo":{"fqn":"aws-cdk-lib.aws_sqs.Queue","version":"0.0.0"},"children":{"Resource":{"id":"Resource","path":"MetadataContextTestStack/OrderQueue/Resource","constructInfo":{"fqn":"aws-cdk-lib.aws_sqs.CfnQueue","version":"0.0.0"},"attributes":{"aws:cdk:cloudformation:type":"AWS::SQS::Queue","aws:cdk:cloudformation:logicalId":"OrderQueue39B99167","aws:cdk:cloudformation:props":{"sqsManagedSseEnabled":true}}}}},"Notifications":{"id":"Notifications","path":"MetadataContextTestStack/Notifications","constructInfo":{"fqn":"constructs.Construct","version":"10.6.0"},"children":{"AlertsTopic":{"id":"AlertsTopic","path":"MetadataContextTestStack/Notifications/AlertsTopic","constructInfo":{"fqn":"aws-cdk-lib.aws_sns.Topic","version":"0.0.0"},"children":{"Resource":{"id":"Resource","path":"MetadataContextTestStack/Notifications/AlertsTopic/Resource","constructInfo":{"fqn":"aws-cdk-lib.aws_sns.CfnTopic","version":"0.0.0"},"attributes":{"aws:cdk:cloudformation:type":"AWS::SNS::Topic","aws:cdk:cloudformation:logicalId":"NotificationsAlertsTopicDFE3487E","aws:cdk:cloudformation:props":{}}}}}}},"BootstrapVersion":{"id":"BootstrapVersion","path":"MetadataContextTestStack/BootstrapVersion","constructInfo":{"fqn":"aws-cdk-lib.CfnParameter","version":"0.0.0"}},"CheckBootstrapVersion":{"id":"CheckBootstrapVersion","path":"MetadataContextTestStack/CheckBootstrapVersion","constructInfo":{"fqn":"aws-cdk-lib.CfnRule","version":"0.0.0"}}}},"MetadataContextInteg":{"id":"MetadataContextInteg","path":"MetadataContextInteg","constructInfo":{"fqn":"@aws-cdk/integ-tests-alpha.IntegTest","version":"0.0.0"},"children":{"DefaultTest":{"id":"DefaultTest","path":"MetadataContextInteg/DefaultTest","constructInfo":{"fqn":"@aws-cdk/integ-tests-alpha.IntegTestCase","version":"0.0.0"},"children":{"Default":{"id":"Default","path":"MetadataContextInteg/DefaultTest/Default","constructInfo":{"fqn":"constructs.Construct","version":"10.6.0"}},"DeployAssert":{"id":"DeployAssert","path":"MetadataContextInteg/DefaultTest/DeployAssert","constructInfo":{"fqn":"aws-cdk-lib.Stack","version":"0.0.0"},"children":{"BootstrapVersion":{"id":"BootstrapVersion","path":"MetadataContextInteg/DefaultTest/DeployAssert/BootstrapVersion","constructInfo":{"fqn":"aws-cdk-lib.CfnParameter","version":"0.0.0"}},"CheckBootstrapVersion":{"id":"CheckBootstrapVersion","path":"MetadataContextInteg/DefaultTest/DeployAssert/CheckBootstrapVersion","constructInfo":{"fqn":"aws-cdk-lib.CfnRule","version":"0.0.0"}}}}}}}},"Tree":{"id":"Tree","path":"Tree","constructInfo":{"fqn":"constructs.Construct","version":"10.6.0"}}}}} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/validation-report.json b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/validation-report.json new file mode 100644 index 0000000000000..57395d4faf4f8 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/validation-report.json @@ -0,0 +1,28 @@ +{ + "version": "54.0.0", + "title": "Validation Report", + "pluginReports": [ + { + "pluginName": "CloudFormation Validate", + "pluginVersion": "1.7.0", + "conclusion": "success", + "violations": [ + { + "ruleName": "F0001", + "description": "Resources section must exist and be non-empty", + "severity": "warning", + "ruleMetadata": { + "category": "Structure" + }, + "violatingConstructs": [ + { + "constructPath": "MetadataContextInteg/DefaultTest/DeployAssert", + "constructFqn": "aws-cdk-lib.Stack", + "libraryVersion": "0.0.0" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.ts b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.ts new file mode 100644 index 0000000000000..658271326aaac --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.ts @@ -0,0 +1,51 @@ +import { App, CfnContextMutability, CfnContextTrustConfidence, CfnContextTrustSource, CfnResourceMetadataContext, CfnTemplateMetadataContext, ConstructSelector, Stack } from 'aws-cdk-lib'; +import * as sqs from 'aws-cdk-lib/aws-sqs'; +import * as sns from 'aws-cdk-lib/aws-sns'; +import * as integ from '@aws-cdk/integ-tests-alpha'; +import { Construct } from 'constructs'; + +const app = new App(); +const stack = new Stack(app, 'MetadataContextTestStack', { + description: 'integ test stack for MetadataContext; exercises resource + template level context', +}); + +CfnTemplateMetadataContext.of(stack).set({ + arch: 'SQS buffer -> consumer; DLQ for poison msgs', + must: ['all queues encrypted w/ SSE'], + ref: [ + { at: 'context/shared/encryption.ctx.yaml', has: 'org CMK + tagging rules', scope: 'shared' }, + ], + owner: 'framework-integ-team', +}); + +// The queue-specific Context below replaces this default. +CfnResourceMetadataContext.of(stack, { + selector: ConstructSelector.resourcesOfType('AWS::SQS::Queue'), +}).set({ + why: 'part of the order-processing subsystem', + deps: ['OrderProcessingSubsystem'], +}); + +const queue = new sqs.Queue(stack, 'OrderQueue', { + // Keep the configuration consistent with the template-level rule. + encryption: sqs.QueueEncryption.SQS_MANAGED, +}); +CfnResourceMetadataContext.of(queue).set({ + why: 'buffer order events async; std queue (throughput > ordering)', + must: ['VisTimeout >= 6x consumer timeout, else dup on retry'], + mutable: CfnContextMutability.CHANGE_WITH_CONSTRAINTS, + mutability: { QueueName: CfnContextMutability.MUST_NEVER_CHANGE }, + trust: { src: CfnContextTrustSource.AUTHORED, conf: CfnContextTrustConfidence.HIGH }, +}); + +const subsystem = new Construct(stack, 'Notifications'); +new sns.Topic(subsystem, 'AlertsTopic'); +CfnResourceMetadataContext.of(subsystem, { + selector: ConstructSelector.all(), +}).set({ + why: 'fan-out of alert events to oncall channels', +}); + +new integ.IntegTest(app, 'MetadataContextInteg', { + testCases: [stack], +}); diff --git a/packages/aws-cdk-lib/README.md b/packages/aws-cdk-lib/README.md index 6a2f11cf00780..71619d6547af3 100644 --- a/packages/aws-cdk-lib/README.md +++ b/packages/aws-cdk-lib/README.md @@ -1619,6 +1619,442 @@ the context key setting. Similarly, to do this for a specific nested stack, add a `suppressTemplateIndentation: true` property to its `NestedStackProps` parameter. You can also set this property to `false` to override the context key setting. +## Metadata Context + +CDK can embed structured, advisory context into the +`Metadata["com.aws.cloudformation.Context"]` sections of synthesized CloudFormation +templates. It captures the *why* behind your infrastructure — rationale, hard +invariants, change-safety and provenance — so that humans and +automated tools working with the deployed template later can act on the author's +intent instead of guessing it. The structural source of truth is the published +[AWS CloudFormation `Metadata` Context schema](https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-attribute-metadata.html#aws-attribute-metadata-context-schema): +every field it defines is optional, and it sets no `minLength`/`minItems`, so +blank strings and empty arrays are structurally valid. The schema is advisory — +CloudFormation does not validate or enforce `Metadata` fields. CDK property names +are the schema's field names (`why`, `must`, `mutable`, `mutability`, `trust.src`, +`trust.conf`, `trust.cite`, `trust.note`, `deps`; `arch`, `must`, `ref`, `owner`), +so code and template use one vocabulary. CDK adds typed enums, targeting, and +the validation described below. + +Context comes in two flavors, each with its own entry point: + +- `CfnResourceMetadataContext` — resource-level context, rendered onto individual + CloudFormation resources. +- `CfnTemplateMetadataContext` — template-level (stack-wide) context, rendered as a + top-level `Metadata` block. + +### Resource-level context + +Set resource-level context on any construct scope. `set()` stores the Context on +the scope, and an Aspect applies it during synthesis, so its selector also sees +resources added after the call. `clear()` records the removal in the same way: + +```typescript +declare const queue: sqs.Queue; + +CfnResourceMetadataContext.of(queue).set({ + why: 'buffer order events async; 14d retention = compliance window', + must: ['VisTimeout >= 6x fn timeout, else dup on retry'], + mutable: CfnContextMutability.CHANGE_WITH_CONSTRAINTS, + mutability: { + QueueName: CfnContextMutability.MUST_NEVER_CHANGE, + }, +}); +``` + +This renders a `Metadata["com.aws.cloudformation.Context"]` block on the +`AWS::SQS::Queue` resource: + +```json +{ + "Type": "AWS::SQS::Queue", + "Metadata": { + "com.aws.cloudformation.Context": { + "why": "buffer order events async; 14d retention = compliance window", + "must": ["VisTimeout >= 6x fn timeout, else dup on retry"], + "mutable": "change-with-constraints", + "mutability": { "QueueName": "must-never-change" } + } + } +} +``` + +`mutable` is the resource's default change-safety level. `mutability` is a +*sparse* per-property map: list only the properties that deviate from `mutable` +(or that are otherwise high-stakes, e.g. replacement-triggering). When both are +supplied, entries that repeat `mutable` are omitted; if none remain, the map is +omitted. Both fields record what the team considers safe to change, not what +CloudFormation can change in place. + +### Targeting: exactly what receives context + +`CfnResourceMetadataContext.of()` takes the targeting options used by subsequent +`set()` and `clear()` calls. With no options, `ConstructSelector.cfnResource()` +selects: + +- the scope itself, when the scope is a `CfnResource`; or +- the scope's immediate + [`defaultChild`](https://docs.aws.amazon.com/cdk/api/v2/docs/constructs.Node.html#defaultchild), + when that child is a `CfnResource` — e.g. the `AWS::SQS::Queue` that an + `sqs.Queue` L2 designates as its `defaultChild`, or the `AWS::Lambda::Function` + inside a `lambda.Function`. + +The default does not follow a chain through other constructs: if the scope's +`defaultChild` is itself a construct, nothing is selected. Incidental helper +resources (auto-created IAM roles/policies, log-retention functions, +custom-resource plumbing) are never the `defaultChild`, so they are not selected +by default. Using the default on a scope with no `CfnResource` default child — +most L3 patterns, such as `ecs_patterns.ApplicationLoadBalancedFargateService`, a +plain grouping `Construct`, or a `Stack` — fails synthesis. + +To target other resources, set `selector` to an `IConstructSelector`, the +selector type that `Mixins.of()` also takes; `ConstructSelector` provides the +built-in ones. Only selected `CfnResource` constructs are changed; other selected +constructs are ignored. `ConstructSelector.all()` targets every `CfnResource` +beneath the scope, helpers included, and `ConstructSelector.resourcesOfType()` +targets resources of the listed types: + +```typescript +declare const stack: Stack; +declare const queue: sqs.Queue; + +// 1. Default: the scope itself or its immediate CfnResource default child. +CfnResourceMetadataContext.of(queue).set({ + why: 'buffers webhook events for async processing', +}); + +// 2. Every resource beneath the scope, helpers included. +CfnResourceMetadataContext.of(stack, { + selector: ConstructSelector.all(), +}).set({ + deps: ['NetworkStack'], +}); + +// 3. Only resources of a specific type. The queue above also matches; see +// "Replacement and ordering" below for which value it keeps. +CfnResourceMetadataContext.of(stack, { + selector: ConstructSelector.resourcesOfType('AWS::SQS::Queue'), +}).set({ + must: ['delivery settings must preserve in-flight messages'], +}); +``` + +`resourcesOfType()`, `byId()`, and `byPath()` narrow the selection, and a custom +`IConstructSelector` can do the same. Each `set()` or `clear()` call that selects +no `CfnResource` fails synthesis. + +Context can be set or cleared on resources in a `NestedStack`. Calls do not apply +across a `Stage`: each Stage is synthesized as its own cloud assembly and Aspects +are invoked separately for each assembly, so a call on an `App` whose only +resources are inside Stages selects nothing and fails. Set context inside each +Stage; this also lets environments carry different guidance: + +```typescript +// Each Stage is synthesized as its own cloud assembly and sets its own context. +const dev = new Stage(app, 'Dev'); +new sqs.Queue(new Stack(dev, 'Orders'), 'WebhookQueue'); +CfnResourceMetadataContext.of(dev, { selector: ConstructSelector.all() }).set({ + why: 'development environment; data is disposable', + mutable: CfnContextMutability.FREE_TO_TUNE, +}); + +const prod = new Stage(app, 'Prod'); +new sqs.Queue(new Stack(prod, 'Orders'), 'WebhookQueue'); +CfnResourceMetadataContext.of(prod, { selector: ConstructSelector.all() }).set({ + must: ['deletion protection and backups stay enabled'], + mutable: CfnContextMutability.REVIEW_REQUIRED, +}); +``` + +The queue in `Dev-Orders` renders the `why` and `mutable: free-to-tune`; the queue +in `Prod-Orders` renders the `must` rule and `mutable: review-required`. + +Targeting is always explicit because repeating one block on many resources makes it +look more important than it is and can attach a rule to resources it does not +govern. A fact that applies to every resource in the template belongs in +`CfnTemplateMetadataContext` (see below). + +#### Helper resources + +Setting context on a `lambda.Function` with the default selector targets its +immediate default child, the `AWS::Lambda::Function`, not its execution role or +dead-letter queue. Helpers that the L2 exposes as constructs can be targeted +through those constructs: + +```typescript +declare const lambdaFunction: lambda.Function; + +// The AWS::Lambda::Function, not its generated role. +CfnResourceMetadataContext.of(lambdaFunction).set({ + why: 'processes order events from the queue; idempotent on order id', +}); + +// A helper the L2 exposes; set when the function was created with a dead-letter queue. +if (lambdaFunction.deadLetterQueue) { + CfnResourceMetadataContext.of(lambdaFunction.deadLetterQueue).set({ + why: 'stores failed order-processor invocations for replay', + }); +} +``` + +To target only an L2's helpers, select their CloudFormation resource types +beneath the L2; a selector that omits `AWS::Lambda::Function` leaves the function +unchanged: + +```typescript +declare const lambdaFunction: lambda.Function; + +// The role and policies the function creates, not the function itself. +CfnResourceMetadataContext.of(lambdaFunction, { + selector: ConstructSelector.resourcesOfType('AWS::IAM::Role', 'AWS::IAM::Policy'), +}).set({ + why: 'created by lambda.Function for the order processor; change them through the function', +}); +``` + +For an L3 pattern (or any multi-resource construct) with no `defaultChild`, +target a child construct or select by resource type. For a pattern that +creates a load balancer, a service, and supporting resources: + +```typescript +declare const service: Construct; // e.g. an ecs_patterns.ApplicationLoadBalancedFargateService + +// Apply this rule only to the Application Load Balancer created by the pattern. +CfnResourceMetadataContext.of(service, { + selector: ConstructSelector.resourcesOfType('AWS::ElasticLoadBalancingV2::LoadBalancer'), +}).set({ + must: ['ALB idle timeout >= backend read timeout'], +}); +``` + +### Replacement and ordering + +Context values are never merged. When a selected `CfnResource` is visited, +`set()` replaces its complete Context value and `clear()` removes it. When several +calls select the same resource, the call applied last determines the result. +Calls are applied by the normal Aspect rules: lower `priority` values run first +(the default is `AspectPriority.MUTATING`); at the same priority a call on a +parent scope runs before one on a child scope, and calls on the same scope run in +call order. For example: + +```typescript +declare const stack: Stack; +declare const queue: sqs.Queue; + +// Set on the Stack for every SQS queue. +CfnResourceMetadataContext.of(stack, { + selector: ConstructSelector.resourcesOfType('AWS::SQS::Queue'), +}).set({ + why: 'part of the order-processing subsystem', + must: ['queues use the security team customer managed KMS key'], +}); + +// Set on one queue. +CfnResourceMetadataContext.of(queue).set({ + why: 'buffers webhook events for async processing', + must: ['VisibilityTimeout >= 6x consumer timeout'], +}); +``` + +On that queue's `AWS::SQS::Queue`, the second `set()` replaces the first; other +queues in the Stack keep only the Context set on the Stack: + +```json +{ + "why": "buffers webhook events for async processing", + "must": [ + "VisibilityTimeout >= 6x consumer timeout" + ] +} +``` + +`clear()` removes Context from the resources chosen by its selector. A later +`set()` writes Context again, while a later `clear()` removes it: + +```typescript +declare const legacyBucket: s3.Bucket; +const legacyContext = CfnResourceMetadataContext.of(legacyBucket); + +legacyContext.set({ + must: ['buckets use the security team customer managed KMS key'], +}); +legacyContext.clear(); +legacyContext.set({ + why: 'legacy public assets; migration tracked separately; approved encryption exception', +}); +``` + +### Trust: explicit provenance + +Context can record where it came from and how much to trust it. `trust` is +optional and may be the only field you supply. When supplied, both `src` and +`conf` are **required** — CDK never infers them or adds a trust block for you. +A tool that lifts `why` from a source comment writes: + +```typescript +declare const queue: sqs.Queue; + +// Written by an authoring tool from a comment in lib/queue.ts. +CfnResourceMetadataContext.of(queue).set({ + why: 'retry buffer for an unreliable dependent payments service', + trust: { + src: CfnContextTrustSource.COMMENT, + conf: CfnContextTrustConfidence.MEDIUM, + cite: 'https://github.com/example/orders/blob/3f9c2d1/lib/queue.ts#L42', + }, +}); +``` + +The trust sources are `AUTHORED` (human-authored or human-confirmed), `COMMENT` +(derived directly from a code comment), `COMMIT` (derived directly from commit +rationale) and `INFER` (produced by agent inference or synthesis). `src` holds +one value. When more than one fits, people and AI agents alike choose by this +precedence: + +1. `AUTHORED` whenever a person wrote or explicitly confirmed the text, even if + it originated in a comment, a commit message, or a tool's inference. Human + confirmation is the strongest evidence; record the original evidence in `cite` + (the comment's file and line, or the commit SHA) and, when useful, in `note`. +2. Otherwise, the most direct evidence: `COMMENT` when the text was copied or + lightly rephrased from a source comment; `COMMIT` when it came from + version-control history. +3. `INFER` when a tool combined evidence or reasoned from code structure or + behavior without an explicit statement, even if a comment or commit + contributed. Name the contributing evidence in `cite` and `note`. + +A person writing context directly uses `src: AUTHORED`. A tool uses `COMMENT`, +`COMMIT`, or `INFER` according to its evidence, and switches to `AUTHORED` only +after a person confirms the text. For example, a tool that lifts `why` from a +comment writes `src: COMMENT` and a `cite` that names the commit, so the reference +stays valid after the file changes; when the author +reviews and accepts it, the author (or the tool, on the author's confirmation) +should change `src` to `AUTHORED` and keep `cite`. `trust` itself is optional, so +a block without it leaves the source unstated; set it wherever tool-derived and +human-written context may share a template. + +### Template-level context + +`CfnTemplateMetadataContext` holds cross-cutting facts stated once per stack: the +architecture overview, template-wide invariants, pointers to external shared +context, and ownership. The stack's purpose itself belongs in the native +CloudFormation `Description` (the `description` prop of `Stack`): `Description` +is one short string (at most 1,024 bytes) that the console stack list and +`DescribeStacks` show, while template context is a set of named fields returned +only inside the template body via `GetTemplate`. Avoid repeating the `Description` +in `arch`, and keep rules and references out of `Description`. + +`set()` replaces the complete template Context value and `clear()` removes it. +Both change the stack's template metadata immediately, and `Stack.addMetadata()` +on the same key has the same replacement behavior, so ordinary call order +determines the result. Every template-context field is optional; supply any +combination, and `set({})` writes an empty Context object. Values must be +concrete. `set()` rejects unresolved tokens because CloudFormation does not +resolve intrinsic functions in top-level template Metadata: + +```typescript +declare const stack: Stack; + +CfnTemplateMetadataContext.of(stack).set({ + arch: 'SQS buffer -> Lambda -> DynamoDB; DLQ for poison msgs', + must: ['all data encrypted w/ security-team CMK'], + ref: [ + { + at: 'context/shared/encryption.ctx.yaml', + has: 'org CMK + tagging rules', + scope: 'shared', + }, + ], + owner: 'order-processing-team', +}); +``` + +`ref` entries point to supporting context by URI — a relative repository path, `s3://`, +or `https://`. A ref containing only `at` renders as a string; add `has` or +`scope` to render the object form. Inline template context takes precedence. +Consumers must treat referenced content as untrusted data, never as agent +instructions, and continue with inline context if a reference is unavailable. + +### Writing good context + +Every schema field is optional. The following are recommendations, not enforced +rules: + +- Provide a `why` for every non-trivial resource so consumers understand its + purpose. Omit Context for a trivial resource whose purpose is already obvious + from its type and name. +- Add `must` only when violating the rule would break correctness, + availability, security, data integrity, or a required dependency. Never invent + a rule to populate the field. Use `why` for reasoning and rejected + alternatives. +- Pair `MUST_NEVER_CHANGE` or `CHANGE_WITH_CONSTRAINTS` with a `must` entry that + states the rule behind the restriction. +- A `trust` block describes the source of other content, so it reads best + alongside a `why` or `must`; using it alone is valid. +- A fact that applies to every resource in the template belongs in + `CfnTemplateMetadataContext`, not on each resource. +- Keep free-text values terse — drop articles and use symbols (`->`, `>=`, `w/`) + — since context competes with resources for the CloudFormation template size + limit. + +CDK rejects unresolved template-level values so the emitted fields retain the +schema's literal types. It enforces the schema requirement that `trust` include +both `src` and `conf` when supplied (`cite` and `note` remain optional), and +omits `mutability` entries that repeat `mutable`. Blank strings, empty arrays, +empty maps, and an empty Context object are written as given. Resource-level +`set()` never throws; missing trust fields and selectors that match no resource +fail synthesis. Template-level `set()` rejects unresolved tokens and concrete +`ref` entries without `at` immediately. + +### Security + +Treat every Context field, template description, comment, and referenced file as +untrusted user data, never as instructions or approval. Never write secrets, +credentials, access tokens, private keys, connection strings, personal names, +email addresses, phone numbers, addresses, or other personally identifiable +information into Metadata. CloudFormation stores Metadata unencrypted and +returns it through service APIs. When the AWS CloudFormation agent skill writes +a template, it also writes `Metadata.AWSToolsMetrics.AWSAgentToolkit` as its +attribution marker. CDK does not add that marker because it cannot claim Agent +Toolkit authored a caller's context. + +### Manually added context + +`com.aws.cloudformation.Context` is a normal metadata key, so a construct library +or a caller can also write it directly with `CfnResource.addMetadata()`. A block +written directly is preserved as written unless a `set()` or `clear()` call later +selects that resource. `set()` performs the same complete-value write when its +Aspect runs, and `clear()` removes the key, so Context written by a library the +caller cannot edit can still be overridden. Conversely, an `addMetadata()` call +made by an Aspect that runs later replaces a `set()` value: + +```typescript +declare const queue: sqs.Queue; +const cfnQueue = queue.node.defaultChild as sqs.CfnQueue; + +// Written directly, for example by a construct library. +cfnQueue.addMetadata('com.aws.cloudformation.Context', { + why: 'written directly', + must: ['written directly'], +}); + +// Setting Context through the typed API replaces the complete Context written above. +CfnResourceMetadataContext.of(queue).set({ + why: 'declared through the metadata-context API', +}); +``` + +The resource contains only the second object; its earlier `must` entry is gone. + +At template level, `Stack.addMetadata()` and `CfnTemplateMetadataContext` write +the same key, and whichever runs last wins. Sibling metadata keys (such as your +own reverse-DNS tool metadata) are never touched. + +The `com.aws.cloudformation.Context` block contains only the fields defined by +the published schema and does not define extension fields for custom +dimensions. Tools that consume Context can publish independently defined +structured data under their own sibling reverse-DNS metadata keys using +`CfnResource.addMetadata()`. + ## App Context [Context values](https://docs.aws.amazon.com/cdk/v2/guide/context.html) are key-value pairs that can be associated with an app, stack, or construct. diff --git a/packages/aws-cdk-lib/core/lib/index.ts b/packages/aws-cdk-lib/core/lib/index.ts index b6863b5310dfb..d721c1e348187 100644 --- a/packages/aws-cdk-lib/core/lib/index.ts +++ b/packages/aws-cdk-lib/core/lib/index.ts @@ -1,5 +1,6 @@ export * from './aspect'; export * from './tag-aspect'; +export * from './metadata-context'; export * from './mixins'; diff --git a/packages/aws-cdk-lib/core/lib/metadata-context.ts b/packages/aws-cdk-lib/core/lib/metadata-context.ts new file mode 100644 index 0000000000000..e0b38b6bd464b --- /dev/null +++ b/packages/aws-cdk-lib/core/lib/metadata-context.ts @@ -0,0 +1,585 @@ +import type { IConstruct } from 'constructs'; +import type { IAspect } from './aspect'; +import { Aspects, AspectPriority } from './aspect'; +import { CfnResource } from './cfn-resource'; +import type { IConstructSelector } from './mixins/selectors'; +import { ConstructSelector } from './mixins/selectors'; +import { + METADATA_CONTEXT_KEY, + renderResourceContext, + renderTemplateContext, + validateResourceContext, + validateTemplateContext, +} from './private/metadata-context-internal'; +import type { Stack } from './stack'; + +/** + * Change-safety level for a resource or an individual resource property. + * + * Mirrors the schema's `MutabilityLevel`. Tells human and machine consumers how + * safe it is to modify a resource or one of its properties. + */ +export enum CfnContextMutability { + /** + * Rename/replace would break consumers or lose data. + * + * A corresponding `must` entry should state the rule that makes this + * immutable. + */ + MUST_NEVER_CHANGE = 'must-never-change', + + /** + * Change is possible but has constraints. + * + * The constraints should be documented in `must` entries. + */ + CHANGE_WITH_CONSTRAINTS = 'change-with-constraints', + + /** + * Change requires review/approval but won't break things. + */ + REVIEW_REQUIRED = 'review-required', + + /** + * Safe to modify without coordination or review. + */ + FREE_TO_TUNE = 'free-to-tune', +} + +/** + * How a piece of context was produced. + * + * Mirrors the schema's `TrustSource`. Consumers weigh source against confidence + * to decide how much to trust a block, so producers must not present inference + * as authored fact. + * + * `src` holds one value. When more than one fits, choose by this precedence: + * + * 1. `AUTHORED` whenever a person wrote or explicitly confirmed the text, even + * if it originated in a comment, a commit message, or a tool's inference. + * Record the original evidence in `cite` and, when useful, `note`. + * 2. Otherwise the most direct evidence: `COMMENT` when the text was copied or + * lightly rephrased from a source comment, `COMMIT` when it came from + * version-control history. + * 3. `INFER` when a tool combined evidence or reasoned from code structure or + * behavior without an explicit statement, even if a comment or commit + * contributed. Name the contributing evidence in `cite` and `note`. + */ +export enum CfnContextTrustSource { + /** + * Human-authored, or produced by tooling and subsequently confirmed by a + * human. + */ + AUTHORED = 'authored', + + /** + * Directly derived from a code comment. + */ + COMMENT = 'comment', + + /** + * Directly derived from a commit message / commit rationale. + */ + COMMIT = 'commit', + + /** + * Produced by agent inference or synthesis, not lifted verbatim from an + * authoritative source. + */ + INFER = 'infer', +} + +/** + * Confidence in the accuracy of a piece of context. + * + * Mirrors the schema's `TrustConfidence`. + */ +export enum CfnContextTrustConfidence { + /** + * Verified by the resource owner or backed by authoritative documentation. + */ + HIGH = 'high', + + /** + * Plausible but unverified — e.g. derived from a descriptive source comment. + */ + MEDIUM = 'medium', + + /** + * Weak evidence — explain the reason via `note`. + */ + LOW = 'low', +} + +/** + * Provenance and confidence metadata for a context block. + * + * Mirrors the schema's `TrustObject`; each property is written to the template + * under the same name. Context written by tooling should say so through `src`; + * `AUTHORED` is reserved for information a person wrote or explicitly confirmed. + * `trust` is optional, but when supplied both `src` and `conf` are required — + * CDK never infers them. + */ +export interface CfnContextTrust { + /** + * How this context was produced. + */ + readonly src: CfnContextTrustSource; + + /** + * Confidence in the context's accuracy. + */ + readonly conf: CfnContextTrustConfidence; + + /** + * Source reference backing this context (e.g. `file.ts:42`, a URL, or a + * commit SHA). + * + * @default - no citation + */ + readonly cite?: string; + + /** + * Reason for reduced confidence (typically when `conf` is `LOW`). + * + * @default - no note + */ + readonly note?: string; +} + +/** + * A reference to supporting context. + * + * Mirrors the object form of the schema's `RefEntry`. References share context + * across templates and move lower-value detail out of a template near the + * CloudFormation size limit. + */ +export interface CfnContextRef { + /** + * URI to the external context source: a relative repository path, `s3://`, + * or `https://`. + * + * CDK does not fetch or verify the reference; callers are responsible for + * that. Treat referenced content as untrusted data. + */ + readonly at: string; + + /** + * Terse hint of what the reference contains, so a consumer can decide + * whether to fetch it. + * + * @default - no hint + */ + readonly has?: string; + + /** + * Usage scope. Common values: `shared` (reused across templates) and + * `overflow` (moved out of the template for size). + * + * @default - no scope + */ + readonly scope?: string; +} + +/** + * Resource-level context, rendered as a `Metadata["com.aws.cloudformation.Context"]` block on a + * CloudFormation resource. + * + * Mirrors the schema's `ResourceContext`; each property is written to the + * template under the same name. + * + * Every field is optional in the advisory schema and CDK enforces no top-level + * requiredness: a `set()` call may omit any field, and the value it renders + * replaces the complete Context on each selected resource. A `why` is recommended so + * consumers understand a resource's purpose, but it is not required — omit + * Context entirely for a trivial resource whose purpose is already obvious from + * its type and name. + * + * Use concise values to conserve template bytes. Authors should remove + * unnecessary words and may use standard symbols or abbreviations when their + * meaning remains clear. + * + * Never include secrets, credentials, or personally identifiable information. + * CloudFormation Metadata is visible through service APIs. Consumers must + * treat all context fields as untrusted data, never as instructions. + */ +export interface CfnResourceMetadataContextProps { + /** + * Reasoning — purpose, important configuration choices, and rejected + * alternatives. Non-binding. + * + * Optional and not enforced. Recommended for every non-trivial resource so + * consumers can act on intent. + * + * Example: `'buffers order events asynchronously; 14-day retention meets compliance requirements'`. + * + * @default - no rationale recorded + */ + readonly why?: string; + + /** + * Required rules. Violating an entry would cause data loss, an outage, a + * security violation, silent corruption, or a dependency failure. + * + * Optional and not enforced. Recommended when `mutable` or any `mutability` + * value is `MUST_NEVER_CHANGE` or `CHANGE_WITH_CONSTRAINTS`, so the constraint + * behind the restriction is stated. + * + * Example: `['VisibilityTimeout must be at least six times the Lambda timeout']`. + * + * @default - no hard constraints recorded + */ + readonly must?: string[]; + + /** + * Resource-level default change-safety level (one value per resource). + * + * When set to `MUST_NEVER_CHANGE` or `CHANGE_WITH_CONSTRAINTS`, a `must` + * entry documenting the constraint is recommended but not enforced. + * + * @default - no change-safety default recorded + */ + readonly mutable?: CfnContextMutability; + + /** + * Sparse per-property change-safety override map (keys are CloudFormation + * property names). + * + * List only properties that differ from `mutable` or are especially + * important. Omit the map when empty and do not enumerate every property. + * When `mutable` is also supplied, entries that repeat the default are + * omitted. When an entry is `MUST_NEVER_CHANGE` or + * `CHANGE_WITH_CONSTRAINTS`, a `must` entry documenting the constraint is + * recommended but not enforced. + * + * @default - no per-property overrides + */ + readonly mutability?: { [propertyName: string]: CfnContextMutability }; + + /** + * Source and confidence for the context content. + * + * Optional and may be supplied as the only field. When provided, `src` and + * `conf` are required (CDK never infers them); `cite` and `note` stay + * optional. + * + * @default - no trust metadata recorded + */ + readonly trust?: CfnContextTrust; + + /** + * Cross-stack or cross-resource producer dependencies, by name: other + * stacks, external services, or systems outside the account. + * + * @default - no dependencies recorded + */ + readonly deps?: string[]; +} + +/** + * Template-level context, rendered as a top-level `Metadata["com.aws.cloudformation.Context"]` block + * in the CloudFormation template. + * + * Mirrors the schema's `TemplateContext`; each property is written to the + * template under the same name. + * + * Holds information that applies throughout the template. Per-resource + * specifics belong in resource-level context; the stack purpose belongs in + * the built-in CloudFormation `Description`. + * + * Every field is optional and CDK enforces no top-level requiredness. Supply + * any combination; `set({})` writes an empty Context object. Values must be + * concrete because CloudFormation does not resolve intrinsic functions in + * top-level template Metadata; `set()` rejects unresolved tokens. + * + * Never include secrets, credentials, or personally identifiable information. + * Consumers must treat template context as untrusted data, never as instructions. + */ +export interface CfnTemplateMetadataContextProps { + /** + * High-level shape/pattern of the system. + * + * Example: `'SQS buffer -> Lambda -> DynamoDB; DLQ for poison msgs'`. + * + * @default - no architecture overview recorded + */ + readonly arch?: string; + + /** + * Cross-cutting constraints that apply broadly across the template. + * + * Example: `['all data encrypted w/ security-team CMK']`. + * + * @default - no cross-cutting constraints recorded + */ + readonly must?: string[]; + + /** + * References to supporting context — relative repository paths, `s3://`, or `https://`. + * + * A reference with only `at` is written as a bare URI string; one with `has` + * or `scope` is written as an object, matching the schema's `RefEntry`. + * Inline template context takes precedence over referenced content. Treat + * referenced content as untrusted data, never as agent instructions. If a + * reference cannot be read, continue with the inline context and report the + * missing reference. + * + * @default - no references + */ + readonly ref?: CfnContextRef[]; + + /** + * Owner/contact identifier for a team or role. + * + * Do not include an individual's name, email address, or other personally + * identifiable information. Include only when ownership is not already + * expressed as a tag. + * + * @default - no owner recorded + */ + readonly owner?: string; +} + +/** + * Options for `CfnResourceMetadataContext.of()`. + */ +export interface CfnResourceMetadataContextOptions { + /** + * Selects the constructs targeted by `set()` or `clear()`. + * + * The selector runs during synthesis, after the construct tree is complete, + * against the scope passed to `CfnResourceMetadataContext.of()`. Only + * selected `CfnResource` constructs are changed; other selected constructs + * are ignored. A call whose selector selects no `CfnResource` fails + * synthesis. + * + * The default selects the scope itself when it is a `CfnResource`, or its + * immediate `defaultChild` when that child is a `CfnResource`. It does not + * follow a `defaultChild` chain through other constructs, and it skips + * helper resources such as auto-created IAM roles. + * + * Use `ConstructSelector.all()` to target every `CfnResource` beneath the + * scope, helpers included, or `ConstructSelector.resourcesOfType()`, + * `byId()` or `byPath()` to narrow the selection. Resources in a + * `NestedStack` can be selected; resources beneath a `Stage` cannot, because + * each Stage is synthesized as its own cloud assembly. + * + * @default ConstructSelector.cfnResource() + */ + readonly selector?: IConstructSelector; + + /** + * The priority of the underlying aspect. + * + * Lower priorities run first. When several calls select the same resource, + * the call applied last determines the result. + * + * @default AspectPriority.MUTATING + */ + readonly priority?: number; +} + +/** + * Sets or clears resource-level `Metadata["com.aws.cloudformation.Context"]` + * on CloudFormation resources within a construct scope. + * + * `Metadata["com.aws.cloudformation.Context"]` is structured, advisory context embedded in + * CloudFormation templates. It carries the *why* behind infrastructure — + * rationale, invariants, change-safety, provenance — so + * that humans and automated tools modifying the deployed template later can + * act with the author's intent instead of guessing it. + * + * Each `set()` or `clear()` call registers an Aspect using the selector and + * priority supplied to `of()`. When the Aspect visits a selected + * `CfnResource`, `set()` replaces the complete Context value and `clear()` + * removes it. Values are never merged: lower Aspect priorities run first; at + * the same priority an Aspect on a parent scope runs before one on a child + * scope, and calls on the same scope run in call order. The last applied + * call determines the result, including over Context written directly with + * `CfnResource.addMetadata()` while the construct tree was built. + * + * Use `CfnTemplateMetadataContext` for template-level (stack-wide) context. + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-attribute-metadata.html#aws-attribute-metadata-context-schema + * + * @example + * declare const queue: sqs.Queue; + * CfnResourceMetadataContext.of(queue).set({ + * why: 'buffer order events async; 14d retention = compliance window', + * must: ['VisTimeout >= 6x fn timeout, else dup on retry'], + * mutable: CfnContextMutability.CHANGE_WITH_CONSTRAINTS, + * mutability: { QueueName: CfnContextMutability.MUST_NEVER_CHANGE }, + * }); + */ +export class CfnResourceMetadataContext { + /** + * Returns the resource context API for the given scope and targeting options. + * + * @param scope The scope whose resources the returned API sets or clears Context on + * @param options Targeting and Aspect priority for subsequent `set()` and `clear()` calls + */ + public static of( + scope: IConstruct, + options: CfnResourceMetadataContextOptions = {}, + ): CfnResourceMetadataContext { + return new CfnResourceMetadataContext(scope, options); + } + + private constructor( + private readonly scope: IConstruct, + private readonly options: CfnResourceMetadataContextOptions, + ) { + } + + /** + * Replaces the Context on the resources selected by this API's options. + * + * The selector runs during synthesis, so it also covers resources added + * beneath the scope after this call. This method never throws; invalid + * values and a call that selects no `CfnResource` fail synthesis. + */ + public set(context: CfnResourceMetadataContextProps): void { + // Validate and write the same snapshot if the caller later mutates its input. + const rendered: CfnResourceMetadataContextProps = renderResourceContext(context); + this.addResourceAspect( + (resource) => resource.addMetadata(METADATA_CONTEXT_KEY, renderResourceContext(rendered)), + validateResourceContext(rendered), + ); + } + + /** + * Removes the Context from the resources selected by this API's options. + * + * The selector runs during synthesis. A call that selects no `CfnResource` + * fails synthesis. + */ + public clear(): void { + this.addResourceAspect((resource) => deleteContext(resource.cfnOptions.metadata), []); + } + + private addResourceAspect( + applyToResource: (resource: CfnResource) => void, + valueErrors: string[], + ): void { + const aspect = new CfnResourceMetadataContextAspect( + this.scope, + this.options.selector ?? ConstructSelector.cfnResource(), + applyToResource, + ); + Aspects.of(this.scope).add(aspect, { priority: this.options.priority ?? AspectPriority.MUTATING }); + this.scope.node.addValidation({ + validate: () => aspect.hasSelectedResource ? valueErrors : [...valueErrors, NO_RESOURCE_SELECTED_ERROR], + }); + } +} + +/** + * Sets or clears the template-level `Metadata["com.aws.cloudformation.Context"]` + * block for a stack. + * + * Template-level context holds cross-cutting facts stated once: the + * architecture overview, template-wide invariants, external context + * references and ownership. It is rendered as a top-level `Metadata` block in + * the synthesized CloudFormation template. For per-resource context, use + * `CfnResourceMetadataContext`. + * + * `set()` and `clear()` change the stack's template metadata immediately, like + * `Stack.addMetadata()` on the same key, so ordinary call order determines + * the result. + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-attribute-metadata.html#aws-attribute-metadata-context-schema + * + * @example + * declare const stack: Stack; + * CfnTemplateMetadataContext.of(stack).set({ + * arch: 'SQS buffer -> Lambda -> DynamoDB; DLQ for poison msgs', + * must: ['all data encrypted w/ security-team CMK'], + * owner: 'order-processing-team', + * }); + */ +export class CfnTemplateMetadataContext { + /** + * Returns the template context API for the given stack. + * + * @param stack The stack whose template receives the context + */ + public static of(stack: Stack): CfnTemplateMetadataContext { + return new CfnTemplateMetadataContext(stack); + } + + private constructor(private readonly stack: Stack) { + } + + /** + * Replaces the template Context of this stack. + * + * Values must be concrete. Unresolved tokens are rejected because top-level + * template Metadata does not resolve intrinsic functions. + */ + public set(context: CfnTemplateMetadataContextProps): void { + validateTemplateContext(this.stack, context); + this.stack.addMetadata(METADATA_CONTEXT_KEY, renderTemplateContext(context)); + } + + /** + * Removes the template Context of this stack. + */ + public clear(): void { + const metadata = this.stack.templateOptions.metadata; + deleteContext(metadata); + // Avoid rendering an empty top-level Metadata section. + if (metadata !== undefined && Object.keys(metadata).length === 0) { + this.stack.templateOptions.metadata = undefined; + } + } +} + +const NO_RESOURCE_SELECTED_ERROR = 'metadata context set() or clear() selected no CfnResource; ' + + 'target a CfnResource or a construct whose defaultChild is a CfnResource, set a selector such as ' + + 'ConstructSelector.all() or ConstructSelector.resourcesOfType(), or call the API inside each Stage'; + +class CfnResourceMetadataContextAspect implements IAspect { + private selection?: { readonly scanned: Set; readonly selected: Set }; + private selectedResource = false; + + constructor( + private readonly scope: IConstruct, + private readonly selector: IConstructSelector, + private readonly applyToResource: (resource: CfnResource) => void, + ) { + } + + public get hasSelectedResource(): boolean { + return this.selectedResource; + } + + public visit(node: IConstruct): void { + if (node === this.scope) { + this.selection = undefined; + this.selectedResource = false; + } + + if (!CfnResource.isCfnResource(node) || !this.selects(node)) { + return; + } + this.selectedResource = true; + this.applyToResource(node); + } + + private selects(resource: CfnResource): boolean { + // Re-evaluate when an earlier Aspect adds a resource. + if (this.selection === undefined || !this.selection.scanned.has(resource)) { + this.selection = { + scanned: new Set(this.scope.node.findAll()), + selected: new Set(this.selector.select(this.scope)), + }; + } + return this.selection.selected.has(resource); + } +} + +function deleteContext(metadata: { [key: string]: any } | undefined): void { + if (metadata !== undefined) { + delete metadata[METADATA_CONTEXT_KEY]; + } +} diff --git a/packages/aws-cdk-lib/core/lib/private/metadata-context-internal.ts b/packages/aws-cdk-lib/core/lib/private/metadata-context-internal.ts new file mode 100644 index 0000000000000..c2fa06974baa3 --- /dev/null +++ b/packages/aws-cdk-lib/core/lib/private/metadata-context-internal.ts @@ -0,0 +1,135 @@ +import type { IConstruct } from 'constructs'; +import { ValidationError } from '../errors'; +import type { CfnResourceMetadataContextProps, CfnTemplateMetadataContextProps, CfnContextRef } from '../metadata-context'; +import { Token } from '../token'; +import { lit } from './literal-string'; + +export const METADATA_CONTEXT_KEY = 'com.aws.cloudformation.Context'; + +export function renderResourceContext(context: CfnResourceMetadataContextProps): Record { + const out: Record = {}; + if (context.why !== undefined) { + out.why = context.why; + } + if (context.must !== undefined) { + out.must = copyArray(context.must); + } + if (context.mutable !== undefined) { + out.mutable = context.mutable; + } + if (context.mutability !== undefined) { + if (Token.isUnresolved(context.mutability)) { + out.mutability = context.mutability; + } else { + const entries = Object.entries(context.mutability); + const deviations = entries.filter(([, level]) => level !== context.mutable); + if (entries.length === 0 || deviations.length > 0) { + out.mutability = Object.fromEntries(deviations); + } + } + } + if (context.trust !== undefined) { + const trust: Record = {}; + if (context.trust.src !== undefined) { + trust.src = context.trust.src; + } + if (context.trust.conf !== undefined) { + trust.conf = context.trust.conf; + } + if (context.trust.cite !== undefined) { + trust.cite = context.trust.cite; + } + if (context.trust.note !== undefined) { + trust.note = context.trust.note; + } + out.trust = trust; + } + if (context.deps !== undefined) { + out.deps = copyArray(context.deps); + } + return out; +} + +export function renderTemplateContext(context: CfnTemplateMetadataContextProps): Record { + const out: Record = {}; + if (context.arch !== undefined) { + out.arch = context.arch; + } + if (context.must !== undefined) { + out.must = copyArray(context.must); + } + if (context.ref !== undefined) { + out.ref = context.ref.map(renderRef); + } + if (context.owner !== undefined) { + out.owner = context.owner; + } + return out; +} + +function renderRef(ref: CfnContextRef): any { + if (ref.has === undefined && ref.scope === undefined) { + // Bare-string form keeps templates terse. + return ref.at; + } + const out: Record = { at: ref.at }; + if (ref.has !== undefined) { + out.has = ref.has; + } + if (ref.scope !== undefined) { + out.scope = ref.scope; + } + return out; +} + +function copyArray(values: T[]): T[] { + return Token.isUnresolved(values) ? values : [...values]; +} + +export function validateResourceContext(context: CfnResourceMetadataContextProps): string[] { + return validateTrust(context.trust); +} + +function validateTrust(trust: CfnResourceMetadataContextProps['trust']): string[] { + if (trust === undefined) { + return []; + } + const errors: string[] = []; + if (trust.src === undefined) { + errors.push('trust requires \'src\' when provided'); + } + if (trust.conf === undefined) { + errors.push('trust requires \'conf\' when provided'); + } + return errors; +} + +export function validateTemplateContext(scope: IConstruct, context: CfnTemplateMetadataContextProps) { + rejectUnresolvedToken(scope, 'arch', context.arch); + rejectUnresolvedToken(scope, 'must', context.must); + for (const [index, entry] of (context.must ?? []).entries()) { + rejectUnresolvedToken(scope, `must[${index}]`, entry); + } + + rejectUnresolvedToken(scope, 'ref', context.ref); + for (const [index, ref] of (context.ref ?? []).entries()) { + rejectUnresolvedToken(scope, `ref[${index}]`, ref); + if (typeof ref.at !== 'string') { + throw new ValidationError(lit`MissingMetadataContextRefAt`, 'ref entries require an \'at\' path', scope); + } + rejectUnresolvedToken(scope, `ref[${index}].at`, ref.at); + rejectUnresolvedToken(scope, `ref[${index}].has`, ref.has); + rejectUnresolvedToken(scope, `ref[${index}].scope`, ref.scope); + } + rejectUnresolvedToken(scope, 'owner', context.owner); +} + +function rejectUnresolvedToken(scope: IConstruct, path: string, value: any): void { + if (Token.isUnresolved(value)) { + throw new ValidationError( + lit`UnresolvedTokenInTemplateMetadataContext`, + `template context field '${path}' cannot contain unresolved tokens because CloudFormation does not resolve intrinsic functions in template metadata`, + scope, + ); + } +} diff --git a/packages/aws-cdk-lib/core/test/metadata-context.test.ts b/packages/aws-cdk-lib/core/test/metadata-context.test.ts new file mode 100644 index 0000000000000..99654c0b5ee4a --- /dev/null +++ b/packages/aws-cdk-lib/core/test/metadata-context.test.ts @@ -0,0 +1,1197 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { IConstruct } from 'constructs'; +import { Construct } from 'constructs'; +import type { CfnTemplateMetadataContextProps, IAspect, IConstructSelector } from '../lib'; +import { + App, + AspectPriority, + Aspects, + CfnResource, + CfnContextMutability, + CfnContextTrustConfidence, + CfnContextTrustSource, + ConstructSelector, + Lazy, + NestedStack, + CfnResourceMetadataContext, + Stack, + Stage, + CfnTemplateMetadataContext, + ValidationError, +} from '../lib'; +import { toCloudFormation } from './util'; +import { synthesize } from '../lib/private/synthesis'; + +const CONTEXT_METADATA_KEY = 'com.aws.cloudformation.Context'; +const NO_RESOURCE_SELECTED = /metadata context set\(\) or clear\(\) selected no CfnResource/; + +describe('metadata context', () => { + describe('resource-level context', () => { + test('renders a namespaced Context metadata block on a CfnResource', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Queue', { type: 'AWS::SQS::Queue' }); + + CfnResourceMetadataContext.of(res).set({ + why: 'buffer order events async; 14d retention = compliance window', + must: ['VisTimeout >= 6x fn timeout, else dup on retry'], + mutable: CfnContextMutability.CHANGE_WITH_CONSTRAINTS, + mutability: { QueueName: CfnContextMutability.MUST_NEVER_CHANGE }, + deps: ['NetworkStack'], + }); + + const template = toCloudFormation(stack); + expect(template.Resources.Queue.Metadata[CONTEXT_METADATA_KEY]).toEqual({ + why: 'buffer order events async; 14d retention = compliance window', + must: ['VisTimeout >= 6x fn timeout, else dup on retry'], + mutable: 'change-with-constraints', + mutability: { QueueName: 'must-never-change' }, + deps: ['NetworkStack'], + }); + }); + + test('API property names are written to the template unchanged (1:1 with the schema)', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ + why: 'resource name is referenced by an external consumer', + must: ['Name must not change because replacement loses the external reference'], + mutable: CfnContextMutability.FREE_TO_TUNE, + mutability: { Name: CfnContextMutability.MUST_NEVER_CHANGE }, + trust: { src: CfnContextTrustSource.AUTHORED, conf: CfnContextTrustConfidence.HIGH, cite: 'docs/naming.md' }, + deps: ['ConsumerStack'], + }); + + const context = toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]; + expect(Object.keys(context).sort()).toEqual(['deps', 'must', 'mutability', 'mutable', 'trust', 'why']); + expect(Object.keys(context.trust).sort()).toEqual(['cite', 'conf', 'src']); + expect(context.mutable).toEqual('free-to-tune'); + expect(context.mutability).toEqual({ Name: 'must-never-change' }); + }); + + test('emits no trust block when trust is not supplied', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ why: 'no trust recorded here', must: ['a rule'] }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ + why: 'no trust recorded here', + must: ['a rule'], + }); + }); + + test('renders explicit trust with the schema field names src/conf/cite/note', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ + why: 'absorb transient processor failures without dropping orders', + trust: { + src: CfnContextTrustSource.INFER, + conf: CfnContextTrustConfidence.LOW, + cite: 'api/handler.ts:87', + note: 'rationale inferred from retry wrapper; no explicit design doc found', + }, + }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY].trust).toEqual({ + src: 'infer', + conf: 'low', + cite: 'api/handler.ts:87', + note: 'rationale inferred from retry wrapper; no explicit design doc found', + }); + }); + + test('list values are written as given, without de-duplication', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ must: ['same rule', 'same rule'], deps: ['NetworkStack', 'NetworkStack'] }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ + must: ['same rule', 'same rule'], + deps: ['NetworkStack', 'NetworkStack'], + }); + }); + + test('resolves lazy resource list values without inspecting their token encoding', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ + must: Lazy.list({ produce: () => ['resolved rule'] }), + deps: Lazy.list({ produce: () => ['ResolvedStack'] }), + }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ + must: ['resolved rule'], + deps: ['ResolvedStack'], + }); + }); + }); + + describe('resource-level targeting', () => { + test('the default selector targets the scope when it is a CfnResource', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ why: 'on the resource itself' }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ why: 'on the resource itself' }); + }); + + test('the default selector targets an immediate CfnResource defaultChild and skips helper resources', () => { + const stack = new Stack(); + const l2 = new Construct(stack, 'MyQueue'); + const primary = new CfnResource(l2, 'Resource', { type: 'AWS::SQS::Queue' }); + const helper = new CfnResource(l2, 'HelperRole', { type: 'AWS::IAM::Role' }); + + CfnResourceMetadataContext.of(l2).set({ why: 'buffers events' }); + + const template = toCloudFormation(stack); + expect(template.Resources[stack.getLogicalId(primary)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ why: 'buffers events' }); + expect(template.Resources[stack.getLogicalId(helper)].Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + }); + + test('fails when the defaultChild is a construct, because the default selector does not follow a defaultChild chain', () => { + const stack = new Stack(); + const l3 = new Construct(stack, 'EdgeFunction'); + const l2 = new Construct(l3, 'Fn'); + const primary = new CfnResource(l2, 'Resource', { type: 'AWS::Lambda::Function' }); + l3.node.defaultChild = l2; + + CfnResourceMetadataContext.of(l3).set({ why: 'runs at the edge' }); + + expect(() => synthesize(stack)).toThrow(NO_RESOURCE_SELECTED); + expect(primary.getMetadata(CONTEXT_METADATA_KEY)).toBeUndefined(); + }); + + test('fails for an L3 that declares no defaultChild even when its children do', () => { + const stack = new Stack(); + const l3 = new Construct(stack, 'Service'); + new CfnResource(new Construct(l3, 'LB'), 'Resource', { type: 'AWS::ElasticLoadBalancingV2::LoadBalancer' }); + new CfnResource(new Construct(l3, 'Svc'), 'Resource', { type: 'AWS::ECS::Service' }); + + CfnResourceMetadataContext.of(l3).set({ why: 'no primary resource' }); + + expect(() => synthesize(stack)).toThrow(NO_RESOURCE_SELECTED); + }); + + test('fails for a grouping construct with no CfnResource defaultChild', () => { + const stack = new Stack(); + const group = new Construct(stack, 'SubSystem'); + new CfnResource(group, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(group).set({ why: 'grouping rationale' }); + + expect(() => synthesize(stack)).toThrow(NO_RESOURCE_SELECTED); + }); + + test('fails from a Stack scope with the default selector', () => { + const stack = new Stack(); + new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(stack).set({ why: 'stack-wide but narrow by default' }); + + expect(() => synthesize(stack)).toThrow(NO_RESOURCE_SELECTED); + }); + + test('an L3 without a defaultChild can be targeted with a resource-type selector', () => { + const stack = new Stack(); + const l3 = new Construct(stack, 'Service'); + const lbL2 = new Construct(l3, 'LB'); + const lb = new CfnResource(lbL2, 'Resource', { type: 'AWS::ElasticLoadBalancingV2::LoadBalancer' }); + const lbHelper = new CfnResource(lbL2, 'SecurityGroup', { type: 'AWS::EC2::SecurityGroup' }); + const svc = new CfnResource(new Construct(l3, 'Svc'), 'Resource', { type: 'AWS::ECS::Service' }); + + CfnResourceMetadataContext.of(l3, { + selector: ConstructSelector.resourcesOfType('AWS::ElasticLoadBalancingV2::LoadBalancer'), + }).set({ + must: ['ALB idle timeout >= backend read timeout'], + }); + + const template = toCloudFormation(stack); + expect(template.Resources[stack.getLogicalId(lb)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ + must: ['ALB idle timeout >= backend read timeout'], + }); + expect(template.Resources[stack.getLogicalId(lbHelper)].Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + expect(template.Resources[stack.getLogicalId(svc)].Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + }); + + test('ConstructSelector.all() targets every CfnResource beneath the scope, helpers included', () => { + const stack = new Stack(); + const l2 = new Construct(stack, 'MyQueue'); + const primary = new CfnResource(l2, 'Resource', { type: 'AWS::SQS::Queue' }); + const helper = new CfnResource(l2, 'Policy', { type: 'AWS::SQS::QueuePolicy' }); + const loose = new CfnResource(stack, 'Loose', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(stack, { selector: ConstructSelector.all() }).set({ deps: ['NetworkStack'] }); + + const template = toCloudFormation(stack); + for (const resource of [primary, helper, loose]) { + expect(template.Resources[stack.getLogicalId(resource)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ deps: ['NetworkStack'] }); + } + }); + + test('a selector that omits the primary resource type targets only the helper resources of an L2', () => { + const stack = new Stack(); + const l2 = new Construct(stack, 'Fn'); + const primary = new CfnResource(l2, 'Resource', { type: 'AWS::Lambda::Function' }); + const role = new CfnResource(l2, 'ServiceRole', { type: 'AWS::IAM::Role' }); + const policy = new CfnResource(l2, 'ServiceRolePolicy', { type: 'AWS::IAM::Policy' }); + + CfnResourceMetadataContext.of(l2, { + selector: ConstructSelector.resourcesOfType('AWS::IAM::Role', 'AWS::IAM::Policy'), + }).set({ + why: 'supporting resource for the order processor', + }); + + const template = toCloudFormation(stack); + expect(template.Resources[stack.getLogicalId(primary)].Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + for (const helper of [role, policy]) { + expect(template.Resources[stack.getLogicalId(helper)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ + why: 'supporting resource for the order processor', + }); + } + }); + + test('selected constructs that are not CfnResources are ignored', () => { + const stack = new Stack(); + const fn = new Construct(stack, 'Fn'); + const role = new Construct(fn, 'ServiceRole'); + const cfnRole = new CfnResource(role, 'Resource', { type: 'AWS::IAM::Role' }); + const cfnServiceRole = new CfnResource(stack, 'ServiceRole', { type: 'AWS::IAM::Role' }); + + CfnResourceMetadataContext.of(stack, { selector: ConstructSelector.byId('ServiceRole') }).set({ + must: ['execution roles must keep the organization permissions boundary'], + }); + + const template = toCloudFormation(stack); + expect(template.Resources[stack.getLogicalId(cfnRole)].Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + expect(template.Resources[stack.getLogicalId(cfnServiceRole)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ + must: ['execution roles must keep the organization permissions boundary'], + }); + }); + + test('ConstructSelector.onlyItself() fails on a construct that is not a CfnResource', () => { + const stack = new Stack(); + const l2 = new Construct(stack, 'MyQueue'); + const primary = new CfnResource(l2, 'Resource', { type: 'AWS::SQS::Queue' }); + + CfnResourceMetadataContext.of(l2, { selector: ConstructSelector.onlyItself() }).set({ why: 'buffers events' }); + + expect(() => synthesize(stack)).toThrow(NO_RESOURCE_SELECTED); + expect(primary.getMetadata(CONTEXT_METADATA_KEY)).toBeUndefined(); + }); + + test('fails when the selector selects only constructs that are not CfnResources', () => { + const stack = new Stack(); + const group = new Construct(stack, 'Group'); + new CfnResource(group, 'Resource', { type: 'AWS::SQS::Queue' }); + + CfnResourceMetadataContext.of(stack, { selector: ConstructSelector.byId('Group') }).set({ why: 'grouping rationale' }); + + expect(() => synthesize(stack)).toThrow(NO_RESOURCE_SELECTED); + }); + + test('the selector runs at synthesis, so it covers resources added after set()', () => { + const stack = new Stack(); + + CfnResourceMetadataContext.of(stack, { + selector: ConstructSelector.resourcesOfType('AWS::SQS::Queue'), + }).set({ + must: ['delivery settings must preserve in-flight messages'], + }); + const queue = new CfnResource(stack, 'Queue', { type: 'AWS::SQS::Queue' }); + + expect(toCloudFormation(stack).Resources[stack.getLogicalId(queue)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ + must: ['delivery settings must preserve in-flight messages'], + }); + }); + + test.each([ + ['single-pass Aspect traversal', false], + ['stabilized Aspect traversal', true], + ])('re-evaluates a custom selector on each synthesis with %s', (_description, aspectStabilization) => { + class SelectTarget implements IConstructSelector { + public target?: IConstruct; + + public select(): IConstruct[] { + return this.target === undefined ? [] : [this.target]; + } + } + + const stack = new Stack(); + const first = new CfnResource(stack, 'First', { type: 'AWS::Fake::Thing' }); + const second = new CfnResource(stack, 'Second', { type: 'AWS::Fake::Thing' }); + const selector = new SelectTarget(); + selector.target = first; + CfnResourceMetadataContext.of(stack, { selector }).set({ why: 'selected resource' }); + + synthesize(stack, { aspectStabilization }); + expect(first.getMetadata(CONTEXT_METADATA_KEY)).toEqual({ why: 'selected resource' }); + expect(second.getMetadata(CONTEXT_METADATA_KEY)).toBeUndefined(); + + selector.target = second; + synthesize(stack, { aspectStabilization }); + expect(second.getMetadata(CONTEXT_METADATA_KEY)).toEqual({ why: 'selected resource' }); + }); + + test.each([ + ['single-pass Aspect traversal', false], + ['stabilized Aspect traversal', true], + ])('does not carry a successful selection into a later synthesis with %s', (_description, aspectStabilization) => { + class ToggleSelector implements IConstructSelector { + public isEnabled = true; + + public select(scope: IConstruct): IConstruct[] { + return this.isEnabled ? [scope] : []; + } + } + + const stack = new Stack(); + const resource = new CfnResource(stack, 'Resource', { type: 'AWS::Fake::Thing' }); + const selector = new ToggleSelector(); + CfnResourceMetadataContext.of(resource, { selector }).set({ why: 'selected resource' }); + + expect(() => synthesize(stack, { aspectStabilization })).not.toThrow(); + + selector.isEnabled = false; + expect(() => synthesize(stack, { aspectStabilization })).toThrow(NO_RESOURCE_SELECTED); + }); + + test.each([ + ['single-pass Aspect traversal', false], + ['stabilized Aspect traversal', true], + ])('rescans for a resource created by a lower-priority Aspect with %s', (_description, aspectStabilization) => { + const stack = new Stack(); + new CfnResource(stack, 'Before', { type: 'AWS::Fake::Before' }); + const trigger = new Construct(stack, 'Trigger'); + let createdResource: CfnResource | undefined; + + Aspects.of(stack).add({ + visit(node: IConstruct): void { + if (node === trigger && createdResource === undefined) { + createdResource = new CfnResource(trigger, 'Created', { type: 'AWS::Fake::Created' }); + } + }, + }, { priority: AspectPriority.MUTATING }); + CfnResourceMetadataContext.of(stack, { + selector: ConstructSelector.resourcesOfType('AWS::Fake::Created'), + priority: AspectPriority.DEFAULT, + }).set({ + why: 'created during Aspect traversal', + }); + + expect(() => synthesize(stack, { aspectStabilization })).not.toThrow(); + expect(createdResource?.getMetadata(CONTEXT_METADATA_KEY)).toEqual({ why: 'created during Aspect traversal' }); + }); + + test('accepts a custom IConstructSelector', () => { + class SelectConstruct implements IConstructSelector { + constructor(private readonly target: IConstruct) { + } + + public select(): IConstruct[] { + return [this.target]; + } + } + const stack = new Stack(); + new CfnResource(stack, 'Other', { type: 'AWS::Fake::Thing' }); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(stack, { selector: new SelectConstruct(res) }).set({ why: 'custom selection' }); + + const template = toCloudFormation(stack); + expect(template.Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ why: 'custom selection' }); + expect(template.Resources.Other.Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + }); + + test('fails when a resource-type selector matches no resources', () => { + const stack = new Stack(); + const scope = new Construct(stack, 'SubSystem'); + new CfnResource(scope, 'Topic', { type: 'AWS::SNS::Topic' }); + + CfnResourceMetadataContext.of(scope, { + selector: ConstructSelector.resourcesOfType('AWS::SQS::Queue'), + }).set({ + why: 'queue-only rationale', + }); + + expect(() => synthesize(stack)).toThrow(NO_RESOURCE_SELECTED); + }); + + test('ConstructSelector.all() fails on an empty scope', () => { + const stack = new Stack(); + const scope = new Construct(stack, 'Empty'); + + CfnResourceMetadataContext.of(scope, { selector: ConstructSelector.all() }).set({ why: 'no targets' }); + + expect(() => synthesize(stack)).toThrow(NO_RESOURCE_SELECTED); + }); + + test('each set() and clear() call must independently select a CfnResource', () => { + const stack = new Stack(); + const scope = new Construct(stack, 'SubSystem'); + new CfnResource(scope, 'Queue', { type: 'AWS::SQS::Queue' }); + + CfnResourceMetadataContext.of(scope, { + selector: ConstructSelector.resourcesOfType('AWS::SQS::Queue'), + }).set({ why: 'queue rationale' }); + CfnResourceMetadataContext.of(scope, { + selector: ConstructSelector.resourcesOfType('AWS::SNS::Topic'), + }).clear(); + + expect(() => synthesize(stack)).toThrow(NO_RESOURCE_SELECTED); + }); + + test('an ambiguous defaultChild surfaces the constructs error at synthesis', () => { + const stack = new Stack(); + const ambiguous = new Construct(stack, 'Ambiguous'); + new CfnResource(ambiguous, 'Resource', { type: 'AWS::Fake::Thing' }); + new CfnResource(ambiguous, 'Default', { type: 'AWS::Fake::Other' }); + + CfnResourceMetadataContext.of(ambiguous).set({ why: 'x' }); + + expect(() => synthesize(stack)).toThrow( + /Cannot determine default child for .*Ambiguous.*both a child with id "Resource" and id "Default"/, + ); + }); + }); + + describe('resource-level replacement and ordering', () => { + test('a set() on a child scope replaces the complete Context set on a parent scope', () => { + const stack = new Stack(); + const l2 = new Construct(stack, 'WebhookQueue'); + const queue = new CfnResource(l2, 'Resource', { type: 'AWS::SQS::Queue' }); + const otherQueue = new CfnResource(stack, 'OtherQueue', { type: 'AWS::SQS::Queue' }); + + CfnResourceMetadataContext.of(stack, { + selector: ConstructSelector.resourcesOfType('AWS::SQS::Queue'), + }).set({ + why: 'part of the order-processing subsystem', + must: ['queues use the security team customer managed AWS KMS key'], + }); + CfnResourceMetadataContext.of(l2).set({ + why: 'buffers webhook events for asynchronous processing', + must: ['VisibilityTimeout must be at least six times the consumer timeout'], + }); + + const template = toCloudFormation(stack); + expect(template.Resources[stack.getLogicalId(queue)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ + why: 'buffers webhook events for asynchronous processing', + must: ['VisibilityTimeout must be at least six times the consumer timeout'], + }); + expect(template.Resources[stack.getLogicalId(otherQueue)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ + why: 'part of the order-processing subsystem', + must: ['queues use the security team customer managed AWS KMS key'], + }); + }); + + test('a child scope wins at the same priority even when its set() call is registered first', () => { + const stack = new Stack(); + const l2 = new Construct(stack, 'WebhookQueue'); + const queue = new CfnResource(l2, 'Resource', { type: 'AWS::SQS::Queue' }); + + CfnResourceMetadataContext.of(l2).set({ why: 'child rationale' }); + CfnResourceMetadataContext.of(stack, { + selector: ConstructSelector.resourcesOfType('AWS::SQS::Queue'), + }).set({ why: 'parent rationale' }); + + expect(toCloudFormation(stack).Resources[stack.getLogicalId(queue)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ + why: 'child rationale', + }); + }); + + test('repeated set() calls on the same scope apply in call order, and the last call replaces the value', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ why: 'first rationale', must: ['rule 1'] }); + CfnResourceMetadataContext.of(res).set({ deps: ['NetworkStack'] }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ deps: ['NetworkStack'] }); + }); + + test('a lower priority runs first, so a parent set() with a higher priority replaces a child set()', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(stack, { + selector: ConstructSelector.all(), + priority: AspectPriority.DEFAULT, + }).set({ why: 'applied last' }); + CfnResourceMetadataContext.of(res, { priority: AspectPriority.MUTATING }).set({ why: 'applied first' }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ why: 'applied last' }); + }); + + test('clear() removes Context, and a later set() writes it again', () => { + const stack = new Stack(); + const bucket = new CfnResource(stack, 'Bucket', { type: 'AWS::S3::Bucket' }); + const legacyContext = CfnResourceMetadataContext.of(bucket); + + legacyContext.set({ must: ['buckets use the security team customer managed AWS KMS key'] }); + legacyContext.clear(); + legacyContext.set({ why: 'legacy public assets; approved encryption exception' }); + + expect(toCloudFormation(stack).Resources.Bucket.Metadata[CONTEXT_METADATA_KEY]).toEqual({ + why: 'legacy public assets; approved encryption exception', + }); + }); + + test('clear() on a child scope removes Context set on a parent scope', () => { + const stack = new Stack(); + const kept = new CfnResource(stack, 'Orders', { type: 'AWS::SQS::Queue' }); + const legacy = new Construct(stack, 'Legacy'); + const legacyQueue = new CfnResource(legacy, 'Queue', { type: 'AWS::SQS::Queue' }); + const legacyTopic = new CfnResource(legacy, 'Topic', { type: 'AWS::SNS::Topic' }); + + CfnResourceMetadataContext.of(stack, { selector: ConstructSelector.all() }).set({ deps: ['NetworkStack'] }); + CfnResourceMetadataContext.of(legacy, { selector: ConstructSelector.all() }).clear(); + + const template = toCloudFormation(stack); + expect(template.Resources[stack.getLogicalId(kept)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ deps: ['NetworkStack'] }); + expect(template.Resources[stack.getLogicalId(legacyQueue)].Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + expect(template.Resources[stack.getLogicalId(legacyTopic)].Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + }); + + test('clear() removes only the Context key and keeps other metadata', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + res.addMetadata('com.example.ToolMetadata', { toolSpecificField: 'tool-specific-value' }); + res.addMetadata(CONTEXT_METADATA_KEY, { why: 'written directly' }); + + CfnResourceMetadataContext.of(res).clear(); + + expect(toCloudFormation(stack).Resources.Res.Metadata).toEqual({ + 'com.example.ToolMetadata': { toolSpecificField: 'tool-specific-value' }, + }); + }); + + test('clear() on a resource without Context synthesizes without metadata', () => { + const stack = new Stack(); + new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(stack, { selector: ConstructSelector.all() }).clear(); + + expect(toCloudFormation(stack).Resources.Res.Metadata).toBeUndefined(); + }); + + test('a later Aspect that writes the Context key directly replaces a set() value', () => { + class WriteContext implements IAspect { + public visit(node: IConstruct): void { + if (CfnResource.isCfnResource(node)) { + node.addMetadata(CONTEXT_METADATA_KEY, { why: 'written by a later aspect' }); + } + } + } + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ why: 'declared through the metadata-context API' }); + Aspects.of(stack).add(new WriteContext(), { priority: AspectPriority.DEFAULT }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ why: 'written by a later aspect' }); + }); + }); + + describe('resource-level directly written context', () => { + test('preserves manually added Context when the API is not used', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + const manualContext = { why: 'manual user value' }; + + res.addMetadata(CONTEXT_METADATA_KEY, manualContext); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual(manualContext); + }); + + test('set() replaces the complete directly written Context', () => { + const stack = new Stack(); + const queue = new Construct(stack, 'Queue'); + const cfnQueue = new CfnResource(queue, 'Resource', { type: 'AWS::SQS::Queue' }); + cfnQueue.addMetadata(CONTEXT_METADATA_KEY, { why: 'written directly', must: ['written directly'] }); + + CfnResourceMetadataContext.of(queue).set({ why: 'declared through the metadata-context API' }); + + expect(toCloudFormation(stack).Resources[stack.getLogicalId(cfnQueue)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ + why: 'declared through the metadata-context API', + }); + }); + + test('set() replaces a directly written value that is not an object', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + res.addMetadata(CONTEXT_METADATA_KEY, 'not a context block'); + + CfnResourceMetadataContext.of(res).set({ why: 'declared through the metadata-context API' }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ + why: 'declared through the metadata-context API', + }); + }); + + test('preserves independently defined tool metadata on a resource', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + res.addMetadata('com.example.ToolMetadata', { toolSpecificField: 'tool-specific-value' }); + + CfnResourceMetadataContext.of(res).set({ why: 'routes events to external storage' }); + + expect(toCloudFormation(stack).Resources.Res.Metadata).toEqual({ + 'com.example.ToolMetadata': { toolSpecificField: 'tool-specific-value' }, + [CONTEXT_METADATA_KEY]: { why: 'routes events to external storage' }, + }); + }); + + test('resources that no call selects receive no Context', () => { + const stack = new Stack(); + const withContext = new CfnResource(stack, 'A', { type: 'AWS::Fake::Thing' }); + new CfnResource(stack, 'B', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(withContext).set({ why: 'has context' }); + + const template = toCloudFormation(stack); + expect(template.Resources.A.Metadata[CONTEXT_METADATA_KEY]).toEqual({ why: 'has context' }); + expect(template.Resources.B.Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + }); + }); + + describe('resource-level assembly boundaries', () => { + test('set() inside a NestedStack writes to the nested template', () => { + const app = new App(); + const parent = new Stack(app, 'ParentStack'); + const nested = new NestedStack(parent, 'Child'); + new CfnResource(nested, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(nested, { selector: ConstructSelector.all() }).set({ why: 'nested resource rationale' }); + + expect(readNestedTemplate(app, nested).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ why: 'nested resource rationale' }); + }); + + test('ConstructSelector.all() on the parent stack reaches nested stack resources', () => { + const app = new App(); + const parent = new Stack(app, 'ParentStack'); + const nested = new NestedStack(parent, 'Child'); + new CfnResource(nested, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(parent, { selector: ConstructSelector.all() }).set({ must: ['all data encrypted w/ CMK'] }); + + expect(readNestedTemplate(app, nested).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ + must: ['all data encrypted w/ CMK'], + }); + }); + + test('set() inside a Stage targets resources in that assembly', () => { + const app = new App(); + const stage = new Stage(app, 'Deployment'); + const stack = new Stack(stage, 'Stack'); + new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(stage, { selector: ConstructSelector.all() }).set({ why: 'stage resource' }); + + const template = stage.synth().getStackByName(stack.stackName).template; + expect(template.Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ why: 'stage resource' }); + }); + + test('fails when the only selected resources are beneath a Stage', () => { + const app = new App(); + const stage = new Stage(app, 'Deployment'); + new CfnResource(new Stack(stage, 'Stack'), 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(app, { selector: ConstructSelector.all() }).set({ why: 'outside assembly' }); + + expect(() => app.synth()).toThrow(NO_RESOURCE_SELECTED); + }); + + test('set() above a Stage does not reach resources inside the Stage', () => { + const app = new App(); + new CfnResource(new Stack(app, 'RootStack'), 'RootRes', { type: 'AWS::Fake::Thing' }); + CfnResourceMetadataContext.of(app, { selector: ConstructSelector.all() }).set({ why: 'root assembly resource' }); + + const stage = new Stage(app, 'Deployment'); + const stack = new Stack(stage, 'StageStack'); + new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + const template = stage.synth().getStackByName(stack.stackName).template; + expect(template.Resources.Res.Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + }); + }); + + describe('resource-level validation', () => { + test('set() never throws; invalid values and empty selections fail synthesis', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Queue', { type: 'AWS::SQS::Queue' }); + + expect(() => CfnResourceMetadataContext.of(res).set({ + why: 'x', + trust: { conf: CfnContextTrustConfidence.HIGH } as any, + })).not.toThrow(); + expect(() => CfnResourceMetadataContext.of(stack).set({ why: 'no CfnResource selected' })).not.toThrow(); + + expect(() => synthesize(stack)).toThrow(/trust requires 'src'/); + expect(() => synthesize(stack)).toThrow(NO_RESOURCE_SELECTED); + }); + + test('set() writes the value passed at call time even if the caller mutates it afterwards', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + const must = ['original rule']; + const mutability: { [propertyName: string]: CfnContextMutability } = { Name: CfnContextMutability.MUST_NEVER_CHANGE }; + + CfnResourceMetadataContext.of(res).set({ mutable: CfnContextMutability.FREE_TO_TUNE, must, mutability }); + must.push('added after set()'); + mutability.Arn = CfnContextMutability.FREE_TO_TUNE; + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ + mutable: 'free-to-tune', + must: ['original rule'], + mutability: { Name: 'must-never-change' }, + }); + }); + + test('set({}) writes an empty Context object', () => { + const stack = new Stack(); + new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(stack, { selector: ConstructSelector.all() }).set({}); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({}); + }); + + test('preserves explicit empty strings, arrays and maps', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ why: '', must: [], mutability: {}, deps: [] }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ + why: '', + must: [], + mutability: {}, + deps: [], + }); + }); + + test('blank strings are structurally valid and synthesize', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ + why: ' ', + must: [' '], + trust: { src: CfnContextTrustSource.AUTHORED, conf: CfnContextTrustConfidence.HIGH, cite: ' ', note: ' ' }, + }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ + why: ' ', + must: [' '], + trust: { src: 'authored', conf: 'high', cite: ' ', note: ' ' }, + }); + }); + + test('trust-only block synthesizes', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ + trust: { src: CfnContextTrustSource.AUTHORED, conf: CfnContextTrustConfidence.HIGH }, + }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ + trust: { src: 'authored', conf: 'high' }, + }); + }); + + test('deps-only block synthesizes', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ deps: ['NetworkStack'] }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ deps: ['NetworkStack'] }); + }); + + test('fails synthesis when trust is provided without src', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ why: 'x', trust: { conf: CfnContextTrustConfidence.HIGH } as any }); + + expect(() => synthesize(stack)).toThrow(/trust requires 'src'/); + }); + + test('fails synthesis when trust is provided without conf', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ why: 'x', trust: { src: CfnContextTrustSource.AUTHORED } as any }); + + expect(() => synthesize(stack)).toThrow(/trust requires 'conf'/); + }); + + test.each([ + CfnContextMutability.MUST_NEVER_CHANGE, + CfnContextMutability.CHANGE_WITH_CONSTRAINTS, + ])('constrained mutable %s without a must rule synthesizes (recommendation not enforced)', mutability => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ why: 'processes order events', mutable: mutability }); + + expect(() => synthesize(stack)).not.toThrow(); + }); + + test.each([ + CfnContextMutability.MUST_NEVER_CHANGE, + CfnContextMutability.CHANGE_WITH_CONSTRAINTS, + ])('constrained mutability %s without a must rule synthesizes (recommendation not enforced)', mutability => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ why: 'processes order events', mutability: { Name: mutability } }); + + expect(() => synthesize(stack)).not.toThrow(); + }); + + test('omits a mutability entry that repeats mutable', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ + mutable: CfnContextMutability.FREE_TO_TUNE, + mutability: { Name: CfnContextMutability.FREE_TO_TUNE }, + }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ + mutable: 'free-to-tune', + }); + }); + + test('keeps mutability entries that deviate from mutable', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ + mutable: CfnContextMutability.FREE_TO_TUNE, + mutability: { + Name: CfnContextMutability.FREE_TO_TUNE, + VisibilityTimeout: CfnContextMutability.CHANGE_WITH_CONSTRAINTS, + }, + }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ + mutable: 'free-to-tune', + mutability: { VisibilityTimeout: 'change-with-constraints' }, + }); + }); + + test('allows mutability without a mutable', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ mutability: { Name: CfnContextMutability.FREE_TO_TUNE } }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ + mutability: { Name: 'free-to-tune' }, + }); + }); + }); + + describe('template-level context', () => { + test('renders a top-level namespaced Context metadata block', () => { + const stack = new Stack(); + + CfnTemplateMetadataContext.of(stack).set({ + arch: 'SQS buffer -> Lambda -> DynamoDB; DLQ for poison msgs', + must: ['all data encrypted w/ security-team CMK'], + owner: 'order-processing-team', + }); + + expect(toCloudFormation(stack).Metadata[CONTEXT_METADATA_KEY]).toEqual({ + arch: 'SQS buffer -> Lambda -> DynamoDB; DLQ for poison msgs', + must: ['all data encrypted w/ security-team CMK'], + owner: 'order-processing-team', + }); + }); + + test('writes template metadata immediately', () => { + const stack = new Stack(); + + CfnTemplateMetadataContext.of(stack).set({ owner: 'order-processing-team' }); + + expect(stack.templateOptions.metadata?.[CONTEXT_METADATA_KEY]).toEqual({ owner: 'order-processing-team' }); + }); + + test('ref entries render bare-string form when only a path is given', () => { + const stack = new Stack(); + + CfnTemplateMetadataContext.of(stack).set({ + ref: [ + { at: 'docs/network-context.yaml' }, + { at: 'docs/encryption-context.yaml', has: 'organization encryption and tagging rules', scope: 'shared' }, + ], + }); + + expect(toCloudFormation(stack).Metadata[CONTEXT_METADATA_KEY].ref).toEqual([ + 'docs/network-context.yaml', + { at: 'docs/encryption-context.yaml', has: 'organization encryption and tagging rules', scope: 'shared' }, + ]); + }); + + test('a second set() replaces the complete template Context', () => { + const stack = new Stack(); + + CfnTemplateMetadataContext.of(stack).set({ arch: 'first arch', must: ['rule 1'] }); + CfnTemplateMetadataContext.of(stack).set({ owner: 'platform-team' }); + + expect(toCloudFormation(stack).Metadata[CONTEXT_METADATA_KEY]).toEqual({ owner: 'platform-team' }); + }); + + test('clear() removes template Context, and a later set() writes it again', () => { + const stack = new Stack(); + const templateContext = CfnTemplateMetadataContext.of(stack); + + templateContext.set({ arch: 'removed arch' }); + templateContext.clear(); + expect(stack.templateOptions.metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + + templateContext.set({ arch: 'current arch' }); + expect(toCloudFormation(stack).Metadata[CONTEXT_METADATA_KEY]).toEqual({ arch: 'current arch' }); + }); + + test('clear() keeps other template metadata keys', () => { + const stack = new Stack(); + stack.addMetadata('SomeOtherKey', 'value'); + CfnTemplateMetadataContext.of(stack).set({ arch: 'the arch' }); + + CfnTemplateMetadataContext.of(stack).clear(); + + expect(toCloudFormation(stack).Metadata).toEqual({ SomeOtherKey: 'value' }); + }); + + test('clear() on a stack without template metadata is a no-op', () => { + const stack = new Stack(); + + CfnTemplateMetadataContext.of(stack).clear(); + + expect(toCloudFormation(stack).Metadata).toBeUndefined(); + }); + + test('set() followed by clear() renders no template Metadata section', () => { + const stack = new Stack(); + + CfnTemplateMetadataContext.of(stack).set({ arch: 'removed arch' }); + CfnTemplateMetadataContext.of(stack).clear(); + + expect(toCloudFormation(stack).Metadata).toBeUndefined(); + }); + + test('set() replaces a Context block added with Stack.addMetadata()', () => { + const stack = new Stack(); + stack.addMetadata(CONTEXT_METADATA_KEY, { arch: 'manual user value', must: ['manual user rule'] }); + + CfnTemplateMetadataContext.of(stack).set({ arch: 'managed architecture' }); + + expect(toCloudFormation(stack).Metadata[CONTEXT_METADATA_KEY]).toEqual({ arch: 'managed architecture' }); + }); + + test('a later Stack.addMetadata() on the Context key replaces a set() value', () => { + const stack = new Stack(); + CfnTemplateMetadataContext.of(stack).set({ arch: 'managed architecture' }); + + stack.addMetadata(CONTEXT_METADATA_KEY, { arch: 'manual user value' }); + + expect(toCloudFormation(stack).Metadata[CONTEXT_METADATA_KEY]).toEqual({ arch: 'manual user value' }); + }); + + test('preserves manually added template Context when the API is not used', () => { + const stack = new Stack(); + const manualContext = { arch: 'manual user value' }; + + stack.addMetadata(CONTEXT_METADATA_KEY, manualContext); + + expect(toCloudFormation(stack).Metadata[CONTEXT_METADATA_KEY]).toEqual(manualContext); + }); + + test('preserves other template metadata keys', () => { + const stack = new Stack(); + stack.addMetadata('SomeOtherKey', 'value'); + + CfnTemplateMetadataContext.of(stack).set({ arch: 'the arch' }); + + expect(toCloudFormation(stack).Metadata).toEqual({ + SomeOtherKey: 'value', + [CONTEXT_METADATA_KEY]: { arch: 'the arch' }, + }); + }); + + test('of(Stack.of(scope)) targets the enclosing stack from a nested scope', () => { + const app = new App(); + const stack = new Stack(app, 'MyStack'); + const scope = new Construct(stack, 'Nested'); + + CfnTemplateMetadataContext.of(Stack.of(scope)).set({ arch: 'nested-declared arch' }); + + expect(toCloudFormation(stack).Metadata[CONTEXT_METADATA_KEY]).toEqual({ arch: 'nested-declared arch' }); + }); + + test('inside a NestedStack targets the nested stack template, not the parent', () => { + const app = new App(); + const parent = new Stack(app, 'ParentStack'); + const nested = new NestedStack(parent, 'Child'); + new CfnResource(nested, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnTemplateMetadataContext.of(nested).set({ arch: 'child-stack arch' }); + + const assembly = app.synth(); + const parentTemplate = assembly.getStackByName(parent.stackName).template; + const nestedTemplate = JSON.parse(fs.readFileSync(path.join(assembly.directory, nested.templateFile), 'utf-8')); + expect(nestedTemplate.Metadata[CONTEXT_METADATA_KEY]).toEqual({ arch: 'child-stack arch' }); + expect(parentTemplate.Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + }); + + test('set({}) writes an empty template Context object', () => { + const stack = new Stack(); + + CfnTemplateMetadataContext.of(stack).set({}); + + expect(toCloudFormation(stack).Metadata[CONTEXT_METADATA_KEY]).toEqual({}); + }); + + test('preserves explicit empty template strings and arrays', () => { + const stack = new Stack(); + + CfnTemplateMetadataContext.of(stack).set({ arch: '', must: [], ref: [], owner: '' }); + + expect(toCloudFormation(stack).Metadata[CONTEXT_METADATA_KEY]).toEqual({ arch: '', must: [], ref: [], owner: '' }); + }); + + test.each<{ + readonly name: string; + readonly path: string; + readonly context: (token: string) => CfnTemplateMetadataContextProps; + }>([ + { name: 'arch', path: 'arch', context: token => ({ arch: token }) }, + { name: 'must entry', path: 'must[0]', context: token => ({ must: [token] }) }, + { name: 'ref at', path: 'ref[0].at', context: token => ({ ref: [{ at: token }] }) }, + { name: 'ref has', path: 'ref[0].has', context: token => ({ ref: [{ at: 'docs/context.md', has: token }] }) }, + { name: 'ref scope', path: 'ref[0].scope', context: token => ({ ref: [{ at: 'docs/context.md', scope: token }] }) }, + { name: 'owner', path: 'owner', context: token => ({ owner: token }) }, + ])('throws before writing when template $name is an unresolved token', ({ path: fieldPath, context }) => { + const stack = new Stack(); + const tokenTarget = new CfnResource(stack, 'TokenTarget', { type: 'AWS::Fake::Thing' }); + const setContext = () => CfnTemplateMetadataContext.of(stack).set(context(tokenTarget.ref)); + + expect(setContext).toThrow(ValidationError); + expect(setContext).toThrow(`template context field '${fieldPath}' cannot contain unresolved tokens`); + expect(stack.templateOptions.metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + }); + + test('throws before inspecting an unresolved template list token', () => { + const stack = new Stack(); + const setContext = () => CfnTemplateMetadataContext.of(stack).set({ + must: Lazy.list({ produce: () => ['resolved rule'] }), + }); + + expect(setContext).toThrow(ValidationError); + expect(setContext).toThrow("template context field 'must' cannot contain unresolved tokens"); + expect(stack.templateOptions.metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + }); + + test('a blank ref at path is structurally valid and synthesizes', () => { + const stack = new Stack(); + + CfnTemplateMetadataContext.of(stack).set({ ref: [{ at: ' ' }] }); + + expect(toCloudFormation(stack).Metadata[CONTEXT_METADATA_KEY].ref).toEqual([' ']); + }); + + test('throws when a ref is missing its at path', () => { + const stack = new Stack(); + + expect(() => CfnTemplateMetadataContext.of(stack).set({ ref: [{ has: 'no at here' } as any] })).toThrow(ValidationError); + expect(() => CfnTemplateMetadataContext.of(stack).set({ ref: [{ has: 'no at here' } as any] })).toThrow(/ref entries require an 'at' path/); + expect(stack.templateOptions.metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + }); + + test.each([ + 'https://example.com/context.md', + 's3://example-bucket/context.yaml', + '/absolute/context.yaml', + 'C:\\absolute\\context.yaml', + '~/context.yaml', + '../outside/context.yaml', + 'docs/../../outside/context.yaml', + ])('accepts any ref URI or path (advisory schema does not enforce scope) %s', at => { + const stack = new Stack(); + + CfnTemplateMetadataContext.of(stack).set({ ref: [{ at }] }); + + expect(toCloudFormation(stack).Metadata[CONTEXT_METADATA_KEY].ref).toEqual([at]); + }); + }); + + describe('schema conformance', () => { + test('emitted resource block uses only advisory schema fields', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + CfnResourceMetadataContext.of(res).set({ + why: 'w', + must: ['m'], + mutable: CfnContextMutability.FREE_TO_TUNE, + mutability: { Prop: CfnContextMutability.REVIEW_REQUIRED }, + trust: { src: CfnContextTrustSource.AUTHORED, conf: CfnContextTrustConfidence.HIGH }, + deps: ['d'], + }); + + const context = toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]; + expect(Object.keys(context).sort()).toEqual(['deps', 'must', 'mutability', 'mutable', 'trust', 'why']); + expect(context.mutable).toEqual('free-to-tune'); + expect(context.mutability.Prop).toEqual('review-required'); + expect(context.trust).toEqual({ src: 'authored', conf: 'high' }); + }); + + test('emitted template block uses only advisory schema fields', () => { + const stack = new Stack(); + + CfnTemplateMetadataContext.of(stack).set({ + arch: 'a', + must: ['m'], + ref: [{ at: 'docs/context.yaml' }], + owner: 'o', + }); + + expect(Object.keys(toCloudFormation(stack).Metadata[CONTEXT_METADATA_KEY]).sort()).toEqual(['arch', 'must', 'owner', 'ref']); + }); + + test('enum values match the advisory schema vocabulary', () => { + expect(Object.values(CfnContextMutability).sort()).toEqual([ + 'change-with-constraints', + 'free-to-tune', + 'must-never-change', + 'review-required', + ]); + expect(Object.values(CfnContextTrustSource).sort()).toEqual([ + 'authored', + 'comment', + 'commit', + 'infer', + ]); + expect(Object.values(CfnContextTrustConfidence).sort()).toEqual([ + 'high', + 'low', + 'medium', + ]); + }); + }); +}); + +function readNestedTemplate(app: App, nested: NestedStack): any { + const assembly = app.synth(); + return JSON.parse(fs.readFileSync(path.join(assembly.directory, nested.templateFile), 'utf-8')); +} diff --git a/packages/aws-cdk-lib/rosetta/default.ts-fixture b/packages/aws-cdk-lib/rosetta/default.ts-fixture index e2ab1578d9906..a77227c6821c3 100644 --- a/packages/aws-cdk-lib/rosetta/default.ts-fixture +++ b/packages/aws-cdk-lib/rosetta/default.ts-fixture @@ -51,6 +51,11 @@ import { Mixin, Mixins, MissingRemovalPolicies, + CfnResourceMetadataContext, + CfnTemplateMetadataContext, + CfnContextMutability, + CfnContextTrustSource, + CfnContextTrustConfidence, Resource, SecretValue, Size,