diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationResourceProvisioner.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationResourceProvisioner.java index 9289eacdf1..03762e97c4 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationResourceProvisioner.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationResourceProvisioner.java @@ -224,7 +224,6 @@ public class CloudFormationResourceProvisioner { "AWS::Batch::ComputeEnvironment", "AWS::Batch::JobDefinition", "AWS::Batch::JobQueue", - "AWS::CDK::Metadata", "AWS::CloudFormation::CustomResource", "AWS::CloudFront::Distribution", "AWS::CloudWatch::Alarm", @@ -241,7 +240,6 @@ public class CloudFormationResourceProvisioner { "AWS::EC2::SecurityGroup", "AWS::EC2::Subnet", "AWS::EC2::SubnetRouteTableAssociation", - "AWS::ECR::Repository", "AWS::ECS::Cluster", "AWS::ECS::Service", "AWS::ECS::TaskDefinition", @@ -259,15 +257,11 @@ public class CloudFormationResourceProvisioner { "AWS::IAM::ManagedPolicy", "AWS::IAM::Policy", "AWS::IAM::User", - "AWS::KMS::Alias", - "AWS::KMS::Key", "AWS::Kinesis::Stream", - "AWS::KinesisFirehose::DeliveryStream", "AWS::Lambda::EventSourceMapping", "AWS::Lambda::Function", "AWS::Lambda::LayerVersion", "AWS::Logs::LogGroup", - "AWS::Pipes::Pipe", "AWS::RDS::DBCluster", "AWS::RDS::DBClusterParameterGroup", "AWS::RDS::DBInstance", @@ -281,7 +275,6 @@ public class CloudFormationResourceProvisioner { "AWS::S3::BucketPolicy", "AWS::SNS::Subscription", "AWS::SNS::Topic", - "AWS::SSM::Parameter", "AWS::SecretsManager::Secret", "AWS::SecretsManager::SecretTargetAttachment", "AWS::StepFunctions::StateMachine", @@ -302,14 +295,10 @@ public class CloudFormationResourceProvisioner { private final DynamoDbService dynamoDbService; private final LambdaService lambdaService; private final IamService iamService; - private final SsmService ssmService; - private final KmsService kmsService; private final SecretsManagerService secretsManagerService; private final EventBridgeService eventBridgeService; private final ApiGatewayService apiGatewayService; private final ApiGatewayV2Service apiGatewayV2Service; - private final EcrService ecrService; - private final PipesService pipesService; private final CognitoService cognitoService; private final LambdaLayerService lambdaLayerService; private final ObjectMapper objectMapper; @@ -326,7 +315,6 @@ public class CloudFormationResourceProvisioner { private final KinesisService kinesisService; private final CloudWatchMetricsService cloudWatchMetricsService; private final AutoScalingService autoScalingService; - private final FirehoseService firehoseService; private final DocDbService docDbService; private final CloudFrontService cloudFrontService; // Item 15 decomposition: extracted per-service provisioners are consulted before the switch @@ -375,14 +363,10 @@ public CloudFormationResourceProvisioner(S3Service s3Service, this.dynamoDbService = dynamoDbService; this.lambdaService = lambdaService; this.iamService = iamService; - this.ssmService = ssmService; - this.kmsService = kmsService; this.secretsManagerService = secretsManagerService; this.eventBridgeService = eventBridgeService; this.apiGatewayService = apiGatewayService; this.apiGatewayV2Service = apiGatewayV2Service; - this.ecrService = ecrService; - this.pipesService = pipesService; this.cognitoService = cognitoService; this.lambdaLayerService = lambdaLayerService; this.objectMapper = objectMapper; @@ -399,7 +383,6 @@ public CloudFormationResourceProvisioner(S3Service s3Service, this.kinesisService = kinesisService; this.cloudWatchMetricsService = cloudWatchMetricsService; this.autoScalingService = autoScalingService; - this.firehoseService = firehoseService; this.docDbService = docDbService; this.cloudFrontService = cloudFrontService; this.resourceRegistry = resourceRegistry; @@ -456,15 +439,10 @@ public StackResource provision(String logicalId, String resourceType, JsonNode p case "AWS::IAM::ManagedPolicy" -> provisionIamManagedPolicy(resource, properties, engine, accountId, stackName); case "AWS::IAM::InstanceProfile" -> provisionInstanceProfile(resource, properties, engine, accountId, stackName); - case "AWS::SSM::Parameter" -> provisionSsmParameter(resource, properties, engine, region, stackName); - case "AWS::KMS::Key" -> provisionKmsKey(resource, properties, engine, region, accountId); - case "AWS::KMS::Alias" -> provisionKmsAlias(resource, properties, engine, region); case "AWS::SecretsManager::Secret" -> provisionSecret(resource, properties, engine, region, accountId, stackName); case "AWS::SecretsManager::SecretTargetAttachment" -> provisionSecretTargetAttachment(resource, properties, engine, region, stackName); - case "AWS::CDK::Metadata" -> provisionCdkMetadata(resource); case "AWS::S3::BucketPolicy" -> provisionS3BucketPolicy(resource, properties, engine); - case "AWS::ECR::Repository" -> provisionEcrRepository(resource, properties, engine, stackName, region); case "AWS::Route53::HostedZone" -> provisionRoute53HostedZone(resource, properties, engine); case "AWS::Route53::RecordSet" -> provisionRoute53RecordSet(resource, properties, engine); case "AWS::Events::Rule" -> provisionEventBridgeRule(resource, properties, engine, region, stackName); @@ -482,7 +460,6 @@ public StackResource provision(String logicalId, String resourceType, JsonNode p case "AWS::ApiGatewayV2::Integration" -> provisionApiGatewayV2Integration(resource, properties, engine, region); case "AWS::ApiGatewayV2::Stage" -> provisionApiGatewayV2Stage(resource, properties, engine, region); case "AWS::ApiGatewayV2::Deployment" -> provisionApiGatewayV2Deployment(resource, properties, engine, region); - case "AWS::Pipes::Pipe" -> provisionPipe(resource, properties, engine, region, stackName); case "AWS::StepFunctions::StateMachine" -> provisionStepFunctionsStateMachine( resource, @@ -529,8 +506,6 @@ public StackResource provision(String logicalId, String resourceType, JsonNode p case "AWS::EC2::Route" -> provisionRoute(resource, properties, engine, region); case "AWS::EC2::NatGateway" -> provisionNatGateway(resource, properties, engine, region); case "AWS::EC2::EIP" -> provisionEip(resource, region); - case "AWS::KinesisFirehose::DeliveryStream" -> - provisionFirehoseDeliveryStream(resource, properties, engine, stackName); case "AWS::EC2::Instance" -> provisionEc2Instance(resource, properties, engine, region); // RDS. DBInstance/DBCluster start real RDS containers (same as the direct API). case "AWS::RDS::DBSubnetGroup" -> provisionDbSubnetGroup(resource, properties, engine, stackName, region); @@ -749,10 +724,6 @@ public void delete(String resourceType, String physicalId, String region) { case "AWS::IAM::Policy" -> { } case "AWS::IAM::ManagedPolicy" -> deletePolicySafe(physicalId); case "AWS::IAM::InstanceProfile" -> iamService.deleteInstanceProfile(physicalId); - case "AWS::SSM::Parameter" -> ssmService.deleteParameter(physicalId, region); - case "AWS::KMS::Key" -> { - } // KMS keys can't be immediately deleted; skip - case "AWS::KMS::Alias" -> kmsService.deleteAlias(physicalId, region); case "AWS::SecretsManager::Secret" -> deleteSecretSafe(physicalId, region); case "AWS::SecretsManager::SecretTargetAttachment" -> throw new AwsException( "ValidationError", @@ -764,9 +735,6 @@ public void delete(String resourceType, String physicalId, String region) { case "AWS::Events::EventBusPolicy" -> removeEventBusPolicySafe(physicalId, region); case "AWS::ApiGateway::RestApi" -> apiGatewayService.deleteRestApi(region, physicalId); case "AWS::ApiGatewayV2::Api" -> apiGatewayV2Service.deleteApi(region, physicalId); - case "AWS::ECR::Repository" -> - ecrService.deleteRepository(physicalId, null, true, region); - case "AWS::Pipes::Pipe" -> pipesService.deletePipe(physicalId, region); case "AWS::StepFunctions::StateMachine" -> stepFunctionsService.deleteStateMachine(physicalId); case "AWS::Lambda::EventSourceMapping" -> lambdaService.deleteEventSourceMapping(physicalId); case "AWS::Lambda::LayerVersion" -> deleteLambdaLayerVersion(physicalId, region); @@ -779,7 +747,6 @@ public void delete(String resourceType, String physicalId, String region) { case "AWS::ElasticLoadBalancingV2::TargetGroup" -> elbV2Service.deleteTargetGroup(region, physicalId); case "AWS::ElasticLoadBalancingV2::Listener" -> elbV2Service.deleteListener(region, physicalId); case "AWS::ElasticLoadBalancingV2::ListenerRule" -> elbV2Service.deleteRule(region, physicalId); - case "AWS::KinesisFirehose::DeliveryStream" -> firehoseService.deleteDeliveryStream(physicalId); case "AWS::EC2::SecurityGroup" -> ec2Service.deleteSecurityGroup(region, physicalId); case "AWS::EC2::Instance" -> ec2Service.terminateInstances(region, List.of(physicalId)); case "AWS::RDS::DBInstance" -> rdsService.deleteDbInstance(physicalId, region); @@ -2184,50 +2151,6 @@ private void provisionEksNodegroup(StackResource r, JsonNode props, CloudFormati // ── Kinesis Data Firehose ─────────────────────────────────────────────────── - private void provisionFirehoseDeliveryStream(StackResource r, JsonNode props, - CloudFormationTemplateEngine engine, String stackName) { - String name = resolveOptional(props, "DeliveryStreamName", engine); - if (name == null || name.isBlank()) { - name = generatePhysicalName(stackName, r.getLogicalId(), 64, false); - } - - DeliveryStreamDescription.S3Destination s3 = null; - JsonNode s3Node = props != null && props.has("ExtendedS3DestinationConfiguration") - ? props.get("ExtendedS3DestinationConfiguration") - : (props != null ? props.get("S3DestinationConfiguration") : null); - if (s3Node != null && !s3Node.isNull()) { - s3 = new DeliveryStreamDescription.S3Destination(); - - s3.setCompressionFormat( - blankToNull(engine.resolve(s3Node.path("CompressionFormat"))) - ); - s3.setBucketArn(blankToNull(engine.resolve(s3Node.path("BucketARN")))); - s3.setPrefix(blankToNull(engine.resolve(s3Node.path("Prefix")))); - if (s3Node.has("BufferingHints")) { - JsonNode hints = s3Node.get("BufferingHints"); - var bufferingHints = new DeliveryStreamDescription.BufferingHints(); - bufferingHints.setSizeInMBs(parseIntProp(hints, "SizeInMBs", engine, 5)); - bufferingHints.setIntervalInSeconds(parseIntProp(hints, "IntervalInSeconds", engine, 300)); - s3.setBufferingHints(bufferingHints); - } - } - - List tags = new ArrayList<>(); - if (props != null && props.has("Tags") && props.get("Tags").isArray()) { - for (JsonNode tag : props.get("Tags")) { - String key = engine.resolve(tag.path("Key")); - if (!key.isEmpty()) { - tags.add(new DeliveryStreamDescription.Tag(key, engine.resolve(tag.path("Value")))); - } - } - } - - String arn = firehoseService.createDeliveryStream(name, s3, tags); - // Ref returns the delivery stream name; Fn::GetAtt Arn returns the stream ARN. - r.setPhysicalId(name); - r.getAttributes().put("Arn", arn); - } - // ── SNS ─────────────────────────────────────────────────────────────────── private void provisionSnsTopic(StackResource r, JsonNode props, CloudFormationTemplateEngine engine, @@ -3275,49 +3198,8 @@ private void provisionInstanceProfile(StackResource r, JsonNode props, CloudForm // ── SSM Parameter ───────────────────────────────────────────────────────── - private void provisionSsmParameter(StackResource r, JsonNode props, CloudFormationTemplateEngine engine, - String region, String stackName) { - String name = resolveOptional(props, "Name", engine); - if (name == null || name.isBlank()) { - name = generatePhysicalName(stackName, r.getLogicalId(), 2048, false); - } - String value = resolveOptional(props, "Value", engine); - if (value == null) { - value = ""; - } - String type = resolveOptional(props, "Type", engine); - if (type == null) { - type = "String"; - } - ssmService.putParameter(name, value, type, null, true, region); - r.setPhysicalId(name); - r.getAttributes().put("Name", name); - r.getAttributes().put("Type", type); - r.getAttributes().put("Value", value); - } - // ── KMS ─────────────────────────────────────────────────────────────────── - private void provisionKmsKey(StackResource r, JsonNode props, CloudFormationTemplateEngine engine, - String region, String accountId) { - String description = resolveOptional(props, "Description", engine); - Map tags = parseCfnTags(props != null ? props.get("Tags") : null, engine); - var key = kmsService.createKey(description, null, tags, region); - r.setPhysicalId(key.getKeyId()); - r.getAttributes().put("Arn", key.getArn()); - r.getAttributes().put("KeyId", key.getKeyId()); - } - - private void provisionKmsAlias(StackResource r, JsonNode props, CloudFormationTemplateEngine engine, - String region) { - String aliasName = resolveOptional(props, "AliasName", engine); - String targetKeyId = resolveOptional(props, "TargetKeyId", engine); - if (aliasName != null && targetKeyId != null) { - kmsService.createAlias(aliasName, targetKeyId, region); - } - r.setPhysicalId(aliasName != null ? aliasName : "alias/cfn-" + UUID.randomUUID().toString().substring(0, 8)); - } - // ── Secrets Manager ─────────────────────────────────────────────────────── private void provisionSecret(StackResource r, JsonNode props, CloudFormationTemplateEngine engine, @@ -4473,46 +4355,6 @@ private void provisionLambdaEventSourceMapping(StackResource r, JsonNode props, // ── Pipes ────────────────────────────────────────────────────────────────── - private void provisionPipe(StackResource r, JsonNode props, CloudFormationTemplateEngine engine, - String region, String stackName) { - String name = resolveOptional(props, "Name", engine); - if (name == null || name.isBlank()) { - name = generatePhysicalName(stackName, r.getLogicalId(), 64, false); - } - - String source = resolveOptional(props, "Source", engine); - String target = resolveOptional(props, "Target", engine); - String roleArn = resolveOptional(props, "RoleArn", engine); - String description = resolveOptional(props, "Description", engine); - String enrichment = resolveOptional(props, "Enrichment", engine); - - String stateStr = resolveOptional(props, "DesiredState", engine); - DesiredState desiredState = "STOPPED".equals(stateStr) ? DesiredState.STOPPED : DesiredState.RUNNING; - - JsonNode sourceParameters = null; - if (props != null && props.has("SourceParameters") && !props.get("SourceParameters").isNull()) { - sourceParameters = engine.resolveNode(props.get("SourceParameters")); - } - - JsonNode targetParameters = null; - if (props != null && props.has("TargetParameters") && !props.get("TargetParameters").isNull()) { - targetParameters = engine.resolveNode(props.get("TargetParameters")); - } - - JsonNode enrichmentParameters = null; - if (props != null && props.has("EnrichmentParameters") && !props.get("EnrichmentParameters").isNull()) { - enrichmentParameters = engine.resolveNode(props.get("EnrichmentParameters")); - } - - Map tags = parseCfnTags(props != null ? props.get("Tags") : null, engine); - - var pipe = pipesService.createPipe(name, source, target, roleArn, description, desiredState, - enrichment, sourceParameters, targetParameters, enrichmentParameters, tags, region); - - r.setPhysicalId(name); - r.getAttributes().put("Arn", pipe.getArn()); - } - private void provisionStepFunctionsStateMachine(StackResource r, JsonNode props, CloudFormationTemplateEngine engine, String region, String accountId, @@ -5117,10 +4959,6 @@ private String resolveStateMachineDefinition(JsonNode props, CloudFormationTempl // ── Helpers ─────────────────────────────────────────────────────────────── - private void provisionCdkMetadata(StackResource r) { - r.setPhysicalId("cdk-metadata-" + UUID.randomUUID().toString().substring(0, 8)); - } - private void provisionS3BucketPolicy(StackResource r, JsonNode props, CloudFormationTemplateEngine engine) { r.setPhysicalId("bucket-policy-" + UUID.randomUUID().toString().substring(0, 8)); } @@ -5146,51 +4984,6 @@ private void provisionIamAccessKey(StackResource r, JsonNode props, CloudFormati } } - private void provisionEcrRepository(StackResource r, JsonNode props, CloudFormationTemplateEngine engine, - String stackName, String region) { - String repoName = resolveOptional(props, "RepositoryName", engine); - if (repoName == null || repoName.isBlank()) { - repoName = generatePhysicalName(stackName, r.getLogicalId(), 256, true); - } - // CDK bootstrap requires lower-case repository names; CFN-generated suffixes can include - // upper-case characters. Normalize to satisfy the AWS ECR repository name pattern. - repoName = repoName.toLowerCase(); - - String mutability = resolveOptional(props, "ImageTagMutability", engine); - Map tags = parseCfnTags(props != null ? props.get("Tags") : null, engine); - - Repository repo; - try { - repo = ecrService.createRepository(repoName, null, mutability, null, null, null, tags, region); - } catch (AwsException e) { - if ("RepositoryAlreadyExistsException".equals(e.getErrorCode())) { - repo = ecrService.describeRepositories(List.of(repoName), null, region).get(0); - } else { - throw e; - } - } - - // Lifecycle policy can be inlined as `LifecyclePolicy.LifecyclePolicyText` - if (props != null && props.has("LifecyclePolicy")) { - JsonNode lp = engine.resolveNode(props.get("LifecyclePolicy")); - String policyText = lp.path("LifecyclePolicyText").asText(null); - if (policyText != null && !policyText.isEmpty()) { - ecrService.putLifecyclePolicy(repoName, null, policyText, region); - } - } - if (props != null && props.has("RepositoryPolicyText")) { - JsonNode pol = engine.resolveNode(props.get("RepositoryPolicyText")); - String policyText = pol.isTextual() ? pol.asText() : pol.toString(); - if (policyText != null && !policyText.isEmpty()) { - ecrService.setRepositoryPolicy(repoName, null, policyText, region); - } - } - - r.setPhysicalId(repoName); - r.getAttributes().put("Arn", repo.getRepositoryArn()); - r.getAttributes().put("RepositoryUri", repo.getRepositoryUri()); - } - private Map parseCfnTags(JsonNode tagsNode, CloudFormationTemplateEngine engine) { Map out = new HashMap<>(); if (tagsNode == null || tagsNode.isNull() || !tagsNode.isArray()) { diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CdkMetadataCfnProvisioner.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CdkMetadataCfnProvisioner.java new file mode 100644 index 0000000000..4bd1a408c6 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CdkMetadataCfnProvisioner.java @@ -0,0 +1,27 @@ +package io.github.hectorvent.floci.services.cloudformation.provisioners; + +import com.fasterxml.jackson.databind.JsonNode; +import io.github.hectorvent.floci.services.cloudformation.model.StackResource; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.Set; +import java.util.UUID; + +/** + * Provisions {@code AWS::CDK::Metadata}, the analytics marker the CDK toolchain adds to synthesized + * templates. It backs no service, so provisioning is just a physical id: without one the stack + * would still succeed via the stub path, but with a fake ARN attribute the real type never has. + */ +@ApplicationScoped +public class CdkMetadataCfnProvisioner implements CfnResourceProvisioner { + + @Override + public Set resourceTypes() { + return Set.of("AWS::CDK::Metadata"); + } + + @Override + public void provision(StackResource r, JsonNode props, ProvisionContext ctx) { + r.setPhysicalId("cdk-metadata-" + UUID.randomUUID().toString().substring(0, 8)); + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/EcrCfnProvisioner.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/EcrCfnProvisioner.java new file mode 100644 index 0000000000..79bbfe2e4c --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/EcrCfnProvisioner.java @@ -0,0 +1,98 @@ +package io.github.hectorvent.floci.services.cloudformation.provisioners; + +import com.fasterxml.jackson.databind.JsonNode; +import io.github.hectorvent.floci.core.common.AwsException; +import io.github.hectorvent.floci.services.cloudformation.model.StackResource; +import io.github.hectorvent.floci.services.ecr.EcrService; +import io.github.hectorvent.floci.services.ecr.model.Repository; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Provisions {@code AWS::ECR::Repository}. */ +@ApplicationScoped +public class EcrCfnProvisioner implements CfnResourceProvisioner { + + private static final int REPOSITORY_NAME_MAX_LENGTH = 256; + + private final EcrService ecrService; + + public EcrCfnProvisioner(EcrService ecrService) { + this.ecrService = ecrService; + } + + @Override + public Set resourceTypes() { + return Set.of("AWS::ECR::Repository"); + } + + @Override + public void provision(StackResource r, JsonNode props, ProvisionContext ctx) { + String repoName = ctx.resolveOptional(props, "RepositoryName"); + if (repoName == null || repoName.isBlank()) { + repoName = ctx.generatePhysicalName(r.getLogicalId(), REPOSITORY_NAME_MAX_LENGTH, true); + } + // CDK bootstrap requires lower-case repository names; CFN-generated suffixes can include + // upper-case characters. Normalize to satisfy the AWS ECR repository name pattern. + repoName = repoName.toLowerCase(); + + String mutability = ctx.resolveOptional(props, "ImageTagMutability"); + Map tags = parseCfnTags(props != null ? props.get("Tags") : null, ctx); + + Repository repo; + try { + repo = ecrService.createRepository(repoName, null, mutability, null, null, null, tags, ctx.region()); + } catch (AwsException e) { + if ("RepositoryAlreadyExistsException".equals(e.getErrorCode())) { + repo = ecrService.describeRepositories(List.of(repoName), null, ctx.region()).get(0); + } else { + throw e; + } + } + + // Lifecycle policy can be inlined as `LifecyclePolicy.LifecyclePolicyText` + if (props != null && props.has("LifecyclePolicy")) { + JsonNode lp = ctx.engine().resolveNode(props.get("LifecyclePolicy")); + String policyText = lp.path("LifecyclePolicyText").asText(null); + if (policyText != null && !policyText.isEmpty()) { + ecrService.putLifecyclePolicy(repoName, null, policyText, ctx.region()); + } + } + if (props != null && props.has("RepositoryPolicyText")) { + JsonNode pol = ctx.engine().resolveNode(props.get("RepositoryPolicyText")); + String policyText = pol.isTextual() ? pol.asText() : pol.toString(); + if (policyText != null && !policyText.isEmpty()) { + ecrService.setRepositoryPolicy(repoName, null, policyText, ctx.region()); + } + } + + r.setPhysicalId(repoName); + r.getAttributes().put("Arn", repo.getRepositoryArn()); + r.getAttributes().put("RepositoryUri", repo.getRepositoryUri()); + } + + @Override + public void delete(String resourceType, String physicalId, String region) { + ecrService.deleteRepository(physicalId, null, true, region); + } + + /** See {@code KmsCfnProvisioner#parseCfnTags} for why this is copied rather than shared. */ + private Map parseCfnTags(JsonNode tagsNode, ProvisionContext ctx) { + Map out = new HashMap<>(); + if (tagsNode == null || tagsNode.isNull() || !tagsNode.isArray()) { + return out; + } + for (JsonNode entry : tagsNode) { + JsonNode resolved = ctx.engine().resolveNode(entry); + String key = resolved.path("Key").asText(null); + String value = resolved.path("Value").asText(""); + if (key != null) { + out.put(key, value); + } + } + return out; + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/FirehoseCfnProvisioner.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/FirehoseCfnProvisioner.java new file mode 100644 index 0000000000..b8ef9dbd52 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/FirehoseCfnProvisioner.java @@ -0,0 +1,97 @@ +package io.github.hectorvent.floci.services.cloudformation.provisioners; + +import com.fasterxml.jackson.databind.JsonNode; +import io.github.hectorvent.floci.services.cloudformation.model.StackResource; +import io.github.hectorvent.floci.services.firehose.FirehoseService; +import io.github.hectorvent.floci.services.firehose.model.DeliveryStreamDescription; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** Provisions {@code AWS::KinesisFirehose::DeliveryStream}. */ +@ApplicationScoped +public class FirehoseCfnProvisioner implements CfnResourceProvisioner { + + private static final int DELIVERY_STREAM_NAME_MAX_LENGTH = 64; + private static final int DEFAULT_BUFFER_SIZE_MB = 5; + private static final int DEFAULT_BUFFER_INTERVAL_SECONDS = 300; + + private final FirehoseService firehoseService; + + public FirehoseCfnProvisioner(FirehoseService firehoseService) { + this.firehoseService = firehoseService; + } + + @Override + public Set resourceTypes() { + return Set.of("AWS::KinesisFirehose::DeliveryStream"); + } + + @Override + public void provision(StackResource r, JsonNode props, ProvisionContext ctx) { + String name = ctx.resolveOptional(props, "DeliveryStreamName"); + if (name == null || name.isBlank()) { + name = ctx.generatePhysicalName(r.getLogicalId(), DELIVERY_STREAM_NAME_MAX_LENGTH, false); + } + + DeliveryStreamDescription.S3Destination s3 = null; + JsonNode s3Node = props != null && props.has("ExtendedS3DestinationConfiguration") + ? props.get("ExtendedS3DestinationConfiguration") + : (props != null ? props.get("S3DestinationConfiguration") : null); + if (s3Node != null && !s3Node.isNull()) { + s3 = new DeliveryStreamDescription.S3Destination(); + + s3.setCompressionFormat( + blankToNull(ctx.engine().resolve(s3Node.path("CompressionFormat"))) + ); + s3.setBucketArn(blankToNull(ctx.engine().resolve(s3Node.path("BucketARN")))); + s3.setPrefix(blankToNull(ctx.engine().resolve(s3Node.path("Prefix")))); + if (s3Node.has("BufferingHints")) { + JsonNode hints = s3Node.get("BufferingHints"); + var bufferingHints = new DeliveryStreamDescription.BufferingHints(); + bufferingHints.setSizeInMBs(parseIntProp(hints, "SizeInMBs", ctx, DEFAULT_BUFFER_SIZE_MB)); + bufferingHints.setIntervalInSeconds( + parseIntProp(hints, "IntervalInSeconds", ctx, DEFAULT_BUFFER_INTERVAL_SECONDS)); + s3.setBufferingHints(bufferingHints); + } + } + + List tags = new ArrayList<>(); + if (props != null && props.has("Tags") && props.get("Tags").isArray()) { + for (JsonNode tag : props.get("Tags")) { + String key = ctx.engine().resolve(tag.path("Key")); + if (!key.isEmpty()) { + tags.add(new DeliveryStreamDescription.Tag(key, ctx.engine().resolve(tag.path("Value")))); + } + } + } + + String arn = firehoseService.createDeliveryStream(name, s3, tags); + // Ref returns the delivery stream name; Fn::GetAtt Arn returns the stream ARN. + r.setPhysicalId(name); + r.getAttributes().put("Arn", arn); + } + + @Override + public void delete(String resourceType, String physicalId, String region) { + firehoseService.deleteDeliveryStream(physicalId); + } + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value; + } + + private int parseIntProp(JsonNode props, String name, ProvisionContext ctx, int fallback) { + String value = ctx.resolveOptional(props, name); + if (value == null || value.isBlank()) { + return fallback; + } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return fallback; + } + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/KmsCfnProvisioner.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/KmsCfnProvisioner.java new file mode 100644 index 0000000000..0fb69dc0e7 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/KmsCfnProvisioner.java @@ -0,0 +1,91 @@ +package io.github.hectorvent.floci.services.cloudformation.provisioners; + +import com.fasterxml.jackson.databind.JsonNode; +import io.github.hectorvent.floci.services.cloudformation.model.StackResource; +import io.github.hectorvent.floci.services.kms.KmsService; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** Provisions {@code AWS::KMS::Key} and {@code AWS::KMS::Alias}. */ +@ApplicationScoped +public class KmsCfnProvisioner implements CfnResourceProvisioner { + + private static final String KEY = "AWS::KMS::Key"; + private static final String ALIAS = "AWS::KMS::Alias"; + + private final KmsService kmsService; + + public KmsCfnProvisioner(KmsService kmsService) { + this.kmsService = kmsService; + } + + @Override + public Set resourceTypes() { + return Set.of(KEY, ALIAS); + } + + @Override + public void provision(StackResource r, JsonNode props, ProvisionContext ctx) { + switch (r.getResourceType()) { + case KEY -> provisionKey(r, props, ctx); + case ALIAS -> provisionAlias(r, props, ctx); + default -> throw new IllegalStateException( + "KmsCfnProvisioner cannot provision " + r.getResourceType()); + } + } + + private void provisionKey(StackResource r, JsonNode props, ProvisionContext ctx) { + String description = ctx.resolveOptional(props, "Description"); + Map tags = parseCfnTags(props != null ? props.get("Tags") : null, ctx); + var key = kmsService.createKey(description, null, tags, ctx.region()); + r.setPhysicalId(key.getKeyId()); + r.getAttributes().put("Arn", key.getArn()); + r.getAttributes().put("KeyId", key.getKeyId()); + } + + private void provisionAlias(StackResource r, JsonNode props, ProvisionContext ctx) { + String aliasName = ctx.resolveOptional(props, "AliasName"); + String targetKeyId = ctx.resolveOptional(props, "TargetKeyId"); + if (aliasName != null && targetKeyId != null) { + kmsService.createAlias(aliasName, targetKeyId, ctx.region()); + } + r.setPhysicalId(aliasName != null + ? aliasName + : "alias/cfn-" + UUID.randomUUID().toString().substring(0, 8)); + } + + @Override + public void delete(String resourceType, String physicalId, String region) { + // A KMS key cannot be deleted immediately, only scheduled, so a stack delete leaves it. + if (ALIAS.equals(resourceType)) { + kmsService.deleteAlias(physicalId, region); + } + } + + /** + * Copied from {@code CloudFormationResourceProvisioner} rather than delegating to + * {@link ProvisionContext#resolveTags}, which is not equivalent: it skips a blank key this + * keeps, orders entries by insertion rather than hash, and resolves the whole property so an + * {@code Fn::If} around the list works. Adopting it here is a behaviour change and belongs in + * its own PR; this copy dies when the monolith's last caller migrates. + */ + private Map parseCfnTags(JsonNode tagsNode, ProvisionContext ctx) { + Map out = new HashMap<>(); + if (tagsNode == null || tagsNode.isNull() || !tagsNode.isArray()) { + return out; + } + for (JsonNode entry : tagsNode) { + JsonNode resolved = ctx.engine().resolveNode(entry); + String key = resolved.path("Key").asText(null); + String value = resolved.path("Value").asText(""); + if (key != null) { + out.put(key, value); + } + } + return out; + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/PipesCfnProvisioner.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/PipesCfnProvisioner.java new file mode 100644 index 0000000000..6121b4c195 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/PipesCfnProvisioner.java @@ -0,0 +1,87 @@ +package io.github.hectorvent.floci.services.cloudformation.provisioners; + +import com.fasterxml.jackson.databind.JsonNode; +import io.github.hectorvent.floci.services.cloudformation.model.StackResource; +import io.github.hectorvent.floci.services.pipes.PipesService; +import io.github.hectorvent.floci.services.pipes.model.DesiredState; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** Provisions {@code AWS::Pipes::Pipe}. */ +@ApplicationScoped +public class PipesCfnProvisioner implements CfnResourceProvisioner { + + private static final int PIPE_NAME_MAX_LENGTH = 64; + + private final PipesService pipesService; + + public PipesCfnProvisioner(PipesService pipesService) { + this.pipesService = pipesService; + } + + @Override + public Set resourceTypes() { + return Set.of("AWS::Pipes::Pipe"); + } + + @Override + public void provision(StackResource r, JsonNode props, ProvisionContext ctx) { + String name = ctx.resolveOptional(props, "Name"); + if (name == null || name.isBlank()) { + name = ctx.generatePhysicalName(r.getLogicalId(), PIPE_NAME_MAX_LENGTH, false); + } + + String source = ctx.resolveOptional(props, "Source"); + String target = ctx.resolveOptional(props, "Target"); + String roleArn = ctx.resolveOptional(props, "RoleArn"); + String description = ctx.resolveOptional(props, "Description"); + String enrichment = ctx.resolveOptional(props, "Enrichment"); + + String stateStr = ctx.resolveOptional(props, "DesiredState"); + DesiredState desiredState = "STOPPED".equals(stateStr) ? DesiredState.STOPPED : DesiredState.RUNNING; + + JsonNode sourceParameters = resolvedObject(props, "SourceParameters", ctx); + JsonNode targetParameters = resolvedObject(props, "TargetParameters", ctx); + JsonNode enrichmentParameters = resolvedObject(props, "EnrichmentParameters", ctx); + + Map tags = parseCfnTags(props != null ? props.get("Tags") : null, ctx); + + var pipe = pipesService.createPipe(name, source, target, roleArn, description, desiredState, + enrichment, sourceParameters, targetParameters, enrichmentParameters, tags, ctx.region()); + + r.setPhysicalId(name); + r.getAttributes().put("Arn", pipe.getArn()); + } + + @Override + public void delete(String resourceType, String physicalId, String region) { + pipesService.deletePipe(physicalId, region); + } + + private JsonNode resolvedObject(JsonNode props, String name, ProvisionContext ctx) { + if (props == null || !props.has(name) || props.get(name).isNull()) { + return null; + } + return ctx.engine().resolveNode(props.get(name)); + } + + /** See {@code KmsCfnProvisioner#parseCfnTags} for why this is copied rather than shared. */ + private Map parseCfnTags(JsonNode tagsNode, ProvisionContext ctx) { + Map out = new HashMap<>(); + if (tagsNode == null || tagsNode.isNull() || !tagsNode.isArray()) { + return out; + } + for (JsonNode entry : tagsNode) { + JsonNode resolved = ctx.engine().resolveNode(entry); + String key = resolved.path("Key").asText(null); + String value = resolved.path("Value").asText(""); + if (key != null) { + out.put(key, value); + } + } + return out; + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/SsmCfnProvisioner.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/SsmCfnProvisioner.java new file mode 100644 index 0000000000..b94779274e --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/SsmCfnProvisioner.java @@ -0,0 +1,52 @@ +package io.github.hectorvent.floci.services.cloudformation.provisioners; + +import com.fasterxml.jackson.databind.JsonNode; +import io.github.hectorvent.floci.services.cloudformation.model.StackResource; +import io.github.hectorvent.floci.services.ssm.SsmService; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.Set; + +/** Provisions {@code AWS::SSM::Parameter}. */ +@ApplicationScoped +public class SsmCfnProvisioner implements CfnResourceProvisioner { + + private static final int PARAMETER_NAME_MAX_LENGTH = 2048; + + private final SsmService ssmService; + + public SsmCfnProvisioner(SsmService ssmService) { + this.ssmService = ssmService; + } + + @Override + public Set resourceTypes() { + return Set.of("AWS::SSM::Parameter"); + } + + @Override + public void provision(StackResource r, JsonNode props, ProvisionContext ctx) { + String name = ctx.resolveOptional(props, "Name"); + if (name == null || name.isBlank()) { + name = ctx.generatePhysicalName(r.getLogicalId(), PARAMETER_NAME_MAX_LENGTH, false); + } + String value = ctx.resolveOptional(props, "Value"); + if (value == null) { + value = ""; + } + String type = ctx.resolveOptional(props, "Type"); + if (type == null) { + type = "String"; + } + ssmService.putParameter(name, value, type, null, true, ctx.region()); + r.setPhysicalId(name); + r.getAttributes().put("Name", name); + r.getAttributes().put("Type", type); + r.getAttributes().put("Value", value); + } + + @Override + public void delete(String resourceType, String physicalId, String region) { + ssmService.deleteParameter(physicalId, region); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudformation/provisioners/LeafCfnProvisionerTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudformation/provisioners/LeafCfnProvisionerTest.java new file mode 100644 index 0000000000..e265edf2d4 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cloudformation/provisioners/LeafCfnProvisionerTest.java @@ -0,0 +1,388 @@ +package io.github.hectorvent.floci.services.cloudformation.provisioners; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.hectorvent.floci.core.common.AwsException; +import io.github.hectorvent.floci.services.cloudformation.CloudFormationTemplateEngine; +import io.github.hectorvent.floci.services.cloudformation.model.StackResource; +import io.github.hectorvent.floci.services.ecr.EcrService; +import io.github.hectorvent.floci.services.ecr.model.Repository; +import io.github.hectorvent.floci.services.firehose.FirehoseService; +import io.github.hectorvent.floci.services.firehose.model.DeliveryStreamDescription; +import io.github.hectorvent.floci.services.kms.KmsService; +import io.github.hectorvent.floci.services.kms.model.KmsKey; +import io.github.hectorvent.floci.services.pipes.PipesService; +import io.github.hectorvent.floci.services.pipes.model.DesiredState; +import io.github.hectorvent.floci.services.pipes.model.Pipe; +import io.github.hectorvent.floci.services.ssm.SsmService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * The single-service CloudFormation provisioners extracted from + * {@code CloudFormationResourceProvisioner} together, since each is a handful of lines with one + * service call. + * + *

Every case asserts the exact physical id and the exact {@code Fn::GetAtt} attribute keys. + * Asserting status alone would prove nothing: an unmapped type still reports CREATE_COMPLETE via + * the dispatcher's stub arm, with a synthetic id and a fake {@code Arn}. + */ +class LeafCfnProvisionerTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String REGION = "us-east-1"; + + private CloudFormationTemplateEngine engine; + private ProvisionContext ctx; + + @BeforeEach + void setUp() { + engine = mock(CloudFormationTemplateEngine.class); + when(engine.resolve(any())).thenAnswer(i -> { + JsonNode node = i.getArgument(0); + return node == null || node.isMissingNode() || node.isNull() ? null : node.asText(); + }); + when(engine.resolveNode(any())).thenAnswer(i -> i.getArgument(0)); + ctx = new ProvisionContext(engine, REGION, "000000000000", "my-stack"); + } + + private static JsonNode props(String json) { + try { + return MAPPER.readTree(json); + } catch (Exception e) { + throw new IllegalArgumentException(e); + } + } + + private static StackResource resource(String logicalId, String type) { + StackResource r = new StackResource(); + r.setLogicalId(logicalId); + r.setResourceType(type); + return r; + } + + @Nested + class Kms { + + private final KmsService kms = mock(KmsService.class); + private final KmsCfnProvisioner provisioner = new KmsCfnProvisioner(kms); + + @Test + void keyRefIsTheKeyIdAndGetAttExposesArnAndKeyId() { + KmsKey key = new KmsKey(); + key.setKeyId("k-123"); + key.setArn("arn:aws:kms:us-east-1:000000000000:key/k-123"); + when(kms.createKey(eq("a key"), eq(null), any(), eq(REGION))).thenReturn(key); + + StackResource r = resource("Key", "AWS::KMS::Key"); + provisioner.provision(r, props(""" + {"Description": "a key", "Tags": [{"Key": "env", "Value": "dev"}]} + """), ctx); + + assertEquals("k-123", r.getPhysicalId(), "Ref is the key id (schema primaryIdentifier)"); + // aws-kms-key.json readOnlyProperties + assertEquals(Map.of("Arn", "arn:aws:kms:us-east-1:000000000000:key/k-123", "KeyId", "k-123"), + r.getAttributes()); + } + + @Test + void keyTagsReachTheService() { + KmsKey key = new KmsKey(); + key.setKeyId("k-1"); + ArgumentCaptor> tags = ArgumentCaptor.forClass(Map.class); + when(kms.createKey(any(), any(), tags.capture(), anyString())).thenReturn(key); + + provisioner.provision(resource("Key", "AWS::KMS::Key"), props(""" + {"Tags": [{"Key": "team", "Value": "core"}]} + """), ctx); + + assertEquals(Map.of("team", "core"), tags.getValue()); + } + + @Test + void aliasRefIsTheAliasName() { + StackResource r = resource("Alias", "AWS::KMS::Alias"); + provisioner.provision(r, props(""" + {"AliasName": "alias/app", "TargetKeyId": "k-123"} + """), ctx); + + verify(kms).createAlias("alias/app", "k-123", REGION); + assertEquals("alias/app", r.getPhysicalId()); + } + + @Test + void aliasWithoutATargetIsNotCreatedButStillGetsAPhysicalId() { + StackResource r = resource("Alias", "AWS::KMS::Alias"); + provisioner.provision(r, props(""" + {"AliasName": "alias/app"} + """), ctx); + + verify(kms, never()).createAlias(anyString(), anyString(), anyString()); + assertEquals("alias/app", r.getPhysicalId()); + } + + @Test + void deletingAKeyIsANoOpBecauseKmsOnlySchedulesDeletion() { + provisioner.delete("AWS::KMS::Key", "k-123", REGION); + verify(kms, never()).deleteAlias(anyString(), anyString()); + } + + @Test + void deletingAnAliasReachesTheService() { + provisioner.delete("AWS::KMS::Alias", "alias/app", REGION); + verify(kms).deleteAlias("alias/app", REGION); + } + } + + @Nested + class Ssm { + + private final SsmService ssm = mock(SsmService.class); + private final SsmCfnProvisioner provisioner = new SsmCfnProvisioner(ssm); + + @Test + void refIsTheParameterNameAndAttributesEchoTheStoredValue() { + StackResource r = resource("Param", "AWS::SSM::Parameter"); + provisioner.provision(r, props(""" + {"Name": "/app/db", "Value": "secret", "Type": "String"} + """), ctx); + + verify(ssm).putParameter("/app/db", "secret", "String", null, true, REGION); + assertEquals("/app/db", r.getPhysicalId()); + assertEquals(Map.of("Name", "/app/db", "Type", "String", "Value", "secret"), r.getAttributes()); + } + + @Test + void anUnnamedParameterGetsAGeneratedStackScopedName() { + StackResource r = resource("Param", "AWS::SSM::Parameter"); + provisioner.provision(r, props("{}"), ctx); + + assertTrue(r.getPhysicalId().startsWith("my-stack-Param-"), + "generated names stay stack-scoped: " + r.getPhysicalId()); + // Absent Value and Type fall back rather than reaching the service as null. + verify(ssm).putParameter(anyString(), eq(""), eq("String"), eq(null), eq(true), eq(REGION)); + } + + @Test + void deleteReachesTheService() { + provisioner.delete("AWS::SSM::Parameter", "/app/db", REGION); + verify(ssm).deleteParameter("/app/db", REGION); + } + } + + @Nested + class Ecr { + + private final EcrService ecr = mock(EcrService.class); + private final EcrCfnProvisioner provisioner = new EcrCfnProvisioner(ecr); + + private Repository repo(String arn, String uri) { + Repository repository = new Repository(); + repository.setRepositoryArn(arn); + repository.setRepositoryUri(uri); + return repository; + } + + @Test + void refIsTheRepositoryNameAndGetAttExposesArnAndUri() { + when(ecr.createRepository(eq("app"), any(), any(), any(), any(), any(), any(), eq(REGION))) + .thenReturn(repo("arn:aws:ecr:us-east-1:000000000000:repository/app", "localhost/app")); + + StackResource r = resource("Repo", "AWS::ECR::Repository"); + provisioner.provision(r, props(""" + {"RepositoryName": "app"} + """), ctx); + + assertEquals("app", r.getPhysicalId()); + // aws-ecr-repository.json readOnlyProperties + assertEquals(Map.of("Arn", "arn:aws:ecr:us-east-1:000000000000:repository/app", + "RepositoryUri", "localhost/app"), r.getAttributes()); + } + + @Test + void repositoryNamesAreLowerCasedForCdkBootstrap() { + when(ecr.createRepository(anyString(), any(), any(), any(), any(), any(), any(), anyString())) + .thenReturn(repo("arn", "uri")); + + StackResource r = resource("Repo", "AWS::ECR::Repository"); + provisioner.provision(r, props(""" + {"RepositoryName": "MyApp"} + """), ctx); + + assertEquals("myapp", r.getPhysicalId()); + } + + @Test + void anExistingRepositoryIsAdoptedRatherThanFailing() { + when(ecr.createRepository(anyString(), any(), any(), any(), any(), any(), any(), anyString())) + .thenThrow(new AwsException("RepositoryAlreadyExistsException", "exists", 400)); + when(ecr.describeRepositories(List.of("app"), null, REGION)) + .thenReturn(List.of(repo("arn:existing", "uri:existing"))); + + StackResource r = resource("Repo", "AWS::ECR::Repository"); + provisioner.provision(r, props(""" + {"RepositoryName": "app"} + """), ctx); + + assertEquals("arn:existing", r.getAttributes().get("Arn")); + } + + @Test + void inlinePoliciesAreApplied() { + when(ecr.createRepository(anyString(), any(), any(), any(), any(), any(), any(), anyString())) + .thenReturn(repo("arn", "uri")); + + provisioner.provision(resource("Repo", "AWS::ECR::Repository"), props(""" + { + "RepositoryName": "app", + "LifecyclePolicy": {"LifecyclePolicyText": "{\\"rules\\":[]}"}, + "RepositoryPolicyText": "{\\"Version\\":\\"2012-10-17\\"}" + } + """), ctx); + + verify(ecr).putLifecyclePolicy("app", null, "{\"rules\":[]}", REGION); + verify(ecr).setRepositoryPolicy("app", null, "{\"Version\":\"2012-10-17\"}", REGION); + } + + @Test + void deleteForcesRemovalSoANonEmptyRepositoryStillGoes() { + provisioner.delete("AWS::ECR::Repository", "app", REGION); + verify(ecr).deleteRepository("app", null, true, REGION); + } + } + + @Nested + class Pipes { + + private final PipesService pipes = mock(PipesService.class); + private final PipesCfnProvisioner provisioner = new PipesCfnProvisioner(pipes); + + @Test + void refIsThePipeNameAndGetAttExposesArn() { + Pipe pipe = new Pipe(); + pipe.setArn("arn:aws:pipes:us-east-1:000000000000:pipe/p"); + when(pipes.createPipe(eq("p"), eq("src"), eq("tgt"), eq("role"), any(), + eq(DesiredState.RUNNING), any(), any(), any(), any(), any(), eq(REGION))) + .thenReturn(pipe); + + StackResource r = resource("Pipe", "AWS::Pipes::Pipe"); + provisioner.provision(r, props(""" + {"Name": "p", "Source": "src", "Target": "tgt", "RoleArn": "role"} + """), ctx); + + assertEquals("p", r.getPhysicalId()); + assertEquals("arn:aws:pipes:us-east-1:000000000000:pipe/p", r.getAttributes().get("Arn")); + } + + @Test + void desiredStateStoppedIsHonouredAndAnythingElseRuns() { + Pipe pipe = new Pipe(); + when(pipes.createPipe(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), + any(), any())).thenReturn(pipe); + + provisioner.provision(resource("Pipe", "AWS::Pipes::Pipe"), props(""" + {"Name": "p", "DesiredState": "STOPPED"} + """), ctx); + verify(pipes).createPipe(any(), any(), any(), any(), any(), eq(DesiredState.STOPPED), + any(), any(), any(), any(), any(), any()); + } + + @Test + void deleteReachesTheService() { + provisioner.delete("AWS::Pipes::Pipe", "p", REGION); + verify(pipes).deletePipe("p", REGION); + } + } + + @Nested + class Firehose { + + private final FirehoseService firehose = mock(FirehoseService.class); + private final FirehoseCfnProvisioner provisioner = new FirehoseCfnProvisioner(firehose); + + @Test + void refIsTheStreamNameAndGetAttExposesArn() { + when(firehose.createDeliveryStream(eq("s"), any(), any())).thenReturn("arn:stream"); + + StackResource r = resource("Stream", "AWS::KinesisFirehose::DeliveryStream"); + provisioner.provision(r, props(""" + {"DeliveryStreamName": "s"} + """), ctx); + + assertEquals("s", r.getPhysicalId()); + assertEquals(Map.of("Arn", "arn:stream"), r.getAttributes()); + } + + @Test + void bufferingHintsFallBackToTheAwsDefaults() { + ArgumentCaptor s3 = + ArgumentCaptor.forClass(DeliveryStreamDescription.S3Destination.class); + when(firehose.createDeliveryStream(anyString(), s3.capture(), any())).thenReturn("arn"); + + provisioner.provision(resource("Stream", "AWS::KinesisFirehose::DeliveryStream"), props(""" + { + "DeliveryStreamName": "s", + "S3DestinationConfiguration": { + "BucketARN": "arn:bucket", + "BufferingHints": {"SizeInMBs": "7"} + } + } + """), ctx); + + assertEquals(7, s3.getValue().getBufferingHints().getSizeInMBs()); + assertEquals(300, s3.getValue().getBufferingHints().getIntervalInSeconds(), + "an omitted interval falls back to the AWS default, not zero"); + } + + @Test + void blankDestinationFieldsBecomeNullRatherThanEmptyStrings() { + ArgumentCaptor s3 = + ArgumentCaptor.forClass(DeliveryStreamDescription.S3Destination.class); + when(firehose.createDeliveryStream(anyString(), s3.capture(), any())).thenReturn("arn"); + + provisioner.provision(resource("Stream", "AWS::KinesisFirehose::DeliveryStream"), props(""" + {"DeliveryStreamName": "s", "S3DestinationConfiguration": {"Prefix": ""}} + """), ctx); + + assertNull(s3.getValue().getPrefix()); + } + + @Test + void deleteReachesTheService() { + provisioner.delete("AWS::KinesisFirehose::DeliveryStream", "s", REGION); + verify(firehose).deleteDeliveryStream("s"); + } + } + + @Nested + class CdkMetadata { + + private final CdkMetadataCfnProvisioner provisioner = new CdkMetadataCfnProvisioner(); + + @Test + void getsAPhysicalIdAndNoAttributes() { + StackResource r = resource("CDKMetadata", "AWS::CDK::Metadata"); + provisioner.provision(r, props("{}"), ctx); + + assertTrue(r.getPhysicalId().startsWith("cdk-metadata-"), r.getPhysicalId()); + // The stub arm would have added a fake Arn here; the real type has no attributes. + assertTrue(r.getAttributes().isEmpty(), "CDK::Metadata exposes no Fn::GetAtt attributes"); + } + } +} diff --git a/src/test/resources/cloudformation/supported-resource-types.tsv b/src/test/resources/cloudformation/supported-resource-types.tsv index 58a30b7cf9..c75d1dcae5 100644 --- a/src/test/resources/cloudformation/supported-resource-types.tsv +++ b/src/test/resources/cloudformation/supported-resource-types.tsv @@ -17,7 +17,7 @@ AWS::AutoScaling::LifecycleHook AutoScalingLifecycleHookCfnProvisioner AWS::Batch::ComputeEnvironment LEGACY_SWITCH AWS::Batch::JobDefinition LEGACY_SWITCH AWS::Batch::JobQueue LEGACY_SWITCH -AWS::CDK::Metadata LEGACY_SWITCH +AWS::CDK::Metadata CdkMetadataCfnProvisioner AWS::CloudFormation::CustomResource LEGACY_SWITCH AWS::CloudFront::Distribution LEGACY_SWITCH AWS::CloudWatch::Alarm LEGACY_SWITCH @@ -49,7 +49,7 @@ AWS::EC2::SubnetRouteTableAssociation LEGACY_SWITCH AWS::EC2::VPC Ec2VpcCfnProvisioner AWS::EC2::VPCEndpoint Ec2VpcEndpointCfnProvisioner AWS::EC2::VPCGatewayAttachment Ec2VpcGatewayAttachmentCfnProvisioner -AWS::ECR::Repository LEGACY_SWITCH +AWS::ECR::Repository EcrCfnProvisioner AWS::ECS::CapacityProvider EcsCapacityCfnProvisioner AWS::ECS::Cluster LEGACY_SWITCH AWS::ECS::ClusterCapacityProviderAssociations EcsCapacityCfnProvisioner @@ -70,10 +70,10 @@ AWS::IAM::ManagedPolicy LEGACY_SWITCH AWS::IAM::Policy LEGACY_SWITCH AWS::IAM::Role IamRoleCfnProvisioner AWS::IAM::User LEGACY_SWITCH -AWS::KMS::Alias LEGACY_SWITCH -AWS::KMS::Key LEGACY_SWITCH +AWS::KMS::Alias KmsCfnProvisioner +AWS::KMS::Key KmsCfnProvisioner AWS::Kinesis::Stream LEGACY_SWITCH -AWS::KinesisFirehose::DeliveryStream LEGACY_SWITCH +AWS::KinesisFirehose::DeliveryStream FirehoseCfnProvisioner AWS::Lambda::Alias LambdaVersionAliasCfnProvisioner AWS::Lambda::EventSourceMapping LEGACY_SWITCH AWS::Lambda::Function LEGACY_SWITCH @@ -88,7 +88,7 @@ AWS::Organizations::Organization OrganizationsCfnProvisioner AWS::Organizations::OrganizationalUnit OrganizationsCfnProvisioner AWS::Organizations::Policy OrganizationsCfnProvisioner AWS::Organizations::ResourcePolicy OrganizationsCfnProvisioner -AWS::Pipes::Pipe LEGACY_SWITCH +AWS::Pipes::Pipe PipesCfnProvisioner AWS::RDS::DBCluster LEGACY_SWITCH AWS::RDS::DBClusterParameterGroup LEGACY_SWITCH AWS::RDS::DBInstance LEGACY_SWITCH @@ -104,7 +104,7 @@ AWS::SNS::Subscription LEGACY_SWITCH AWS::SNS::Topic LEGACY_SWITCH AWS::SQS::Queue SqsCfnProvisioner AWS::SQS::QueuePolicy SqsCfnProvisioner -AWS::SSM::Parameter LEGACY_SWITCH +AWS::SSM::Parameter SsmCfnProvisioner AWS::SecretsManager::Secret LEGACY_SWITCH AWS::SecretsManager::SecretTargetAttachment LEGACY_SWITCH AWS::StepFunctions::StateMachine LEGACY_SWITCH