diff --git a/src/schema/transformers/ResourceEdgeCaseTransformer.ts b/src/schema/transformers/ResourceEdgeCaseTransformer.ts new file mode 100644 index 00000000..740ca2c0 --- /dev/null +++ b/src/schema/transformers/ResourceEdgeCaseTransformer.ts @@ -0,0 +1,36 @@ +import { ResourceSchema } from '../ResourceSchema'; +import type { ResourceTemplateTransformer } from './ResourceTemplateTransformer'; + +type EdgeCaseHandler = (resourceProperties: Record) => void; + +// WAFv2 IPSet Description pattern requires non-whitespace start/end chars +const removeEmptyOrWhitespaceDescription: EdgeCaseHandler = (resourceProperties) => { + if (typeof resourceProperties.Description !== 'string') { + return; + } + if (resourceProperties.Description.trim() === '') { + delete resourceProperties.Description; + } +}; + +/** + * Transformer that handles specific edge cases not covered by schema-based transformers. + * Add handlers here for resource-specific issues that cause deployment failures. + */ +export class ResourceEdgeCaseTransformer implements ResourceTemplateTransformer { + private readonly handlers: ReadonlyMap = new Map([ + // WAFv2 IPSet: Description pattern requires non-whitespace start/end chars + ['AWS::WAFv2::IPSet', [removeEmptyOrWhitespaceDescription]], + ]); + + public transform(resourceProperties: Record, schema: ResourceSchema): void { + const handlers = this.handlers.get(schema.typeName); + if (!handlers) { + return; + } + + for (const handler of handlers) { + handler(resourceProperties); + } + } +} diff --git a/src/schema/transformers/TransformersUtil.ts b/src/schema/transformers/TransformersUtil.ts index 020b2b0a..9ef60125 100644 --- a/src/schema/transformers/TransformersUtil.ts +++ b/src/schema/transformers/TransformersUtil.ts @@ -5,6 +5,7 @@ import { RemoveReadonlyPropertiesTransformer } from './RemoveReadonlyPropertiesT import { RemoveRequiredXorPropertiesTransformer } from './RemoveRequiredXorPropertiesTransformer'; import { RemoveSystemTagsTransformer } from './RemoveSystemTagsTransformer'; import { ReplacePrimaryIdentifierTransformer } from './ReplacePrimaryIdentifierTransformer'; +import { ResourceEdgeCaseTransformer } from './ResourceEdgeCaseTransformer'; import type { ResourceTemplateTransformer } from './ResourceTemplateTransformer'; export class TransformersUtil { @@ -16,6 +17,7 @@ export class TransformersUtil { new RemoveSystemTagsTransformer(), new RemoveRequiredXorPropertiesTransformer(), new AddWriteOnlyRequiredPropertiesTransformer(), + new ResourceEdgeCaseTransformer(), ]; } else if (purpose === ResourceStatePurpose.CLONE) { return [ @@ -25,6 +27,7 @@ export class TransformersUtil { new ReplacePrimaryIdentifierTransformer(), new RemoveRequiredXorPropertiesTransformer(), new AddWriteOnlyRequiredPropertiesTransformer(), + new ResourceEdgeCaseTransformer(), ]; } return []; diff --git a/tst/resources/schemas/aws-wafv2-ipset.json b/tst/resources/schemas/aws-wafv2-ipset.json new file mode 100644 index 00000000..982670c6 --- /dev/null +++ b/tst/resources/schemas/aws-wafv2-ipset.json @@ -0,0 +1,130 @@ +{ + "typeName" : "AWS::WAFv2::IPSet", + "description" : "Contains a list of IP addresses. This can be either IPV4 or IPV6. The list will be mutually", + "sourceUrl" : "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-wafv2.git", + "definitions" : { + "EntityName" : { + "description" : "Name of the IPSet.", + "type" : "string", + "pattern" : "^[0-9A-Za-z_-]{1,128}$" + }, + "EntityDescription" : { + "description" : "Description of the entity.", + "type" : "string", + "pattern" : "^[a-zA-Z0-9=:#@/\\-,.][a-zA-Z0-9+=:#@/\\-,.\\s]+[a-zA-Z0-9+=:#@/\\-,.]{1,256}$" + }, + "EntityId" : { + "description" : "Id of the IPSet", + "type" : "string", + "pattern" : "^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$" + }, + "Scope" : { + "description" : "Use CLOUDFRONT for CloudFront IPSet, use REGIONAL for Application Load Balancer and API Gateway.", + "type" : "string", + "enum" : [ "CLOUDFRONT", "REGIONAL" ] + }, + "IPAddressVersion" : { + "description" : "Type of addresses in the IPSet, use IPV4 for IPV4 IP addresses, IPV6 for IPV6 address.", + "type" : "string", + "enum" : [ "IPV4", "IPV6" ] + }, + "IPAddress" : { + "description" : "IP address", + "type" : "string", + "maxLength" : 50, + "minLength" : 1 + }, + "ResourceArn" : { + "description" : "ARN of the WAF entity.", + "type" : "string" + }, + "Tag" : { + "type" : "object", + "properties" : { + "Key" : { + "type" : "string", + "minLength" : 1, + "maxLength" : 128 + }, + "Value" : { + "type" : "string", + "minLength" : 0, + "maxLength" : 256 + } + }, + "additionalProperties" : false + } + }, + "properties" : { + "Arn" : { + "$ref" : "#/definitions/ResourceArn" + }, + "Description" : { + "$ref" : "#/definitions/EntityDescription" + }, + "Name" : { + "$ref" : "#/definitions/EntityName" + }, + "Id" : { + "$ref" : "#/definitions/EntityId" + }, + "Scope" : { + "$ref" : "#/definitions/Scope" + }, + "IPAddressVersion" : { + "$ref" : "#/definitions/IPAddressVersion" + }, + "Addresses" : { + "description" : "List of IPAddresses.", + "type" : "array", + "items" : { + "$ref" : "#/definitions/IPAddress" + } + }, + "Tags" : { + "type" : "array", + "items" : { + "$ref" : "#/definitions/Tag" + }, + "minItems" : 1 + } + }, + "required" : [ "Addresses", "IPAddressVersion", "Scope" ], + "primaryIdentifier" : [ "/properties/Name", "/properties/Id", "/properties/Scope" ], + "createOnlyProperties" : [ "/properties/Name", "/properties/Scope" ], + "readOnlyProperties" : [ "/properties/Arn", "/properties/Id" ], + "additionalProperties" : false, + "tagging" : { + "cloudFormationSystemTags" : true, + "tagOnCreate" : true, + "tagUpdatable" : true, + "taggable" : true, + "tagProperty" : "/properties/Tags", + "permissions" : [ "wafv2:TagResource", "wafv2:UntagResource", "wafv2:ListTagsForResource" ] + }, + "handlers" : { + "create" : { + "permissions" : [ "wafv2:CreateIPSet", "wafv2:GetIPSet", "wafv2:ListTagsForResource", "wafv2:TagResource", "wafv2:UntagResource" ] + }, + "delete" : { + "permissions" : [ "wafv2:DeleteIPSet", "wafv2:GetIPSet" ] + }, + "read" : { + "permissions" : [ "wafv2:GetIPSet", "wafv2:ListTagsForResource" ] + }, + "update" : { + "permissions" : [ "wafv2:UpdateIPSet", "wafv2:GetIPSet", "wafv2:ListTagsForResource", "wafv2:TagResource", "wafv2:UntagResource" ] + }, + "list" : { + "permissions" : [ "wafv2:listIPSets" ], + "handlerSchema" : { + "properties" : { + "Scope" : { + "$ref" : "resource-schema.json#/properties/Scope" + } + }, + "required" : [ "Scope" ] + } + } + } +} \ No newline at end of file diff --git a/tst/unit/schema/transformers/ResourceEdgeCaseTransformer.test.ts b/tst/unit/schema/transformers/ResourceEdgeCaseTransformer.test.ts new file mode 100644 index 00000000..2cca0df6 --- /dev/null +++ b/tst/unit/schema/transformers/ResourceEdgeCaseTransformer.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { ResourceEdgeCaseTransformer } from '../../../../src/schema/transformers/ResourceEdgeCaseTransformer'; +import { combinedSchemas } from '../../../utils/SchemaUtils'; + +describe('ResourceEdgeCaseTransformer', () => { + const schemas = combinedSchemas(); + const transformer = new ResourceEdgeCaseTransformer(); + + it('should remove empty Description from AWS::WAFv2::IPSet', () => { + const schema = schemas.schemas.get('AWS::WAFv2::IPSet')!; + const resourceProperties = { + Name: 'test-ipset', + Description: '', + Scope: 'REGIONAL', + }; + + transformer.transform(resourceProperties, schema); + + expect(resourceProperties.Description).toBeUndefined(); + }); + + it('should remove whitespace-only Description from AWS::WAFv2::IPSet', () => { + const schema = schemas.schemas.get('AWS::WAFv2::IPSet')!; + const resourceProperties = { + Name: 'test-ipset', + Description: ' ', + Scope: 'REGIONAL', + }; + + transformer.transform(resourceProperties, schema); + + expect(resourceProperties.Description).toBeUndefined(); + }); + + it('should not modify valid Description with leading/trailing whitespace in AWS::WAFv2::IPSet', () => { + const schema = schemas.schemas.get('AWS::WAFv2::IPSet')!; + const resourceProperties = { + Name: 'test-ipset', + Description: ' Valid description ', + Scope: 'REGIONAL', + }; + + transformer.transform(resourceProperties, schema); + + expect(resourceProperties.Description).toBe(' Valid description '); + }); + + it('should not modify resources without edge case handlers', () => { + const schema = schemas.schemas.get('AWS::S3::Bucket')!; + const resourceProperties = { + BucketName: 'test-bucket', + Description: '', + }; + + transformer.transform(resourceProperties, schema); + + expect(resourceProperties.Description).toBe(''); + }); +}); diff --git a/tst/utils/SchemaUtils.ts b/tst/utils/SchemaUtils.ts index 96009d8d..a77bab74 100644 --- a/tst/utils/SchemaUtils.ts +++ b/tst/utils/SchemaUtils.ts @@ -137,6 +137,12 @@ export const Schemas = { return loadSchema('aws-wafv2-webacl.json'); }, }, + WAFv2IPSet: { + fileName: 'file://aws-wafv2-ipset.json', + get contents() { + return loadSchema('aws-wafv2-ipset.json'); + }, + }, }; export const SamSchemaFiles = {