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..1744668269861 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.js.snapshot/MetadataContextTestStack.metadata.json @@ -0,0 +1,77 @@ +{ + "/MetadataContextTestStack/OrderQueue": [ + { + "type": "aws:cdk:analytics:construct", + "data": { + "encryption": "SQS_MANAGED" + } + }, + { + "type": "aws:cdk:metadata-context", + "data": { + "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" + } + }, + "options": { + "propagate": false, + "inheritAncestorContext": true + } + } + } + ], + "/MetadataContextTestStack/Notifications": [ + { + "type": "aws:cdk:metadata-context", + "data": { + "context": { + "why": "fan-out of alert events to oncall channels" + }, + "options": { + "propagate": true, + "inheritAncestorContext": true + } + } + } + ], + "/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..676b36a466f69 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/core/test/integ.metadata-context.ts @@ -0,0 +1,46 @@ +import { App, ContextMutability, ContextTrustConfidence, ContextTrustSource, ResourceMetadataContext, Stack, TemplateMetadataContext } 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', +}); + +// Template-level cross-cutting context +TemplateMetadataContext.of(stack).add({ + 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', +}); + +// Resource-level context on an L2: renders onto the primary AWS::SQS::Queue only +const queue = new sqs.Queue(stack, 'OrderQueue', { + // Explicit so the template states the encryption the template-level `must` promises. + encryption: sqs.QueueEncryption.SQS_MANAGED, +}); +ResourceMetadataContext.of(queue).add({ + why: 'buffer order events async; std queue (throughput > ordering)', + must: ['VisTimeout >= 6x consumer timeout, else dup on retry'], + mutable: ContextMutability.CHANGE_WITH_CONSTRAINTS, + mutability: { QueueName: ContextMutability.MUST_NEVER_CHANGE }, + trust: { src: ContextTrustSource.AUTHORED, conf: ContextTrustConfidence.HIGH }, +}); + +// Scope-level context propagated to every resource beneath the scope +const subsystem = new Construct(stack, 'Notifications'); +new sns.Topic(subsystem, 'AlertsTopic'); +ResourceMetadataContext.of(subsystem).add({ + why: 'fan-out of alert events to oncall channels', +}, { + propagate: true, +}); + +new integ.IntegTest(app, 'MetadataContextInteg', { + testCases: [stack], +}); diff --git a/packages/aws-cdk-lib/README.md b/packages/aws-cdk-lib/README.md index b04d5116ec2a7..80e3f467f0fe0 100644 --- a/packages/aws-cdk-lib/README.md +++ b/packages/aws-cdk-lib/README.md @@ -1619,6 +1619,417 @@ 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 and targeting, but +no requirements the schema does not impose. + +Context comes in two flavors, each with its own entry point: + +- `ResourceMetadataContext` — resource-level context, rendered onto individual + CloudFormation resources. +- `TemplateMetadataContext` — template-level (stack-wide) context, rendered as a + top-level `Metadata` block. + +### Resource-level context + +Add resource-level context on any construct scope: + +```typescript +declare const queue: sqs.Queue; + +ResourceMetadataContext.of(queue).add({ + why: 'buffer order events async; 14d retention = compliance window', + must: ['VisTimeout >= 6x fn timeout, else dup on retry'], + mutable: ContextMutability.CHANGE_WITH_CONSTRAINTS, + mutability: { + QueueName: ContextMutability.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, an entry that repeats the `mutable` value is rejected — the map records +deviations only. + +### Targeting: exactly what receives context + +By default, `add()` targets the scope's *primary resource*: + +- the scope itself, when the scope is a `CfnResource`; or +- the `CfnResource` at the end of the scope's + [`defaultChild`](https://docs.aws.amazon.com/cdk/api/v2/docs/constructs.Node.html#defaultchild) + chain — 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 chain is followed through intermediate constructs, not just one level. If a +construct's `defaultChild` is itself a construct, CDK follows *that* construct's +`defaultChild` next, until it reaches a `CfnResource`. For example, +`cloudfront.experimental.EdgeFunction` designates its internal `lambda.Function` +as its `defaultChild`, and `lambda.Function` designates its +`AWS::Lambda::Function`, so context added on the `EdgeFunction` lands on the +`AWS::Lambda::Function` and still skips the function's generated role. + +Incidental helper resources (auto-created IAM roles/policies, log-retention +functions, custom-resource plumbing) are not on the `defaultChild` chain, so they +never receive context by default. Applying context to a scope with no +`defaultChild` — most L3 patterns, such as +`ecs_patterns.ApplicationLoadBalancedFargateService`, a plain grouping +`Construct`, or a `Stack` — fails unless `propagate` is set (see below). + +To reach more than the primary resource, set `propagate: true`. Propagation +replaces `defaultChild` selection entirely: the declaration applies to every +`CfnResource` beneath the scope, helpers included, and only a `PropagationFilter` +narrows it, by resource type: + +```typescript +declare const stack: Stack; + +// 1. Default: only the scope's primary resource. +declare const queue: sqs.Queue; +ResourceMetadataContext.of(queue).add({ + why: 'buffers webhook events for async processing', +}); + +// 2. Propagate to every resource beneath the scope, helpers included. +ResourceMetadataContext.of(stack).add({ + deps: ['NetworkStack'], +}, { + propagate: true, +}); + +// 3. Propagate only to resources of a specific type. +ResourceMetadataContext.of(stack).add({ + must: ['delivery settings must preserve in-flight messages'], +}, { + propagate: true, + propagationFilter: PropagationFilter.includeResourceTypes(['AWS::SQS::Queue']), +}); + +// 4. Propagate to everything except resources of a specific type. +ResourceMetadataContext.of(stack).add({ + must: ['execution roles keep the org permissions boundary'], +}, { + propagate: true, + propagationFilter: PropagationFilter.excludeResourceTypes(['AWS::Lambda::Function']), +}); +``` + +A `propagationFilter` requires `propagate: true`; `add()` throws otherwise, +because default targeting already selects exactly one resource. + +Propagation crosses `NestedStack` boundaries like `Tags` does, so context set on a +scope containing a `NestedStack` reaches resources in the nested template. It does +not cross `Stage` boundaries: a Stage is a separate cloud assembly, so a +declaration on an `App` whose only children are Stages matches nothing and fails. +Declare context inside each Stage; this also lets environments carry different +guidance: + +```typescript +// Each Stage is a separate cloud assembly and declares its own context. +const dev = new Stage(app, 'Dev'); +new sqs.Queue(new Stack(dev, 'Orders'), 'WebhookQueue'); +ResourceMetadataContext.of(dev).add({ + why: 'development environment; data is disposable', + mutable: ContextMutability.FREE_TO_TUNE, +}, { propagate: true }); + +const prod = new Stage(app, 'Prod'); +new sqs.Queue(new Stack(prod, 'Orders'), 'WebhookQueue'); +ResourceMetadataContext.of(prod).add({ + must: ['deletion protection and backups stay enabled'], + mutable: ContextMutability.REVIEW_REQUIRED, +}, { propagate: true }); +``` + +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`. + +Propagation is 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 +`TemplateMetadataContext` (see below). + +#### Helper resources + +Adding context on a `lambda.Function` targets the `AWS::Lambda::Function`, not +its execution role or dead-letter queue. Helpers that the L2 exposes as constructs +can be targeted through it: + +```typescript +declare const lambdaFunction: lambda.Function; + +// The primary resource: the AWS::Lambda::Function, not its generated role. +ResourceMetadataContext.of(lambdaFunction).add({ + 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) { + ResourceMetadataContext.of(lambdaFunction.deadLetterQueue).add({ + why: 'stores failed order-processor invocations for replay', + }); +} +``` + +To target only an L2's helpers, propagate from the L2 and exclude the primary +resource's type — everything left beneath the L2 is a helper: + +```typescript +declare const lambdaFunction: lambda.Function; + +// Everything the function creates except the function itself: role, policies, log group. +ResourceMetadataContext.of(lambdaFunction).add({ + deps: ['OrderProcessorFunction'], +}, { + propagate: true, + propagationFilter: PropagationFilter.excludeResourceTypes(['AWS::Lambda::Function']), +}); +``` + +For an L3 pattern (or any multi-resource construct) with no `defaultChild`, +target a child construct or propagate with a type filter. 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. +ResourceMetadataContext.of(service).add({ + must: ['ALB idle timeout >= backend read timeout'], +}, { + propagate: true, + propagationFilter: PropagationFilter.includeResourceTypes(['AWS::ElasticLoadBalancingV2::LoadBalancer']), +}); +``` + +### Merging and ancestor inheritance + +When more than one applicable entry targets the same resource, entries merge with +nearest-wins semantics: scalar fields (`why`, `mutable`, `trust`) from entries +closer to the resource win, while list fields (`must`, `deps`) accumulate and +de-duplicate. `mutability` maps merge per property. For example: + +```typescript +declare const stack: Stack; +declare const queue: sqs.Queue; + +// Declared on the Stack for every SQS queue. +ResourceMetadataContext.of(stack).add({ + why: 'part of the order-processing subsystem', + must: ['queues use the security team customer managed KMS key'], +}, { + propagate: true, + propagationFilter: PropagationFilter.includeResourceTypes(['AWS::SQS::Queue']), +}); + +// Declared on one queue. +ResourceMetadataContext.of(queue).add({ + why: 'buffers webhook events for async processing', + must: ['VisibilityTimeout >= 6x consumer timeout'], +}); +``` + +On that queue's `AWS::SQS::Queue` the closer `why` wins and the `must` entries +combine, Stack entry first; other queues in the Stack render only the Stack +declaration: + +```json +{ + "why": "buffers webhook events for async processing", + "must": [ + "queues use the security team customer managed KMS key", + "VisibilityTimeout >= 6x consumer timeout" + ] +} +``` + +An entry inherits context merged from enclosing scopes by default. Set +`inheritAncestorContext: false` to make an entry a fresh starting point — any +context merged from ancestor scopes is discarded before that entry (and any +entries closer to the resource) is applied: + +```typescript +declare const queue: sqs.Queue; + +ResourceMetadataContext.of(queue).add({ + why: 'self-contained rationale; ignore inherited stack-level context', +}, { + inheritAncestorContext: false, +}); +``` + +### 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. +Producers that infer context should say so: + +```typescript +declare const queue: sqs.Queue; + +ResourceMetadataContext.of(queue).add({ + why: 'absorb transient processor failures without dropping orders', + trust: { + src: ContextTrustSource.INFER, + conf: ContextTrustConfidence.LOW, + cite: 'api/handler.ts:87', + note: 'rationale inferred from retry wrapper; no explicit design doc found', + }, +}); +``` + +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 `cite: 'lib/queue.ts:42'`; 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 + +`TemplateMetadataContext` 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`. Every +template-context field is optional; supply any combination, and an empty +declaration is a harmless no-op: + +```typescript +declare const stack: Stack; + +TemplateMetadataContext.of(stack).add({ + 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 field is optional and CDK adds no requirements beyond the schema; an empty +declaration is a harmless no-op. 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. An entry may omit `why` + or `must` when another applicable entry supplies them. +- A fact that applies to every resource in the template belongs in + `TemplateMetadataContext`, 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 enforces only the schema's nested requirements: when `trust` is supplied, +both `src` and `conf` are required (`cite` and `note` remain optional), and in the +sparse `mutability` map an entry must not repeat `mutable` when both are supplied. + +### 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. + +### Precedence and collisions + +A manually added `com.aws.cloudformation.Context` value (via +`CfnResource.addMetadata()` or `Stack.addMetadata()`) is preserved as long as no +API-produced Context targets the same location. If both a manual block and an +API- or template-produced block target the same location, synthesis fails with +a scoped `ValidationError` rather than silently overwriting or merging +incompatible blocks — remove one to resolve it. 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/cfn-resource.ts b/packages/aws-cdk-lib/core/lib/cfn-resource.ts index 50f0069ad30da..64a1a4a5b7d5f 100644 --- a/packages/aws-cdk-lib/core/lib/cfn-resource.ts +++ b/packages/aws-cdk-lib/core/lib/cfn-resource.ts @@ -23,6 +23,7 @@ import { ValidationError } from './errors'; import { deepMerge } from './private/deep-merge'; import type { ResourceEnvironment } from './environment'; import { lit } from './private/literal-string'; +import { renderResourceMetadata } from './private/metadata-context-metadata'; import { captureStackTrace } from './private/stack-trace'; import { Stack } from './stack'; import { isCfnResource, STACK_TYPE } from './private/core-construct-finders'; @@ -555,7 +556,7 @@ export class CfnResource extends CfnRefElement { DeletionPolicy: capitalizePropertyNames(this, this.cfnOptions.deletionPolicy), Version: this.cfnOptions.version, Description: this.cfnOptions.description, - Metadata: ignoreEmpty(this.cfnOptions.metadata), + Metadata: ignoreEmpty(renderResourceMetadata(this, this.cfnOptions.metadata)), Condition: this.cfnOptions.condition && this.cfnOptions.condition.logicalId, }, (resourceDef, context) => { const renderedProps = this.renderProperties(resourceDef.Properties || {}); 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..e4f86ffc112ef --- /dev/null +++ b/packages/aws-cdk-lib/core/lib/metadata-context.ts @@ -0,0 +1,767 @@ +import type { IConstruct } from 'constructs'; +import type { AspectOptions, IAspect } from './aspect'; +import { Aspects, AspectPriority } from './aspect'; +import { CfnResource } from './cfn-resource'; +import { UnscopedValidationError } from './errors'; +import { STAGE_TYPE } from './private/core-construct-finders'; +import { lit } from './private/literal-string'; +import { + RESOURCE_CONTEXT_METADATA_TYPE, + dedupe, + mergeResourceContext, + renderRef, + renderResourceContext, + validateResourceContext, + validateTemplateContext, +} from './private/metadata-context-internal'; +import { + clearResourceMetadataContext, + getTemplateMetadataContext, + setResourceMetadataContext, + setTemplateMetadataContext, +} from './private/metadata-context-metadata'; +import { 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 ContextMutability { + /** + * 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 ContextTrustSource { + /** + * 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 ContextTrustConfidence { + /** + * 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 ContextTrust { + /** + * How this context was produced. + */ + readonly src: ContextTrustSource; + + /** + * Confidence in the context's accuracy. + */ + readonly conf: ContextTrustConfidence; + + /** + * 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 ContextRef { + /** + * 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: individual declarations may omit any field, and CDK merges + * declarations from the construct hierarchy. 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 ResourceContextProps { + /** + * 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; may be supplied by this declaration or + * inherited from another applicable declaration. + * + * 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 token 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?: ContextMutability; + + /** + * 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, an entry must not repeat the default — + * this sparse-map rule is enforced. 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]: ContextMutability }; + + /** + * 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?: ContextTrust; + + /** + * Cross-stack/cross-resource producer dependencies (stack names, logical + * IDs, or service identifiers). + * + * @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; an empty declaration is a harmless no-op. + * + * Never include secrets, credentials, or personally identifiable information. + * Consumers must treat template context as untrusted data, never as instructions. + */ +export interface TemplateContextProps { + /** + * 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?: ContextRef[]; + + /** + * 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; +} + +/** + * Plain-data form of a `PropagationFilter`, stored in construct-node metadata. + * + * Staged entries are serialized into the cloud assembly, so the filter must be + * reducible to JSON. + */ +interface PropagationFilterSpec { + readonly includeResourceTypes?: string[]; + readonly excludeResourceTypes?: string[]; +} + +/** + * Narrows which resources beneath a scope receive a propagated context block. + * + * Used with `propagate: true`. Without a filter, propagation reaches every + * `CfnResource` beneath the scope. + * + * @example + * declare const stack: Stack; + * ResourceMetadataContext.of(stack).add({ + * must: ['delivery settings must preserve in-flight messages'], + * }, { + * propagate: true, + * propagationFilter: PropagationFilter.includeResourceTypes(['AWS::SQS::Queue']), + * }); + */ +export class PropagationFilter { + /** + * Only resources whose CloudFormation type is in `resourceTypes` receive the + * context (e.g. `['AWS::SQS::Queue']`). + */ + public static includeResourceTypes(resourceTypes: string[]): PropagationFilter { + return new PropagationFilter({ includeResourceTypes: [...resourceTypes] }); + } + + /** + * Every resource except those whose CloudFormation type is in + * `resourceTypes` receives the context (e.g. `['AWS::IAM::Role']`). + */ + public static excludeResourceTypes(resourceTypes: string[]): PropagationFilter { + return new PropagationFilter({ excludeResourceTypes: [...resourceTypes] }); + } + + private constructor(private readonly spec: PropagationFilterSpec) { + } + + /** + * The JSON-serializable form of this filter. + * + * @internal + */ + public _toSpec(): PropagationFilterSpec { + return this.spec; + } +} + +/** + * Options for adding resource-level context via `ResourceMetadataContext.of()`. + */ +export interface ResourceMetadataContextOptions { + /** + * Propagate the context block to every CloudFormation resource beneath the + * scope. + * + * By default (`false`), `add()` targets only the scope's primary resource: the + * scope itself when it is a `CfnResource`, or the `CfnResource` at the end of + * its `defaultChild` chain (e.g. the `AWS::SQS::Queue` inside an `sqs.Queue`). + * The chain passes through intermediate constructs, so a declaration on + * `cloudfront.experimental.EdgeFunction` (whose `defaultChild` is a + * `lambda.Function`) lands on the `AWS::Lambda::Function`. Helpers off the + * chain (auto-created IAM roles and policies, log retention functions, + * custom-resource plumbing) are not targeted. A scope with no `defaultChild` — + * most L3 patterns, a plain grouping `Construct`, or a `Stack` — has no + * primary resource, so a declaration on it with no options fails synthesis. + * + * Set to `true` to target every `CfnResource` beneath the scope, helpers + * included, and narrow with `propagationFilter`. Propagation crosses + * `NestedStack` boundaries but never a `Stage` boundary. + * + * @default false + */ + readonly propagate?: boolean; + + /** + * Narrows which resources receive the context when `propagate` is `true`. + * + * Requires `propagate: true`; `add()` throws otherwise, because default + * targeting already selects exactly one resource. + * + * @default - every CloudFormation resource beneath the scope + */ + readonly propagationFilter?: PropagationFilter; + + /** + * Whether this entry inherits context merged from enclosing (ancestor) + * scopes. + * + * By default context added closer to a resource merges on top of context + * added further up the tree (nearest-wins for scalars, union for lists). + * Set to `false` to make this a fresh starting point for the resources it + * targets: any context merged from ancestor scopes is discarded before this + * entry (and any entries closer to the resource) is applied. + * + * @default true + */ + readonly inheritAncestorContext?: boolean; + + /** + * The priority to use when applying the underlying aspect. + * + * @default AspectPriority.MUTATING + */ + readonly priority?: number; +} + +/** + * Manages resource-level `Metadata["com.aws.cloudformation.Context"]` blocks for 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. + * + * By default context targets only the scope's primary resource (the scope + * itself when it is a `CfnResource`, or the end of its `defaultChild` chain). + * Set `propagate: true` to target every resource beneath the scope, optionally + * narrowed with a `PropagationFilter`. Every declaration must match at least + * one CloudFormation resource; otherwise synthesis fails. + * When multiple applicable entries target the same resource, they merge with + * nearest-wins semantics: scalar fields (`why`, `mutable`, `trust`) from + * entries closer to the resource win, while list-valued fields (`must`, + * `deps`) accumulate and de-duplicate, and the `mutability` map merges per + * property. + * + * Use `TemplateMetadataContext` 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; + * ResourceMetadataContext.of(queue).add({ + * why: 'buffer order events async; 14d retention = compliance window', + * must: ['VisTimeout >= 6x fn timeout, else dup on retry'], + * mutable: ContextMutability.CHANGE_WITH_CONSTRAINTS, + * mutability: { QueueName: ContextMutability.MUST_NEVER_CHANGE }, + * }); + */ +export class ResourceMetadataContext { + /** + * Returns the resource context API for the given scope. + * + * @param scope The scope on which to add context + */ + public static of(scope: IConstruct): ResourceMetadataContext { + return new ResourceMetadataContext(scope); + } + + private constructor(private readonly scope: IConstruct) { + } + + /** + * Add a resource-level context block targeting resources within this scope. + * + * Calling `add()` multiple times on the same scope merges the blocks: + * scalar fields (`why`, `mutable`, `trust`) from later calls override + * earlier ones; list fields and the `mutability` map accumulate. + */ + public add(context: ResourceContextProps, options: ResourceMetadataContextOptions = {}) { + validateResourceContext(context); + + const propagate = options.propagate ?? false; + if (options.propagationFilter !== undefined && !propagate) { + throw new UnscopedValidationError( + lit`MetadataContextPropagationFilterWithoutPropagate`, + 'MetadataContext propagationFilter requires propagate: true; without propagation the declaration targets only the scope\'s primary resource', + ); + } + + const staged: StagedEntry = { + context, + options: { + propagate, + inheritAncestorContext: options.inheritAncestorContext ?? true, + ...options.propagationFilter?._toSpec(), + }, + }; + + // Stage the entry as construct-node metadata so the rendering aspect can + // walk ancestor scopes deterministically (nearest-wins) regardless of + // aspect invocation order. + this.scope.node.addMetadata(RESOURCE_CONTEXT_METADATA_TYPE, staged, { stackTrace: false }); + this.scope.node.addValidation({ + validate: () => matchedStagedEntries.has(staged) + ? [] + : [ + 'resource context declaration matched no CloudFormation resources; ' + + 'target a CfnResource or an L2 with a defaultChild, set propagate: true ' + + '(optionally with a propagationFilter) for an L3, grouping construct or Stack, ' + + 'declare context inside each Stage, or adjust the propagation filter', + ], + }); + + const aspectOptions: AspectOptions = { priority: options.priority ?? AspectPriority.MUTATING }; + const aspects = Aspects.of(this.scope); + if (!aspects.all.some((aspect) => aspect instanceof MetadataContextAspect)) { + aspects.add(new MetadataContextAspect(), aspectOptions); + } + } +} + +/** + * Manages 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 + * `ResourceMetadataContext`. + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-attribute-metadata.html#aws-attribute-metadata-context-schema + * + * @example + * declare const stack: Stack; + * TemplateMetadataContext.of(stack).add({ + * arch: 'SQS buffer -> Lambda -> DynamoDB; DLQ for poison msgs', + * must: ['all data encrypted w/ security-team CMK'], + * owner: 'order-processing-team', + * }); + */ +export class TemplateMetadataContext { + /** + * Returns the template context API for the given stack. + * + * @param stack The stack whose template receives the context + */ + public static of(stack: Stack): TemplateMetadataContext { + return new TemplateMetadataContext(stack); + } + + private constructor(private readonly stack: Stack) { + } + + /** + * Add template-level context to this stack's template. + * + * Calling this method multiple times merges blocks: `arch` and `owner` + * from later calls win, `must` and `ref` entries accumulate. + */ + public add(context: TemplateContextProps) { + validateTemplateContext(context); + + const existing = getTemplateMetadataContext(this.stack) ?? {}; + const merged: Record = { ...existing }; + + if (context.arch !== undefined) { + merged.arch = context.arch; + } + if (context.must !== undefined && context.must.length > 0) { + merged.must = dedupe([...(existing.must ?? []), ...context.must]); + } + if (context.ref !== undefined && context.ref.length > 0) { + const rendered = context.ref.map(renderRef); + merged.ref = [...(existing.ref ?? []), ...rendered]; + } + if (context.owner !== undefined) { + merged.owner = context.owner; + } + + if (Object.keys(merged).length === 0) { + return; + } + + setTemplateMetadataContext(this.stack, merged); + } +} + +/** + * A staged context entry recovered from construct-node metadata. + * + * Kept as plain data because construct-node metadata is serialized into the + * cloud assembly. + */ +interface StagedEntry { + readonly context: ResourceContextProps; + readonly options: { + readonly propagate: boolean; + readonly inheritAncestorContext: boolean; + } & PropagationFilterSpec; +} + +const matchedStagedEntries = new WeakSet(); + +/** + * The aspect that renders staged context entries into `Metadata["com.aws.cloudformation.Context"]` + * blocks on CloudFormation resources. + * + * This is an internal implementation detail of `ResourceMetadataContext`; it + * is registered automatically by `ResourceMetadataContext.of(scope).add()`. + */ +class MetadataContextAspect implements IAspect { + public visit(node: IConstruct): void { + // Aspect traversal is pre-order. Clear declarations staged on this node + // before visiting descendants so repeated synthesis validates only matches + // from the current traversal. + for (const metadataEntry of node.node.metadata) { + if (metadataEntry.type === RESOURCE_CONTEXT_METADATA_TYPE) { + matchedStagedEntries.delete(metadataEntry.data as StagedEntry); + } + } + + if (!CfnResource.isCfnResource(node)) { + return; + } + + clearResourceMetadataContext(node); + + // Walk ancestor scopes inside the current assembly root -> leaf, merging + // staged entries so that entries closer to the resource win. A Stage is a + // cloud-assembly boundary, so declarations above the nearest Stage are + // intentionally excluded even when an in-stage aspect visits the resource. + const scopes = node.node.scopes; + let assemblyRootIndex = 0; + for (let i = 0; i < scopes.length; i++) { + if (STAGE_TYPE.isMarked(scopes[i])) { + assemblyRootIndex = i; + } + } + + let merged: Record | undefined; + for (const scope of scopes.slice(assemblyRootIndex)) { + const applicableEntries: StagedEntry[] = []; + for (const metadataEntry of scope.node.metadata) { + if (metadataEntry.type !== RESOURCE_CONTEXT_METADATA_TYPE) { + continue; + } + const staged = metadataEntry.data as StagedEntry; + if (this.applies(node, scope, staged)) { + matchedStagedEntries.add(staged); + applicableEntries.push(staged); + } + } + + if (applicableEntries.some((entry) => !entry.options.inheritAncestorContext)) { + // Opt out of inherited ancestor context once before processing this + // scope, preserving all declarations made on the scope itself. + merged = undefined; + } + for (const staged of applicableEntries) { + merged = mergeResourceContext(merged, renderResourceContext(staged.context)); + } + } + + if (merged === undefined || Object.keys(merged).length === 0) { + return; + } + + setResourceMetadataContext(node, merged); + } + + private applies(resource: CfnResource, appliedScope: IConstruct, staged: StagedEntry): boolean { + if (!staged.options.propagate) { + // Default: only the scope's own resource or the end of its defaultChild chain. + return isOnDefaultChildChain(resource, appliedScope); + } + // Propagation: every resource beneath the scope (the ancestor walk in + // `visit` already stops at the nearest Stage), narrowed by the filter. + const include = staged.options.includeResourceTypes; + if (include !== undefined && !include.includes(resource.cfnResourceType)) { + return false; + } + const exclude = staged.options.excludeResourceTypes; + if (exclude !== undefined && exclude.includes(resource.cfnResourceType)) { + return false; + } + return true; + } +} + +/** + * Whether `resource` is reachable from `appliedScope` purely by following + * `defaultChild` links (the default, narrow targeting). + * + * This matches the scope itself when it is the resource, or the primary + * resource of an L2 (e.g. the `AWS::SQS::Queue` designated as the + * `defaultChild` of an `sqs.Queue`). Plain grouping constructs, L3 patterns + * and stacks are NOT transparent: if any construct on the path does not + * designate the next node down as its `defaultChild`, the resource is not a + * target. Stage nodes are assembly boundaries and are never crossed. + * + * Reading `defaultChild` throws (in the constructs library) when a construct has + * both a `Resource` and a `Default` child. The error is not caught: it names the + * construct at fault and is the same error any CDK code reading `defaultChild` + * produces. + */ +function isOnDefaultChildChain(resource: CfnResource, appliedScope: IConstruct): boolean { + let current: IConstruct = resource; + while (current !== appliedScope) { + const parent = current.node.scope; + if (parent === undefined) { + // appliedScope is not an ancestor (should not happen for a staged entry). + return false; + } + if (STAGE_TYPE.isMarked(parent) && parent !== appliedScope) { + return false; + } + if (Stack.isStack(parent)) { + return false; + } + if (parent.node.defaultChild !== current) { + return false; + } + current = parent; + } + return true; +} 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..e0ae8939719de --- /dev/null +++ b/packages/aws-cdk-lib/core/lib/private/metadata-context-internal.ts @@ -0,0 +1,148 @@ +import { UnscopedValidationError } from '../errors'; +import type { ResourceContextProps, TemplateContextProps, ContextRef } from '../metadata-context'; +import { lit } from './literal-string'; + +/** + * The construct-node metadata type used to stage resource context entries + * until the rendering aspect writes them onto CloudFormation resources. + */ +export const RESOURCE_CONTEXT_METADATA_TYPE = 'aws:cdk:metadata-context'; + +/** + * Render explicitly authored props into the advisory schema. + * + * The public property names are identical to the field names defined by the + * published CloudFormation Metadata Context schema, so rendering only drops + * absent fields and copies arrays/maps defensively. + */ +export function renderResourceContext(context: ResourceContextProps): Record { + const out: Record = {}; + if (context.why !== undefined) { + out.why = context.why; + } + if (context.must !== undefined && context.must.length > 0) { + out.must = [...context.must]; + } + if (context.mutable !== undefined) { + out.mutable = context.mutable; + } + if (context.mutability !== undefined && Object.keys(context.mutability).length > 0) { + out.mutability = { ...context.mutability }; + } + 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 && context.deps.length > 0) { + out.deps = [...context.deps]; + } + return out; +} + +/** + * Merge two rendered context blocks; fields in `overriding` win over + * `base` for scalars, while list fields accumulate (base first) and the + * `mutability` map merges per key. + */ +export function mergeResourceContext(base: Record | undefined, overriding: Record): Record { + if (base === undefined) { + return { ...overriding }; + } + const out: Record = { ...base }; + for (const scalar of ['why', 'mutable', 'trust']) { + if (overriding[scalar] !== undefined) { + out[scalar] = overriding[scalar]; + } + } + for (const listField of ['must', 'deps']) { + if (overriding[listField] !== undefined) { + out[listField] = dedupe([...(base[listField] ?? []), ...overriding[listField]]); + } + } + if (overriding.mutability !== undefined) { + out.mutability = { ...(base.mutability ?? {}), ...overriding.mutability }; + } + return out; +} + +export function renderRef(ref: ContextRef): 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; +} + +export function dedupe(entries: string[]): string[] { + return [...new Set(entries)]; +} + +export function validateResourceContext(context: ResourceContextProps) { + // Every top-level field is optional in the advisory schema, which sets no + // minLength/minItems, so blank strings and empty arrays are structurally + // valid and a block may carry only trust or only deps. CDK enforces just the + // schema's nested requirements: trust provenance and the sparse + // mutability rule. + validateTrust(context.trust); + validateMutability(context); +} + +function validateTrust(trust: ResourceContextProps['trust']) { + if (trust === undefined) { + return; + } + // The schema requires src and conf whenever a trust object is present; cite + // and note stay optional, and blank strings are structurally valid. + if (trust.src === undefined) { + throw new UnscopedValidationError(lit`MissingMetadataContextTrustSrc`, 'MetadataContext trust requires \'src\' when trust is provided'); + } + if (trust.conf === undefined) { + throw new UnscopedValidationError(lit`MissingMetadataContextTrustConf`, 'MetadataContext trust requires \'conf\' when trust is provided'); + } +} + +function validateMutability(context: ResourceContextProps) { + if (context.mutable === undefined || context.mutability === undefined) { + return; + } + for (const [property, level] of Object.entries(context.mutability)) { + if (level === context.mutable) { + throw new UnscopedValidationError( + lit`RedundantMetadataContextMutability`, + `MetadataContext mutability entry '${property}' must not repeat mutable ${JSON.stringify(context.mutable)}; the map records deviations only`, + ); + } + } +} + +export function validateTemplateContext(context: TemplateContextProps) { + // Every top-level field is optional and blank strings are structurally + // valid, so an empty declaration is a harmless no-op handled by the caller. + // The schema does require an `at` on every rich ref object, so enforce its + // presence and type — but not that it is non-blank (an empty string is a + // valid string). + for (const ref of context.ref ?? []) { + if (typeof ref.at !== 'string') { + throw new UnscopedValidationError(lit`MissingMetadataContextRefAt`, 'MetadataContext ref entries require an \'at\' path'); + } + } +} diff --git a/packages/aws-cdk-lib/core/lib/private/metadata-context-metadata.ts b/packages/aws-cdk-lib/core/lib/private/metadata-context-metadata.ts new file mode 100644 index 0000000000000..41b4c940b65aa --- /dev/null +++ b/packages/aws-cdk-lib/core/lib/private/metadata-context-metadata.ts @@ -0,0 +1,64 @@ +import type { IConstruct } from 'constructs'; +import { ValidationError } from '../errors'; +import { lit } from './literal-string'; + +/** The Amazon-owned key under which CloudFormation Context is stored. */ +export const METADATA_CONTEXT_KEY = 'com.aws.cloudformation.Context'; + +const resourceContext = new WeakMap>(); +const templateContext = new WeakMap>(); + +export function clearResourceMetadataContext(resource: object): void { + resourceContext.delete(resource); +} + +export function setResourceMetadataContext(resource: object, context: Record): void { + resourceContext.set(resource, context); +} + +export function getTemplateMetadataContext(stack: object): Record | undefined { + return templateContext.get(stack); +} + +export function setTemplateMetadataContext(stack: object, context: Record): void { + templateContext.set(stack, context); +} + +export function renderResourceMetadata( + resource: IConstruct, + metadata: Record | undefined, +): Record | undefined { + return renderMetadata(resource, metadata, resourceContext.get(resource), 'resource'); +} + +export function renderTemplateMetadata( + stack: IConstruct, + metadata: Record | undefined, +): Record | undefined { + return renderMetadata(stack, metadata, templateContext.get(stack), 'template'); +} + +function renderMetadata( + scope: IConstruct, + metadata: Record | undefined, + contextFromApi: Record | undefined, + level: 'resource' | 'template', +): Record | undefined { + const rendered = { ...metadata }; + + if (contextFromApi !== undefined) { + if (rendered[METADATA_CONTEXT_KEY] !== undefined) { + // A manually added Context block and API/mixin/template-produced Context + // collide at the same location. Fail loudly instead of silently + // overwriting or merging incompatible blocks. + throw new ValidationError( + lit`MetadataContextCollision`, + `both a manually added '${METADATA_CONTEXT_KEY}' metadata block and one produced by the ${level} MetadataContext API target this location; remove one to resolve the conflict`, + scope, + ); + } + rendered[METADATA_CONTEXT_KEY] = contextFromApi; + } + + return Object.keys(rendered).length > 0 ? rendered : undefined; +} diff --git a/packages/aws-cdk-lib/core/lib/stack.ts b/packages/aws-cdk-lib/core/lib/stack.ts index c32269295aa6c..9f6bfa7bcb1db 100644 --- a/packages/aws-cdk-lib/core/lib/stack.ts +++ b/packages/aws-cdk-lib/core/lib/stack.ts @@ -20,6 +20,7 @@ import type { PermissionsBoundary } from './permissions-boundary'; import { PERMISSIONS_BOUNDARY_CONTEXT_KEY } from './permissions-boundary'; import { CLOUDFORMATION_TOKEN_RESOLVER, CloudFormationLang } from './private/cloudformation-lang'; import { LogicalIDs } from './private/logical-id'; +import { renderTemplateMetadata } from './private/metadata-context-metadata'; import { resolve } from './private/resolve'; import { makeUniqueId } from './private/uniqueid'; import type { IPropertyInjector } from './prop-injectors'; @@ -1426,7 +1427,7 @@ export class Stack extends Construct implements ITaggable { Description: this.templateOptions.description, Transform: transform, AWSTemplateFormatVersion: this.templateOptions.templateFormatVersion, - Metadata: this.templateOptions.metadata, + Metadata: renderTemplateMetadata(this, this.templateOptions.metadata), }; const elements = cfnElements(this); 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..251da8692d0d3 --- /dev/null +++ b/packages/aws-cdk-lib/core/test/metadata-context.test.ts @@ -0,0 +1,1145 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { Construct } from 'constructs'; +import { + App, + CfnResource, + ContextMutability, + ContextTrustConfidence, + ContextTrustSource, + NestedStack, + PropagationFilter, + ResourceMetadataContext, + Stack, + Stage, + TemplateMetadataContext, + UnscopedValidationError, +} from '../lib'; +import { toCloudFormation } from './util'; +import { synthesize } from '../lib/private/synthesis'; + +const CONTEXT_METADATA_KEY = 'com.aws.cloudformation.Context'; + +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' }); + + ResourceMetadataContext.of(res).add({ + why: 'buffer order events async; 14d retention = compliance window', + must: ['VisTimeout >= 6x fn timeout, else dup on retry'], + mutable: ContextMutability.CHANGE_WITH_CONSTRAINTS, + mutability: { QueueName: ContextMutability.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' }); + + ResourceMetadataContext.of(res).add({ + why: 'resource name is referenced by an external consumer', + must: ['Name must not change because replacement loses the external reference'], + mutable: ContextMutability.FREE_TO_TUNE, + mutability: { Name: ContextMutability.MUST_NEVER_CHANGE }, + trust: { src: ContextTrustSource.AUTHORED, conf: ContextTrustConfidence.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' }); + + ResourceMetadataContext.of(res).add({ 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' }); + + ResourceMetadataContext.of(res).add({ + why: 'absorb transient processor failures without dropping orders', + trust: { + src: ContextTrustSource.INFER, + conf: ContextTrustConfidence.LOW, + cite: 'api/handler.ts:87', + note: 'rationale inferred from retry wrapper; no explicit design doc found', + }, + }); + + const template = toCloudFormation(stack); + expect(template.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('default targeting applies to the scope when it is a CfnResource', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(res).add({ why: 'on the resource itself' }); + + const template = toCloudFormation(stack); + expect(template.Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toMatchObject({ why: 'on the resource itself' }); + }); + + test('default targeting applies down the defaultChild chain but skips helper resources', () => { + const stack = new Stack(); + + // Model an L2-style construct: primary resource is the defaultChild, + // helper resource (e.g. an auto-created IAM role) is not. + const l2 = new Construct(stack, 'MyQueue'); + const primary = new CfnResource(l2, 'Resource', { type: 'AWS::SQS::Queue' }); + l2.node.defaultChild = primary; + const helper = new CfnResource(l2, 'HelperRole', { type: 'AWS::IAM::Role' }); + + ResourceMetadataContext.of(l2).add({ why: 'buffers events' }); + + const template = toCloudFormation(stack); + expect(template.Resources[stack.getLogicalId(primary)].Metadata[CONTEXT_METADATA_KEY]).toMatchObject({ why: 'buffers events' }); + expect(template.Resources[stack.getLogicalId(helper)].Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + }); + + test('default targeting follows a multi-hop defaultChild chain when the defaultChild is another construct', () => { + const stack = new Stack(); + + // Model an L3 whose defaultChild is an L2 (like cloudfront.experimental.EdgeFunction, + // whose defaultChild is a lambda.Function), which in turn designates its L1. + const l3 = new Construct(stack, 'EdgeFunction'); + const l2 = new Construct(l3, 'Fn'); + const primary = new CfnResource(l2, 'Resource', { type: 'AWS::Lambda::Function' }); + const helper = new CfnResource(l2, 'ServiceRole', { type: 'AWS::IAM::Role' }); + const sibling = new CfnResource(l3, 'Version', { type: 'AWS::Lambda::Version' }); + l3.node.defaultChild = l2; + + ResourceMetadataContext.of(l3).add({ why: 'runs at the edge' }); + + const template = toCloudFormation(stack); + expect(template.Resources[stack.getLogicalId(primary)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ why: 'runs at the edge' }); + expect(template.Resources[stack.getLogicalId(helper)].Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + expect(template.Resources[stack.getLogicalId(sibling)].Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + }); + + test('default targeting fails when the defaultChild chain ends at a construct without a defaultChild', () => { + const stack = new Stack(); + + const l3 = new Construct(stack, 'Outer'); + const middle = new Construct(l3, 'Middle'); + // Not named 'Resource' or 'Default', so `middle` designates no defaultChild. + new CfnResource(middle, 'Thing', { type: 'AWS::Fake::Thing' }); + l3.node.defaultChild = middle; + + ResourceMetadataContext.of(l3).add({ why: 'dead-end chain' }); + + expect(() => synthesize(stack)).toThrow( + /resource context declaration matched no CloudFormation resources.*propagate/, + ); + }); + + test('default targeting fails for an L3 that declares no defaultChild even when its children do', () => { + const stack = new Stack(); + + // Model an L3 pattern such as ApplicationLoadBalancedFargateService: several + // L2 children, each with its own primary resource, but no defaultChild on the L3. + const l3 = new Construct(stack, 'Service'); + const lbL2 = new Construct(l3, 'LB'); + const lb = new CfnResource(lbL2, 'Resource', { type: 'AWS::ElasticLoadBalancingV2::LoadBalancer' }); + lbL2.node.defaultChild = lb; + const svcL2 = new Construct(l3, 'Svc'); + const svc = new CfnResource(svcL2, 'Service', { type: 'AWS::ECS::Service' }); + svcL2.node.defaultChild = svc; + + ResourceMetadataContext.of(l3).add({ why: 'no primary resource' }); + + expect(() => synthesize(stack)).toThrow( + /resource context declaration matched no CloudFormation resources.*propagate/, + ); + }); + + test('an L3 without a defaultChild can be targeted with propagate and a type filter', () => { + 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' }); + lbL2.node.defaultChild = lb; + const lbHelper = new CfnResource(lbL2, 'SecurityGroup', { type: 'AWS::EC2::SecurityGroup' }); + const svcL2 = new Construct(l3, 'Svc'); + const svc = new CfnResource(svcL2, 'Service', { type: 'AWS::ECS::Service' }); + svcL2.node.defaultChild = svc; + + ResourceMetadataContext.of(l3).add({ + must: ['ALB idle timeout >= backend read timeout'], + }, { + propagate: true, + propagationFilter: PropagationFilter.includeResourceTypes(['AWS::ElasticLoadBalancingV2::LoadBalancer']), + }); + + 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('default targeting fails when a grouping construct has no primary resource', () => { + const stack = new Stack(); + const group = new Construct(stack, 'SubSystem'); + new CfnResource(group, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(group).add({ why: 'grouping rationale' }); + + expect(() => synthesize(stack)).toThrow( + /resource context declaration matched no CloudFormation resources.*propagate/, + ); + }); + + test('default targeting fails from a Stack scope', () => { + const stack = new Stack(); + // `Resource` is a special defaultChild id in constructs; Stack remains + // a structural boundary even when a direct child has that id. + new CfnResource(stack, 'Resource', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(stack).add({ why: 'stack-wide but narrow by default' }); + + expect(() => synthesize(stack)).toThrow( + /resource context declaration matched no CloudFormation resources.*propagate/, + ); + }); + + test('propagate reaches every resource beneath a grouping construct, helpers included', () => { + const stack = new Stack(); + + const group = new Construct(stack, 'SubSystem'); + const l2 = new Construct(group, 'Topic'); + const primary = new CfnResource(l2, 'Resource', { type: 'AWS::SNS::Topic' }); + l2.node.defaultChild = primary; + const helper = new CfnResource(l2, 'Policy', { type: 'AWS::SNS::TopicPolicy' }); + + ResourceMetadataContext.of(group).add({ deps: ['AlertingStack'] }, { propagate: true }); + + const template = toCloudFormation(stack); + expect(template.Resources[stack.getLogicalId(primary)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ deps: ['AlertingStack'] }); + expect(template.Resources[stack.getLogicalId(helper)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ deps: ['AlertingStack'] }); + }); + + test('propagate with excludeResourceTypes reaches 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' }); + l2.node.defaultChild = primary; + const role = new CfnResource(l2, 'ServiceRole', { type: 'AWS::IAM::Role' }); + const logGroup = new CfnResource(l2, 'LogGroup', { type: 'AWS::Logs::LogGroup' }); + + // Excluding the primary resource's own type leaves exactly the helpers. + ResourceMetadataContext.of(l2).add({ + why: 'supporting resource for the order processor', + }, { + propagate: true, + propagationFilter: PropagationFilter.excludeResourceTypes(['AWS::Lambda::Function']), + }); + + const template = toCloudFormation(stack); + expect(template.Resources[stack.getLogicalId(primary)].Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + for (const helper of [role, logGroup]) { + expect(template.Resources[stack.getLogicalId(helper)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ + why: 'supporting resource for the order processor', + }); + } + }); + + test('propagate reaches resources from a stack scope', () => { + const stack = new Stack(); + new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(stack).add({ why: 'resource belongs to the networked subsystem', deps: ['NetworkStack'] }, { propagate: true }); + + const template = toCloudFormation(stack); + expect(template.Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toMatchObject({ deps: ['NetworkStack'] }); + }); + + test('resource context inside a Stage matches 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' }); + + ResourceMetadataContext.of(stack).add({ why: 'stage resource' }, { propagate: true }); + + expect(() => stage.synth()).not.toThrow(); + }); + + test('resource context does not silently cross Stage assembly boundaries', () => { + const app = new App(); + const stage = new Stage(app, 'Deployment'); + const stack = new Stack(stage, 'Stack'); + new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(app).add({ why: 'outside assembly' }, { propagate: true }); + + expect(() => app.synth()).toThrow( + /resource context declaration matched no CloudFormation resources.*inside each Stage/, + ); + }); + + test('an in-Stage declaration does not render context from above the Stage boundary', () => { + const app = new App(); + const rootStack = new Stack(app, 'RootStack'); + new CfnResource(rootStack, 'RootRes', { type: 'AWS::Fake::Thing' }); + ResourceMetadataContext.of(app).add({ why: 'resources belong to the root assembly', must: ['root assembly rule'] }, { propagate: true }); + + const stage = new Stage(app, 'Deployment'); + const stack = new Stack(stage, 'StageStack'); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + ResourceMetadataContext.of(stack).add({ why: 'stage resource' }, { propagate: true }); + + const template = stage.synth().getStackByName(stack.stackName).template; + expect(template.Resources[stack.getLogicalId(res)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ + why: 'stage resource', + }); + }); + + test('propagate on an L2 scope reaches its helper resources too', () => { + const stack = new Stack(); + const l2 = new Construct(stack, 'MyQueue'); + const primary = new CfnResource(l2, 'Resource', { type: 'AWS::SQS::Queue' }); + l2.node.defaultChild = primary; + const helper = new CfnResource(l2, 'HelperRole', { type: 'AWS::IAM::Role' }); + + ResourceMetadataContext.of(l2).add({ why: 'buffers events' }, { propagate: true }); + + const template = toCloudFormation(stack); + expect(template.Resources[stack.getLogicalId(primary)].Metadata[CONTEXT_METADATA_KEY]).toMatchObject({ why: 'buffers events' }); + expect(template.Resources[stack.getLogicalId(helper)].Metadata[CONTEXT_METADATA_KEY]).toMatchObject({ why: 'buffers events' }); + }); + + test('propagate selects every resource under the scope, not only helpers', () => { + const stack = new Stack(); + const l2 = new Construct(stack, 'MyQueue'); + const primary = new CfnResource(l2, 'Resource', { type: 'AWS::SQS::Queue' }); + l2.node.defaultChild = primary; + const helper = new CfnResource(l2, 'Policy', { type: 'AWS::SQS::QueuePolicy' }); + const loose = new CfnResource(stack, 'Loose', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(stack).add({ deps: ['NetworkStack'] }, { propagate: true }); + + 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('propagate with includeResourceTypes reaches helpers of that type only', () => { + const stack = new Stack(); + const l2 = new Construct(stack, 'Fn'); + const primary = new CfnResource(l2, 'Resource', { type: 'AWS::Lambda::Function' }); + l2.node.defaultChild = primary; + const role = new CfnResource(l2, 'ServiceRole', { type: 'AWS::IAM::Role' }); + const policy = new CfnResource(l2, 'ServiceRolePolicy', { type: 'AWS::IAM::Policy' }); + + ResourceMetadataContext.of(stack).add({ + must: ['execution roles keep the org permissions boundary'], + }, { + propagate: true, + propagationFilter: PropagationFilter.includeResourceTypes(['AWS::IAM::Role']), + }); + + const template = toCloudFormation(stack); + expect(template.Resources[stack.getLogicalId(role)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ + must: ['execution roles keep the org permissions boundary'], + }); + expect(template.Resources[stack.getLogicalId(primary)].Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + expect(template.Resources[stack.getLogicalId(policy)].Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + }); + + 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' }); + // A sibling with id "Default" makes node.defaultChild ambiguous. The + // constructs library throws, and CDK deliberately lets that error through + // because it names the root cause. + new CfnResource(ambiguous, 'Default', { type: 'AWS::Fake::Other' }); + + ResourceMetadataContext.of(ambiguous).add({ why: 'x' }); + + expect(() => synthesize(stack)).toThrow( + /Cannot determine default child for .*Ambiguous.*both a child with id "Resource" and id "Default"/, + ); + }); + + test('revalidates targets and clears stale render state on repeated synthesis', () => { + const stack = new Stack(); + const l2 = new Construct(stack, 'MyQueue'); + const primary = new CfnResource(l2, 'Resource', { type: 'AWS::SQS::Queue' }); + const nonResource = new Construct(l2, 'NotAResource'); + l2.node.defaultChild = primary; + + ResourceMetadataContext.of(l2).add({ why: 'buffers events' }); + + const firstTemplate = synthesize(stack).getStackByName(stack.stackName).template; + const logicalId = stack.getLogicalId(primary); + expect(firstTemplate.Resources[logicalId].Metadata[CONTEXT_METADATA_KEY]).toEqual({ + why: 'buffers events', + }); + + l2.node.defaultChild = nonResource; + const secondTemplate = synthesize(stack, { skipValidation: true }).getStackByName(stack.stackName).template; + expect(secondTemplate.Resources[logicalId].Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + expect(() => synthesize(stack)).toThrow( + /resource context declaration matched no CloudFormation resources.*defaultChild/, + ); + }); + + test('nearest-wins: scalar fields from closer scopes override outer scopes', () => { + const stack = new Stack(); + const scope = new Construct(stack, 'SubSystem'); + const res = new CfnResource(scope, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(scope).add({ + why: 'outer rationale', + mutable: ContextMutability.FREE_TO_TUNE, + must: ['outer invariant'], + }, { propagate: true }); + ResourceMetadataContext.of(res).add({ + why: 'inner rationale', + must: ['inner invariant'], + }); + + const template = toCloudFormation(stack); + const logicalId = stack.getLogicalId(res); + expect(template.Resources[logicalId].Metadata[CONTEXT_METADATA_KEY]).toMatchObject({ + why: 'inner rationale', + mutable: 'free-to-tune', + must: ['outer invariant', 'inner invariant'], + }); + }); + + test('list fields accumulate across scopes and de-duplicate', () => { + const stack = new Stack(); + const scope = new Construct(stack, 'SubSystem'); + const res = new CfnResource(scope, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(scope).add({ why: 'shared subsystem resource', must: ['shared rule', 'outer rule'] }, { propagate: true }); + ResourceMetadataContext.of(res).add({ must: ['shared rule', 'inner rule'] }); + + const template = toCloudFormation(stack); + const logicalId = stack.getLogicalId(res); + expect(template.Resources[logicalId].Metadata[CONTEXT_METADATA_KEY].must).toEqual([ + 'shared rule', + 'outer rule', + 'inner rule', + ]); + }); + + test('mutability maps merge per key with nearest-wins per property', () => { + const stack = new Stack(); + const scope = new Construct(stack, 'SubSystem'); + const res = new CfnResource(scope, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(scope).add({ + why: 'queue settings preserve order-processing behavior', + must: ['VisibilityTimeout changes must preserve the retry timing relationship'], + mutability: { + QueueName: ContextMutability.REVIEW_REQUIRED, + VisibilityTimeout: ContextMutability.CHANGE_WITH_CONSTRAINTS, + }, + }, { propagate: true }); + ResourceMetadataContext.of(res).add({ + must: ['QueueName must not change because replacement loses the external reference'], + mutability: { QueueName: ContextMutability.MUST_NEVER_CHANGE }, + }); + + const template = toCloudFormation(stack); + const logicalId = stack.getLogicalId(res); + expect(template.Resources[logicalId].Metadata[CONTEXT_METADATA_KEY].mutability).toEqual({ + QueueName: 'must-never-change', + VisibilityTimeout: 'change-with-constraints', + }); + }); + + test('inheritAncestorContext defaults to inheriting merged ancestor context', () => { + const stack = new Stack(); + const scope = new Construct(stack, 'SubSystem'); + const res = new CfnResource(scope, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(scope).add({ must: ['ancestor rule'] }, { propagate: true }); + ResourceMetadataContext.of(res).add({ why: 'leaf rationale' }); + + const template = toCloudFormation(stack); + expect(template.Resources[stack.getLogicalId(res)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ + must: ['ancestor rule'], + why: 'leaf rationale', + }); + }); + + test('inheritAncestorContext=false resets previously merged ancestor context', () => { + const stack = new Stack(); + const scope = new Construct(stack, 'SubSystem'); + const res = new CfnResource(scope, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(scope).add({ must: ['ancestor rule'], why: 'ancestor rationale' }, { propagate: true }); + ResourceMetadataContext.of(res).add({ why: 'leaf rationale' }, { inheritAncestorContext: false }); + + const template = toCloudFormation(stack); + expect(template.Resources[stack.getLogicalId(res)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ + why: 'leaf rationale', + }); + }); + + test('inheritAncestorContext=false preserves all declarations on the same scope', () => { + const stack = new Stack(); + const scope = new Construct(stack, 'SubSystem'); + const res = new CfnResource(scope, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(scope).add({ must: ['ancestor rule'] }, { propagate: true }); + ResourceMetadataContext.of(res).add({ must: ['same-scope rule'] }); + ResourceMetadataContext.of(res).add({ why: 'leaf rationale' }, { inheritAncestorContext: false }); + + const template = toCloudFormation(stack); + expect(template.Resources[stack.getLogicalId(res)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ + must: ['same-scope rule'], + why: 'leaf rationale', + }); + }); + + test('multiple add() calls on the same scope merge', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(res).add({ why: 'first rationale', must: ['rule 1'] }); + ResourceMetadataContext.of(res).add({ why: 'second rationale', must: ['rule 2'] }); + + const template = toCloudFormation(stack); + expect(template.Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toMatchObject({ + why: 'second rationale', + must: ['rule 1', 'rule 2'], + }); + }); + + test('include/exclude resource type filters', () => { + const stack = new Stack(); + const scope = new Construct(stack, 'SubSystem'); + const queue = new CfnResource(scope, 'Queue', { type: 'AWS::SQS::Queue' }); + const topic = new CfnResource(scope, 'Topic', { type: 'AWS::SNS::Topic' }); + + ResourceMetadataContext.of(scope).add( + { why: 'queue-specific context' }, + { propagate: true, propagationFilter: PropagationFilter.includeResourceTypes(['AWS::SQS::Queue']) }, + ); + ResourceMetadataContext.of(scope).add( + { why: 'non-queue subsystem resource' }, + { propagate: true, propagationFilter: PropagationFilter.excludeResourceTypes(['AWS::SQS::Queue']) }, + ); + + const template = toCloudFormation(stack); + const queueId = stack.getLogicalId(queue); + const topicId = stack.getLogicalId(topic); + expect(template.Resources[queueId].Metadata[CONTEXT_METADATA_KEY]).toMatchObject({ why: 'queue-specific context' }); + expect(template.Resources[topicId].Metadata[CONTEXT_METADATA_KEY]).toMatchObject({ why: 'non-queue subsystem resource' }); + }); + + test('fails when resource type filters match no resources', () => { + const stack = new Stack(); + const scope = new Construct(stack, 'SubSystem'); + new CfnResource(scope, 'Topic', { type: 'AWS::SNS::Topic' }); + + ResourceMetadataContext.of(scope).add( + { why: 'queue-only rationale' }, + { propagate: true, propagationFilter: PropagationFilter.includeResourceTypes(['AWS::SQS::Queue']) }, + ); + + expect(() => synthesize(stack)).toThrow( + /resource context declaration matched no CloudFormation resources.*propagation filter/, + ); + }); + + test('fails when excludeResourceTypes removes every propagated target', () => { + const stack = new Stack(); + const scope = new Construct(stack, 'SubSystem'); + new CfnResource(scope, 'Queue', { type: 'AWS::SQS::Queue' }); + + ResourceMetadataContext.of(scope).add( + { why: 'excluded rationale' }, + { propagate: true, propagationFilter: PropagationFilter.excludeResourceTypes(['AWS::SQS::Queue']) }, + ); + + expect(() => synthesize(stack)).toThrow( + /resource context declaration matched no CloudFormation resources.*propagation filter/, + ); + }); + + test('a propagationFilter without propagate: true throws at add()', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Queue', { type: 'AWS::SQS::Queue' }); + + expect(() => ResourceMetadataContext.of(res).add( + { why: 'filter without propagation' }, + { propagationFilter: PropagationFilter.includeResourceTypes(['AWS::SQS::Queue']) }, + )).toThrow(UnscopedValidationError); + expect(() => ResourceMetadataContext.of(res).add( + { why: 'filter without propagation' }, + { propagate: false, propagationFilter: PropagationFilter.excludeResourceTypes(['AWS::IAM::Role']) }, + )).toThrow(/propagationFilter requires propagate: true/); + }); + + test('each declaration must independently match at least one resource', () => { + const stack = new Stack(); + const scope = new Construct(stack, 'SubSystem'); + new CfnResource(scope, 'Queue', { type: 'AWS::SQS::Queue' }); + + ResourceMetadataContext.of(scope).add( + { why: 'queue rationale' }, + { propagate: true, propagationFilter: PropagationFilter.includeResourceTypes(['AWS::SQS::Queue']) }, + ); + ResourceMetadataContext.of(scope).add( + { why: 'topic rationale' }, + { propagate: true, propagationFilter: PropagationFilter.includeResourceTypes(['AWS::SNS::Topic']) }, + ); + + expect(() => synthesize(stack)).toThrow( + /resource context declaration matched no CloudFormation resources.*propagation filter/, + ); + }); + + test('propagate fails on an empty scope', () => { + const stack = new Stack(); + const scope = new Construct(stack, 'Empty'); + + ResourceMetadataContext.of(scope).add({ why: 'no targets' }, { propagate: true }); + + expect(() => synthesize(stack)).toThrow( + /resource context declaration matched no CloudFormation resources/, + ); + }); + + 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); + + const template = toCloudFormation(stack); + expect(template.Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual(manualContext); + expect(res.getMetadata(CONTEXT_METADATA_KEY)).toEqual(manualContext); + }); + + 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' }); + + ResourceMetadataContext.of(res).add({ why: 'routes events to external storage' }); + + const template = toCloudFormation(stack); + expect(template.Resources.Res.Metadata['com.example.ToolMetadata']).toEqual({ + toolSpecificField: 'tool-specific-value', + }); + expect(template.Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toBeDefined(); + }); + + test('no namespaced Context metadata emitted for resources with no applicable context', () => { + const stack = new Stack(); + const withContext = new CfnResource(stack, 'A', { type: 'AWS::Fake::Thing' }); + new CfnResource(stack, 'B', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(withContext).add({ why: 'has context' }); + + const template = toCloudFormation(stack); + expect(template.Resources.A.Metadata[CONTEXT_METADATA_KEY]).toBeDefined(); + expect(template.Resources.B.Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + }); + }); + + describe('resource-level validation', () => { + test('an empty context block is a harmless no-op', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + expect(() => ResourceMetadataContext.of(res).add({})).not.toThrow(); + expect(() => ResourceMetadataContext.of(res).add({ must: [] })).not.toThrow(); + + expect(toCloudFormation(stack).Resources.Res.Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + }); + + test('trust-only block synthesizes', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(res).add({ + trust: { + src: ContextTrustSource.AUTHORED, + conf: ContextTrustConfidence.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' }); + + ResourceMetadataContext.of(res).add({ deps: ['NetworkStack'] }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ + deps: ['NetworkStack'], + }); + }); + + test('why is optional and merges from an applicable ancestor declaration', () => { + const stack = new Stack(); + const scope = new Construct(stack, 'SubSystem'); + const res = new CfnResource(scope, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(scope).add({ why: 'processes order events' }, { propagate: true }); + ResourceMetadataContext.of(res).add({ deps: ['check queue depth'] }); + + expect(toCloudFormation(stack).Resources[stack.getLogicalId(res)].Metadata[CONTEXT_METADATA_KEY]).toEqual({ + why: 'processes order events', + deps: ['check queue depth'], + }); + }); + + test('allows trust when accompanied by why', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(res).add({ + why: 'processes order events', + trust: { + src: ContextTrustSource.AUTHORED, + conf: ContextTrustConfidence.HIGH, + }, + }); + + expect(() => synthesize(stack)).not.toThrow(); + }); + + test('blank list entries are structurally valid and synthesize', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(res).add({ must: [' '] }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ must: [' '] }); + }); + + test('a blank why is structurally valid and synthesizes', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(res).add({ why: ' ' }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toEqual({ why: ' ' }); + }); + + test('throws when trust is provided without src', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + const trust = { conf: ContextTrustConfidence.HIGH } as any; + + expect(() => ResourceMetadataContext.of(res).add({ why: 'x', trust })).toThrow(/trust requires 'src'/); + }); + + test('throws when trust is provided without conf', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + const trust = { src: ContextTrustSource.AUTHORED } as any; + + expect(() => ResourceMetadataContext.of(res).add({ why: 'x', trust })).toThrow(/trust requires 'conf'/); + }); + + test('blank trust cite or note is structurally valid and synthesizes', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(res).add({ + why: 'x', + trust: { src: ContextTrustSource.AUTHORED, conf: ContextTrustConfidence.HIGH, cite: ' ', note: ' ' }, + }); + + expect(toCloudFormation(stack).Resources.Res.Metadata[CONTEXT_METADATA_KEY].trust).toEqual({ + src: 'authored', + conf: 'high', + cite: ' ', + note: ' ', + }); + }); + + test.each([ + ContextMutability.MUST_NEVER_CHANGE, + ContextMutability.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' }); + + ResourceMetadataContext.of(res).add({ + why: 'processes order events', + mutable: mutability, + }); + + expect(() => synthesize(stack)).not.toThrow(); + }); + + test.each([ + ContextMutability.MUST_NEVER_CHANGE, + ContextMutability.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' }); + + ResourceMetadataContext.of(res).add({ + why: 'processes order events', + mutability: { Name: mutability }, + }); + + expect(() => synthesize(stack)).not.toThrow(); + }); + + test('allows constrained mutability with a non-empty must rule', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(res).add({ + why: 'processes order events', + must: ['Name must not change because replacement loses the external reference'], + mutable: ContextMutability.CHANGE_WITH_CONSTRAINTS, + mutability: { Name: ContextMutability.MUST_NEVER_CHANGE }, + }); + + expect(() => synthesize(stack)).not.toThrow(); + }); + + test('constrained mutability can use an inherited must rule', () => { + const stack = new Stack(); + const scope = new Construct(stack, 'SubSystem'); + const res = new CfnResource(scope, 'Res', { type: 'AWS::Fake::Thing' }); + + ResourceMetadataContext.of(scope).add({ + why: 'processes order events', + must: ['VisibilityTimeout must preserve the retry timing relationship'], + }, { propagate: true }); + ResourceMetadataContext.of(res).add({ + mutability: { VisibilityTimeout: ContextMutability.CHANGE_WITH_CONSTRAINTS }, + }); + + expect(() => synthesize(stack)).not.toThrow(); + }); + + test('throws when a mutability entry repeats mutable', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + expect(() => ResourceMetadataContext.of(res).add({ + mutable: ContextMutability.FREE_TO_TUNE, + mutability: { Name: ContextMutability.FREE_TO_TUNE }, + })).toThrow(/must not repeat mutable/); + }); + + test('allows mutability entries that deviate from mutable', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + expect(() => ResourceMetadataContext.of(res).add({ + why: 'processes order events', + must: ['Name must not change because replacement loses the external reference'], + mutable: ContextMutability.FREE_TO_TUNE, + mutability: { Name: ContextMutability.MUST_NEVER_CHANGE }, + })).not.toThrow(); + }); + + test('allows mutability without a mutable', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + + expect(() => ResourceMetadataContext.of(res).add({ + why: 'processes order events', + mutability: { Name: ContextMutability.FREE_TO_TUNE }, + })).not.toThrow(); + }); + }); + + describe('template-level context', () => { + test('renders a top-level namespaced Context metadata block', () => { + const stack = new Stack(); + + TemplateMetadataContext.of(stack).add({ + arch: 'SQS buffer -> Lambda -> DynamoDB; DLQ for poison msgs', + must: ['all data encrypted w/ security-team CMK'], + owner: 'order-processing-team', + }); + + const template = toCloudFormation(stack); + expect(template.Metadata[CONTEXT_METADATA_KEY]).toMatchObject({ + arch: 'SQS buffer -> Lambda -> DynamoDB; DLQ for poison msgs', + must: ['all data encrypted w/ security-team CMK'], + owner: 'order-processing-team', + }); + }); + + test('allows template context without must', () => { + const archStack = new Stack(); + const ownerStack = new Stack(); + + expect(() => TemplateMetadataContext.of(archStack).add({ arch: 'queue to function to database' })).not.toThrow(); + expect(() => TemplateMetadataContext.of(ownerStack).add({ owner: 'order-processing-team' })).not.toThrow(); + }); + + test('ref entries render bare-string form when only a relative path is given', () => { + const stack = new Stack(); + + TemplateMetadataContext.of(stack).add({ + ref: [ + { at: 'docs/network-context.yaml' }, + { at: 'docs/encryption-context.yaml', has: 'organization encryption and tagging rules', scope: 'shared' }, + ], + }); + + const template = toCloudFormation(stack); + expect(template.Metadata[CONTEXT_METADATA_KEY].ref).toEqual([ + 'docs/network-context.yaml', + { at: 'docs/encryption-context.yaml', has: 'organization encryption and tagging rules', scope: 'shared' }, + ]); + }); + + test('multiple add() calls merge (scalars win, lists accumulate)', () => { + const stack = new Stack(); + + TemplateMetadataContext.of(stack).add({ arch: 'first arch', must: ['rule 1'] }); + TemplateMetadataContext.of(stack).add({ arch: 'second arch', must: ['rule 2'], owner: 'platform-team' }); + + const template = toCloudFormation(stack); + expect(template.Metadata[CONTEXT_METADATA_KEY]).toMatchObject({ + arch: 'second arch', + must: ['rule 1', 'rule 2'], + owner: 'platform-team', + }); + }); + + 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'); + new CfnResource(scope, 'Res', { type: 'AWS::Fake::Thing' }); + + TemplateMetadataContext.of(Stack.of(scope)).add({ arch: 'nested-declared arch' }); + + const template = toCloudFormation(stack); + expect(template.Metadata[CONTEXT_METADATA_KEY].arch).toEqual('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' }); + + TemplateMetadataContext.of(nested).add({ arch: 'child-stack arch' }); + ResourceMetadataContext.of(nested).add({ why: 'nested resource rationale' }, { propagate: true }); + + const assembly = app.synth(); + const parentTemplate = assembly.getStackByName(parent.stackName).template; + // The nested stack's template is written as a separate cloud-assembly file + const nestedTemplate = JSON.parse( + fs.readFileSync(path.join(assembly.directory, nested.templateFile), 'utf-8'), + ); + + expect(nestedTemplate.Metadata[CONTEXT_METADATA_KEY]).toMatchObject({ arch: 'child-stack arch' }); + expect(nestedTemplate.Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toMatchObject({ why: 'nested resource rationale' }); + expect(parentTemplate.Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + }); + + test('propagate 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' }); + + ResourceMetadataContext.of(parent).add({ + why: 'resource belongs to the encrypted application stack', + must: ['all data encrypted w/ CMK'], + }, { propagate: true }); + + const assembly = app.synth(); + const nestedTemplate = JSON.parse( + fs.readFileSync(path.join(assembly.directory, nested.templateFile), 'utf-8'), + ); + + expect(nestedTemplate.Resources.Res.Metadata[CONTEXT_METADATA_KEY]).toMatchObject({ + must: ['all data encrypted w/ CMK'], + }); + }); + + 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'); + + TemplateMetadataContext.of(stack).add({ arch: 'the arch' }); + + const template = toCloudFormation(stack); + expect(template.Metadata.SomeOtherKey).toEqual('value'); + expect(template.Metadata[CONTEXT_METADATA_KEY].arch).toEqual('the arch'); + }); + + test('an empty template context is a harmless no-op', () => { + const stack = new Stack(); + + expect(() => TemplateMetadataContext.of(stack).add({})).not.toThrow(); + expect(toCloudFormation(stack).Metadata?.[CONTEXT_METADATA_KEY]).toBeUndefined(); + }); + + test('a blank ref at path is structurally valid and synthesizes', () => { + const stack = new Stack(); + TemplateMetadataContext.of(stack).add({ 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(() => TemplateMetadataContext.of(stack).add({ ref: [{ has: 'no at here' } as any] })).toThrow(UnscopedValidationError); + expect(() => TemplateMetadataContext.of(stack).add({ ref: [{ has: 'no at here' } as any] })).toThrow(/ref entries require an 'at' path/); + }); + + 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(); + TemplateMetadataContext.of(stack).add({ ref: [{ at }] }); + + const template = toCloudFormation(stack); + expect(template.Metadata[CONTEXT_METADATA_KEY].ref).toEqual([at]); + }); + }); + + describe('collision detection', () => { + test('resource-level API context colliding with a manual Context block throws at synthesis', () => { + const stack = new Stack(); + const res = new CfnResource(stack, 'Res', { type: 'AWS::Fake::Thing' }); + res.addMetadata(CONTEXT_METADATA_KEY, { why: 'manual user value', must: ['manual user rule'] }); + + ResourceMetadataContext.of(res).add({ why: 'managed rationale' }); + + expect(() => toCloudFormation(stack)).toThrow(/both a manually added/); + }); + + test('template-level API context colliding with a manual Context block throws at synthesis', () => { + const stack = new Stack(); + stack.addMetadata(CONTEXT_METADATA_KEY, { arch: 'manual user value', must: ['manual user rule'] }); + + TemplateMetadataContext.of(stack).add({ arch: 'managed architecture' }); + + expect(() => toCloudFormation(stack)).toThrow(/both a manually added/); + }); + }); + + 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' }); + + ResourceMetadataContext.of(res).add({ + why: 'w', + must: ['m'], + mutable: ContextMutability.FREE_TO_TUNE, + mutability: { Prop: ContextMutability.REVIEW_REQUIRED }, + trust: { src: ContextTrustSource.AUTHORED, conf: ContextTrustConfidence.HIGH }, + deps: ['d'], + }); + + const template = toCloudFormation(stack); + const context = template.Resources.Res.Metadata[CONTEXT_METADATA_KEY]; + const resourceFields = ['why', 'must', 'mutable', 'mutability', 'trust', 'deps']; + expect(Object.keys(context).sort()).toEqual([...resourceFields].sort()); + // Enum values are frozen advisory-schema tokens + 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(); + + TemplateMetadataContext.of(stack).add({ + arch: 'a', + must: ['m'], + ref: [{ at: 'docs/context.yaml' }], + owner: 'o', + }); + + const template = toCloudFormation(stack); + expect(Object.keys(template.Metadata[CONTEXT_METADATA_KEY]).sort()).toEqual(['arch', 'must', 'owner', 'ref']); + }); + + test('enum values match the advisory schema vocabulary', () => { + // Drift check per the schema's consumer-update strategy: these string + // values are FROZEN for the advisory schema. If this test fails, the emitted + // values no longer match the published schema. + expect(Object.values(ContextMutability).sort()).toEqual([ + 'change-with-constraints', + 'free-to-tune', + 'must-never-change', + 'review-required', + ]); + expect(Object.values(ContextTrustSource).sort()).toEqual([ + 'authored', + 'comment', + 'commit', + 'infer', + ]); + expect(Object.values(ContextTrustConfidence).sort()).toEqual([ + 'high', + 'low', + 'medium', + ]); + }); + }); +}); diff --git a/packages/aws-cdk-lib/rosetta/default.ts-fixture b/packages/aws-cdk-lib/rosetta/default.ts-fixture index e2ab1578d9906..cb1a10e11c46f 100644 --- a/packages/aws-cdk-lib/rosetta/default.ts-fixture +++ b/packages/aws-cdk-lib/rosetta/default.ts-fixture @@ -51,6 +51,12 @@ import { Mixin, Mixins, MissingRemovalPolicies, + ResourceMetadataContext, + TemplateMetadataContext, + PropagationFilter, + ContextMutability, + ContextTrustSource, + ContextTrustConfidence, Resource, SecretValue, Size,