diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 5883e99..de9c5ae 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -18,10 +18,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - name: Set up JDK 1.8 - uses: actions/setup-java@v1 + - name: Set up JDK 17 + uses: actions/setup-java@v2 with: - java-version: 1.8 + java-version: 17 - name: Set up Python 3.8 uses: actions/setup-python@v2 with: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d40904d --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# macOS +.DS_Store +._* + +# Maven outputs +.classpath + +# IntelliJ +*.iml +.idea +out.java +out/ +.settings +.project + +# Auto-generated files +target/ +build +.gradle +gradle +gradlew +gradlew.bat +wrapper +*.zip +# Allow CustomPlugin ZIP file to be uplaoded. +!*/contract-tests-artifacts/**/*.zip +.factorypath + +# Our logs +rpdk.log* + +# Contains credentials +sam-tests/ + +# CFN Contract Tests V1 outputs +.hypothesis + +# VS Code +.vscode/ \ No newline at end of file diff --git a/aws-kafkaconnect-connector/.rpdk-config b/aws-kafkaconnect-connector/.rpdk-config index f8435d6..0b687d9 100644 --- a/aws-kafkaconnect-connector/.rpdk-config +++ b/aws-kafkaconnect-connector/.rpdk-config @@ -2,7 +2,7 @@ "artifact_type": "RESOURCE", "typeName": "AWS::KafkaConnect::Connector", "language": "java", - "runtime": "java8", + "runtime": "java17", "entrypoint": "software.amazon.kafkaconnect.connector.HandlerWrapper::handleRequest", "testEntrypoint": "software.amazon.kafkaconnect.connector.HandlerWrapper::testEntrypoint", "settings": { @@ -21,5 +21,6 @@ "codegen_template_path": "guided_aws", "protocolVersion": "2.0.0" }, + "logProcessorEnabled": "true", "executableEntrypoint": "software.amazon.kafkaconnect.connector.HandlerWrapperExecutable" } diff --git a/aws-kafkaconnect-connector/aws-kafkaconnect-connector.json b/aws-kafkaconnect-connector/aws-kafkaconnect-connector.json index 73f1ef4..abaeaf4 100644 --- a/aws-kafkaconnect-connector/aws-kafkaconnect-connector.json +++ b/aws-kafkaconnect-connector/aws-kafkaconnect-connector.json @@ -2,7 +2,13 @@ "typeName": "AWS::KafkaConnect::Connector", "description": "Resource Type definition for AWS::KafkaConnect::Connector", "additionalProperties": false, - "taggable": false, + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-kafkaconnect.git", "properties": { "Capacity": { @@ -65,6 +71,15 @@ "type": "string", "pattern": "arn:(aws|aws-us-gov|aws-cn):iam:.*" }, + "Tags": { + "description": "A collection of tags associated with a resource", + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, "WorkerConfiguration": { "$ref": "#/definitions/WorkerConfiguration" } @@ -368,6 +383,25 @@ "CpuUtilizationPercentage" ] }, + "Tag": { + "type": "object", + "properties": { + "Key": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "type": "string", + "maxLength": 256 + } + }, + "required": [ + "Value", + "Key" + ], + "additionalProperties": false + }, "Vpc": { "description": "Information about a VPC used with the connector.", "type": "object", @@ -451,6 +485,11 @@ "primaryIdentifier": [ "/properties/ConnectorArn" ], + "additionalIdentifiers": [ + [ + "/properties/ConnectorName" + ] + ], "readOnlyProperties": [ "/properties/ConnectorArn" ], @@ -473,6 +512,8 @@ "permissions": [ "kafkaconnect:CreateConnector", "kafkaconnect:DescribeConnector", + "kafkaconnect:TagResource", + "kafkaconnect:ListTagsForResource", "iam:CreateServiceLinkedRole", "iam:PassRole", "ec2:CreateNetworkInterface", @@ -492,7 +533,8 @@ }, "read": { "permissions": [ - "kafkaconnect:DescribeConnector" + "kafkaconnect:DescribeConnector", + "kafkaconnect:ListTagsForResource" ] }, "delete": { @@ -508,6 +550,9 @@ "permissions": [ "kafkaconnect:UpdateConnector", "kafkaconnect:DescribeConnector", + "kafkaconnect:TagResource", + "kafkaconnect:ListTagsForResource", + "kafkaconnect:UntagResource", "iam:CreateServiceLinkedRole", "logs:UpdateLogDelivery", "logs:GetLogDelivery", @@ -526,4 +571,4 @@ ] } } -} +} \ No newline at end of file diff --git a/aws-kafkaconnect-connector/checkstyle.xml b/aws-kafkaconnect-connector/checkstyle.xml new file mode 100644 index 0000000..631567a --- /dev/null +++ b/aws-kafkaconnect-connector/checkstyle.xml @@ -0,0 +1,360 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/aws-kafkaconnect-connector/docs/README.md b/aws-kafkaconnect-connector/docs/README.md new file mode 100644 index 0000000..4f1aa3a --- /dev/null +++ b/aws-kafkaconnect-connector/docs/README.md @@ -0,0 +1,209 @@ +# AWS::KafkaConnect::Connector + +Resource Type definition for AWS::KafkaConnect::Connector + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Type" : "AWS::KafkaConnect::Connector",
+    "Properties" : {
+        "Capacity" : Capacity,
+        "ConnectorConfiguration" : ConnectorConfiguration,
+        "ConnectorDescription" : String,
+        "ConnectorName" : String,
+        "KafkaCluster" : KafkaCluster,
+        "KafkaClusterClientAuthentication" : KafkaClusterClientAuthentication,
+        "KafkaClusterEncryptionInTransit" : KafkaClusterEncryptionInTransit,
+        "KafkaConnectVersion" : String,
+        "LogDelivery" : LogDelivery,
+        "Plugins" : [ Plugin, ... ],
+        "ServiceExecutionRoleArn" : String,
+        "Tags" : [ Tag, ... ],
+        "WorkerConfiguration" : WorkerConfiguration
+    }
+}
+
+ +### YAML + +
+Type: AWS::KafkaConnect::Connector
+Properties:
+    Capacity: Capacity
+    ConnectorConfiguration: ConnectorConfiguration
+    ConnectorDescription: String
+    ConnectorName: String
+    KafkaCluster: KafkaCluster
+    KafkaClusterClientAuthentication: KafkaClusterClientAuthentication
+    KafkaClusterEncryptionInTransit: KafkaClusterEncryptionInTransit
+    KafkaConnectVersion: String
+    LogDelivery: LogDelivery
+    Plugins: 
+      - Plugin
+    ServiceExecutionRoleArn: String
+    Tags: 
+      - Tag
+    WorkerConfiguration: WorkerConfiguration
+
+ +## Properties + +#### Capacity + +Information about the capacity allocated to the connector. + +_Required_: Yes + +_Type_: Capacity + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### ConnectorConfiguration + +The configuration for the connector. + +_Required_: Yes + +_Type_: ConnectorConfiguration + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### ConnectorDescription + +A summary description of the connector. + +_Required_: No + +_Type_: String + +_Maximum Length_: 1024 + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### ConnectorName + +The name of the connector. + +_Required_: Yes + +_Type_: String + +_Minimum Length_: 1 + +_Maximum Length_: 128 + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### KafkaCluster + +Details of how to connect to the Kafka cluster. + +_Required_: Yes + +_Type_: KafkaCluster + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### KafkaClusterClientAuthentication + +Details of the client authentication used by the Kafka cluster. + +_Required_: Yes + +_Type_: KafkaClusterClientAuthentication + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### KafkaClusterEncryptionInTransit + +Details of encryption in transit to the Kafka cluster. + +_Required_: Yes + +_Type_: KafkaClusterEncryptionInTransit + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### KafkaConnectVersion + +The version of Kafka Connect. It has to be compatible with both the Kafka cluster's version and the plugins. + +_Required_: Yes + +_Type_: String + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### LogDelivery + +Details of what logs are delivered and where they are delivered. + +_Required_: No + +_Type_: LogDelivery + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### Plugins + +List of plugins to use with the connector. + +_Required_: Yes + +_Type_: List of Plugin + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### ServiceExecutionRoleArn + +The Amazon Resource Name (ARN) of the IAM role used by the connector to access Amazon S3 objects and other external resources. + +_Required_: Yes + +_Type_: String + +_Pattern_: arn:(aws|aws-us-gov|aws-cn):iam:.* + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### Tags + +A collection of tags associated with a resource + +_Required_: No + +_Type_: List of Tag + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### WorkerConfiguration + +Specifies the worker configuration to use with the connector. + +_Required_: No + +_Type_: WorkerConfiguration + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +## Return Values + +### Ref + +When you pass the logical ID of this resource to the intrinsic `Ref` function, Ref returns the ConnectorArn. + +### Fn::GetAtt + +The `Fn::GetAtt` intrinsic function returns a value for a specified attribute of this type. The following are the available attributes and sample return values. + +For more information about using the `Fn::GetAtt` intrinsic function, see [Fn::GetAtt](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html). + +#### ConnectorArn + +Amazon Resource Name for the created Connector. + diff --git a/aws-kafkaconnect-connector/docs/apachekafkacluster.md b/aws-kafkaconnect-connector/docs/apachekafkacluster.md new file mode 100644 index 0000000..d26a29e --- /dev/null +++ b/aws-kafkaconnect-connector/docs/apachekafkacluster.md @@ -0,0 +1,46 @@ +# AWS::KafkaConnect::Connector ApacheKafkaCluster + +Details of how to connect to an Apache Kafka cluster. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "BootstrapServers" : String,
+    "Vpc" : Vpc
+}
+
+ +### YAML + +
+BootstrapServers: String
+Vpc: Vpc
+
+ +## Properties + +#### BootstrapServers + +The bootstrap servers string of the Apache Kafka cluster. + +_Required_: Yes + +_Type_: String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Vpc + +Information about a VPC used with the connector. + +_Required_: Yes + +_Type_: Vpc + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/autoscaling.md b/aws-kafkaconnect-connector/docs/autoscaling.md new file mode 100644 index 0000000..91da14d --- /dev/null +++ b/aws-kafkaconnect-connector/docs/autoscaling.md @@ -0,0 +1,84 @@ +# AWS::KafkaConnect::Connector AutoScaling + +Details about auto scaling of a connector. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "MaxWorkerCount" : Integer,
+    "MinWorkerCount" : Integer,
+    "ScaleInPolicy" : ScaleInPolicy,
+    "ScaleOutPolicy" : ScaleOutPolicy,
+    "McuCount" : Integer
+}
+
+ +### YAML + +
+MaxWorkerCount: Integer
+MinWorkerCount: Integer
+ScaleInPolicy: ScaleInPolicy
+ScaleOutPolicy: ScaleOutPolicy
+McuCount: Integer
+
+ +## Properties + +#### MaxWorkerCount + +The maximum number of workers for a connector. + +_Required_: Yes + +_Type_: Integer + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### MinWorkerCount + +The minimum number of workers for a connector. + +_Required_: Yes + +_Type_: Integer + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### ScaleInPolicy + +Information about the scale in policy of the connector. + +_Required_: Yes + +_Type_: ScaleInPolicy + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### ScaleOutPolicy + +Information about the scale out policy of the connector. + +_Required_: Yes + +_Type_: ScaleOutPolicy + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### McuCount + +Specifies how many MSK Connect Units (MCU) as the minimum scaling unit. + +_Required_: Yes + +_Type_: Integer + +_Allowed Values_: 1 | 2 | 4 | 8 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/capacity.md b/aws-kafkaconnect-connector/docs/capacity.md new file mode 100644 index 0000000..9190b56 --- /dev/null +++ b/aws-kafkaconnect-connector/docs/capacity.md @@ -0,0 +1,46 @@ +# AWS::KafkaConnect::Connector Capacity + +Information about the capacity allocated to the connector. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "AutoScaling" : AutoScaling,
+    "ProvisionedCapacity" : ProvisionedCapacity
+}
+
+ +### YAML + +
+AutoScaling: AutoScaling
+ProvisionedCapacity: ProvisionedCapacity
+
+ +## Properties + +#### AutoScaling + +Details about auto scaling of a connector. + +_Required_: Yes + +_Type_: AutoScaling + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### ProvisionedCapacity + +Details about a fixed capacity allocated to a connector. + +_Required_: Yes + +_Type_: ProvisionedCapacity + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/cloudwatchlogslogdelivery.md b/aws-kafkaconnect-connector/docs/cloudwatchlogslogdelivery.md new file mode 100644 index 0000000..dc3dadc --- /dev/null +++ b/aws-kafkaconnect-connector/docs/cloudwatchlogslogdelivery.md @@ -0,0 +1,46 @@ +# AWS::KafkaConnect::Connector CloudWatchLogsLogDelivery + +Details about delivering logs to Amazon CloudWatch Logs. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Enabled" : Boolean,
+    "LogGroup" : String
+}
+
+ +### YAML + +
+Enabled: Boolean
+LogGroup: String
+
+ +## Properties + +#### Enabled + +Specifies whether the logs get sent to the specified CloudWatch Logs destination. + +_Required_: Yes + +_Type_: Boolean + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### LogGroup + +The CloudWatch log group that is the destination for log delivery. + +_Required_: No + +_Type_: String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/connectorconfiguration.md b/aws-kafkaconnect-connector/docs/connectorconfiguration.md new file mode 100644 index 0000000..f1fd83b --- /dev/null +++ b/aws-kafkaconnect-connector/docs/connectorconfiguration.md @@ -0,0 +1,32 @@ +# AWS::KafkaConnect::Connector ConnectorConfiguration + +The configuration for the connector. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    ".*" : String
+}
+
+ +### YAML + +
+.*: String
+
+ +## Properties + +#### \.* + +_Required_: No + +_Type_: String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/customplugin.md b/aws-kafkaconnect-connector/docs/customplugin.md new file mode 100644 index 0000000..f7c6b4d --- /dev/null +++ b/aws-kafkaconnect-connector/docs/customplugin.md @@ -0,0 +1,48 @@ +# AWS::KafkaConnect::Connector CustomPlugin + +Details about a custom plugin. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "CustomPluginArn" : String,
+    "Revision" : Integer
+}
+
+ +### YAML + +
+CustomPluginArn: String
+Revision: Integer
+
+ +## Properties + +#### CustomPluginArn + +The Amazon Resource Name (ARN) of the custom plugin to use. + +_Required_: Yes + +_Type_: String + +_Pattern_: arn:(aws|aws-us-gov|aws-cn):kafkaconnect:.* + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Revision + +The revision of the custom plugin to use. + +_Required_: Yes + +_Type_: Integer + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/firehoselogdelivery.md b/aws-kafkaconnect-connector/docs/firehoselogdelivery.md new file mode 100644 index 0000000..8d3643d --- /dev/null +++ b/aws-kafkaconnect-connector/docs/firehoselogdelivery.md @@ -0,0 +1,46 @@ +# AWS::KafkaConnect::Connector FirehoseLogDelivery + +Details about delivering logs to Amazon Kinesis Data Firehose. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "DeliveryStream" : String,
+    "Enabled" : Boolean
+}
+
+ +### YAML + +
+DeliveryStream: String
+Enabled: Boolean
+
+ +## Properties + +#### DeliveryStream + +The Kinesis Data Firehose delivery stream that is the destination for log delivery. + +_Required_: No + +_Type_: String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Enabled + +Specifies whether the logs get sent to the specified Kinesis Data Firehose delivery stream. + +_Required_: Yes + +_Type_: Boolean + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/kafkacluster.md b/aws-kafkaconnect-connector/docs/kafkacluster.md new file mode 100644 index 0000000..72bf7d8 --- /dev/null +++ b/aws-kafkaconnect-connector/docs/kafkacluster.md @@ -0,0 +1,34 @@ +# AWS::KafkaConnect::Connector KafkaCluster + +Details of how to connect to the Kafka cluster. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "ApacheKafkaCluster" : ApacheKafkaCluster
+}
+
+ +### YAML + +
+ApacheKafkaCluster: ApacheKafkaCluster
+
+ +## Properties + +#### ApacheKafkaCluster + +Details of how to connect to an Apache Kafka cluster. + +_Required_: Yes + +_Type_: ApacheKafkaCluster + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/kafkaclusterclientauthentication.md b/aws-kafkaconnect-connector/docs/kafkaclusterclientauthentication.md new file mode 100644 index 0000000..4e50b81 --- /dev/null +++ b/aws-kafkaconnect-connector/docs/kafkaclusterclientauthentication.md @@ -0,0 +1,36 @@ +# AWS::KafkaConnect::Connector KafkaClusterClientAuthentication + +Details of the client authentication used by the Kafka cluster. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "AuthenticationType" : String
+}
+
+ +### YAML + +
+AuthenticationType: String
+
+ +## Properties + +#### AuthenticationType + +The type of client authentication used to connect to the Kafka cluster. Value NONE means that no client authentication is used. + +_Required_: Yes + +_Type_: String + +_Allowed Values_: NONE | IAM + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/kafkaclusterencryptionintransit.md b/aws-kafkaconnect-connector/docs/kafkaclusterencryptionintransit.md new file mode 100644 index 0000000..8e3d3ca --- /dev/null +++ b/aws-kafkaconnect-connector/docs/kafkaclusterencryptionintransit.md @@ -0,0 +1,36 @@ +# AWS::KafkaConnect::Connector KafkaClusterEncryptionInTransit + +Details of encryption in transit to the Kafka cluster. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "EncryptionType" : String
+}
+
+ +### YAML + +
+EncryptionType: String
+
+ +## Properties + +#### EncryptionType + +The type of encryption in transit to the Kafka cluster. + +_Required_: Yes + +_Type_: String + +_Allowed Values_: PLAINTEXT | TLS + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/logdelivery.md b/aws-kafkaconnect-connector/docs/logdelivery.md new file mode 100644 index 0000000..e62488a --- /dev/null +++ b/aws-kafkaconnect-connector/docs/logdelivery.md @@ -0,0 +1,34 @@ +# AWS::KafkaConnect::Connector LogDelivery + +Details of what logs are delivered and where they are delivered. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "WorkerLogDelivery" : WorkerLogDelivery
+}
+
+ +### YAML + +
+WorkerLogDelivery: WorkerLogDelivery
+
+ +## Properties + +#### WorkerLogDelivery + +Specifies where worker logs are delivered. + +_Required_: Yes + +_Type_: WorkerLogDelivery + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/plugin.md b/aws-kafkaconnect-connector/docs/plugin.md new file mode 100644 index 0000000..9306eba --- /dev/null +++ b/aws-kafkaconnect-connector/docs/plugin.md @@ -0,0 +1,34 @@ +# AWS::KafkaConnect::Connector Plugin + +Details about a Kafka Connect plugin which will be used with the connector. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "CustomPlugin" : CustomPlugin
+}
+
+ +### YAML + +
+CustomPlugin: CustomPlugin
+
+ +## Properties + +#### CustomPlugin + +Details about a custom plugin. + +_Required_: Yes + +_Type_: CustomPlugin + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/provisionedcapacity.md b/aws-kafkaconnect-connector/docs/provisionedcapacity.md new file mode 100644 index 0000000..6f8977b --- /dev/null +++ b/aws-kafkaconnect-connector/docs/provisionedcapacity.md @@ -0,0 +1,48 @@ +# AWS::KafkaConnect::Connector ProvisionedCapacity + +Details about a fixed capacity allocated to a connector. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "McuCount" : Integer,
+    "WorkerCount" : Integer
+}
+
+ +### YAML + +
+McuCount: Integer
+WorkerCount: Integer
+
+ +## Properties + +#### McuCount + +Specifies how many MSK Connect Units (MCU) are allocated to the connector. + +_Required_: No + +_Type_: Integer + +_Allowed Values_: 1 | 2 | 4 | 8 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### WorkerCount + +Number of workers for a connector. + +_Required_: Yes + +_Type_: Integer + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/s3logdelivery.md b/aws-kafkaconnect-connector/docs/s3logdelivery.md new file mode 100644 index 0000000..e06e6a9 --- /dev/null +++ b/aws-kafkaconnect-connector/docs/s3logdelivery.md @@ -0,0 +1,58 @@ +# AWS::KafkaConnect::Connector S3LogDelivery + +Details about delivering logs to Amazon S3. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Bucket" : String,
+    "Enabled" : Boolean,
+    "Prefix" : String
+}
+
+ +### YAML + +
+Bucket: String
+Enabled: Boolean
+Prefix: String
+
+ +## Properties + +#### Bucket + +The name of the S3 bucket that is the destination for log delivery. + +_Required_: No + +_Type_: String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Enabled + +Specifies whether the logs get sent to the specified Amazon S3 destination. + +_Required_: Yes + +_Type_: Boolean + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Prefix + +The S3 prefix that is the destination for log delivery. + +_Required_: No + +_Type_: String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/scaleinpolicy.md b/aws-kafkaconnect-connector/docs/scaleinpolicy.md new file mode 100644 index 0000000..d103213 --- /dev/null +++ b/aws-kafkaconnect-connector/docs/scaleinpolicy.md @@ -0,0 +1,34 @@ +# AWS::KafkaConnect::Connector ScaleInPolicy + +Information about the scale in policy of the connector. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "CpuUtilizationPercentage" : Integer
+}
+
+ +### YAML + +
+CpuUtilizationPercentage: Integer
+
+ +## Properties + +#### CpuUtilizationPercentage + +Specifies the CPU utilization percentage threshold at which connector scale in should trigger. + +_Required_: Yes + +_Type_: Integer + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/scaleoutpolicy.md b/aws-kafkaconnect-connector/docs/scaleoutpolicy.md new file mode 100644 index 0000000..07a5a81 --- /dev/null +++ b/aws-kafkaconnect-connector/docs/scaleoutpolicy.md @@ -0,0 +1,34 @@ +# AWS::KafkaConnect::Connector ScaleOutPolicy + +Information about the scale out policy of the connector. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "CpuUtilizationPercentage" : Integer
+}
+
+ +### YAML + +
+CpuUtilizationPercentage: Integer
+
+ +## Properties + +#### CpuUtilizationPercentage + +Specifies the CPU utilization percentage threshold at which connector scale out should trigger. + +_Required_: Yes + +_Type_: Integer + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/tag.md b/aws-kafkaconnect-connector/docs/tag.md new file mode 100644 index 0000000..a95d9e5 --- /dev/null +++ b/aws-kafkaconnect-connector/docs/tag.md @@ -0,0 +1,46 @@ +# AWS::KafkaConnect::Connector Tag + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Key" : String,
+    "Value" : String
+}
+
+ +### YAML + +
+Key: String
+Value: String
+
+ +## Properties + +#### Key + +_Required_: Yes + +_Type_: String + +_Minimum Length_: 1 + +_Maximum Length_: 128 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Value + +_Required_: Yes + +_Type_: String + +_Maximum Length_: 256 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/vpc.md b/aws-kafkaconnect-connector/docs/vpc.md new file mode 100644 index 0000000..7cb9fa3 --- /dev/null +++ b/aws-kafkaconnect-connector/docs/vpc.md @@ -0,0 +1,48 @@ +# AWS::KafkaConnect::Connector Vpc + +Information about a VPC used with the connector. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "SecurityGroups" : [ String, ... ],
+    "Subnets" : [ String, ... ]
+}
+
+ +### YAML + +
+SecurityGroups: 
+      - String
+Subnets: 
+      - String
+
+ +## Properties + +#### SecurityGroups + +The AWS security groups to associate with the elastic network interfaces in order to specify what the connector has access to. + +_Required_: Yes + +_Type_: List of String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Subnets + +The list of subnets to connect to in the virtual private cloud (VPC). AWS creates elastic network interfaces inside these subnets. + +_Required_: Yes + +_Type_: List of String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/workerconfiguration.md b/aws-kafkaconnect-connector/docs/workerconfiguration.md new file mode 100644 index 0000000..5b07ecf --- /dev/null +++ b/aws-kafkaconnect-connector/docs/workerconfiguration.md @@ -0,0 +1,48 @@ +# AWS::KafkaConnect::Connector WorkerConfiguration + +Specifies the worker configuration to use with the connector. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Revision" : Integer,
+    "WorkerConfigurationArn" : String
+}
+
+ +### YAML + +
+Revision: Integer
+WorkerConfigurationArn: String
+
+ +## Properties + +#### Revision + +The revision of the worker configuration to use. + +_Required_: Yes + +_Type_: Integer + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### WorkerConfigurationArn + +The Amazon Resource Name (ARN) of the worker configuration to use. + +_Required_: Yes + +_Type_: String + +_Pattern_: arn:(aws|aws-us-gov|aws-cn):kafkaconnect:.* + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/docs/workerlogdelivery.md b/aws-kafkaconnect-connector/docs/workerlogdelivery.md new file mode 100644 index 0000000..a6ed4c5 --- /dev/null +++ b/aws-kafkaconnect-connector/docs/workerlogdelivery.md @@ -0,0 +1,58 @@ +# AWS::KafkaConnect::Connector WorkerLogDelivery + +Specifies where worker logs are delivered. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "CloudWatchLogs" : CloudWatchLogsLogDelivery,
+    "Firehose" : FirehoseLogDelivery,
+    "S3" : S3LogDelivery
+}
+
+ +### YAML + +
+CloudWatchLogs: CloudWatchLogsLogDelivery
+Firehose: FirehoseLogDelivery
+S3: S3LogDelivery
+
+ +## Properties + +#### CloudWatchLogs + +Details about delivering logs to Amazon CloudWatch Logs. + +_Required_: No + +_Type_: CloudWatchLogsLogDelivery + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Firehose + +Details about delivering logs to Amazon Kinesis Data Firehose. + +_Required_: No + +_Type_: FirehoseLogDelivery + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### S3 + +Details about delivering logs to Amazon S3. + +_Required_: No + +_Type_: S3LogDelivery + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-connector/pom.xml b/aws-kafkaconnect-connector/pom.xml index 02c4d70..6ce2884 100644 --- a/aws-kafkaconnect-connector/pom.xml +++ b/aws-kafkaconnect-connector/pom.xml @@ -1,8 +1,8 @@ + xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> 4.0.0 software.amazon.kafkaconnect.connector @@ -12,10 +12,12 @@ jar - 1.8 - 1.8 + 17 + ${java.version} + ${java.version} UTF-8 UTF-8 + @@ -25,7 +27,7 @@ software.amazon.awssdk bom - 2.17.121 + 2.25.17 pom import @@ -33,7 +35,8 @@ - + software.amazon.cloudformation aws-cloudformation-rpdk-java-plugin @@ -43,7 +46,7 @@ org.projectlombok lombok - 1.18.4 + 1.18.32 provided @@ -64,6 +67,8 @@ log4j-slf4j-impl 2.13.3 + + software.amazon.awssdk kafkaconnect @@ -104,7 +109,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.8.1 + 3.12.1 -Xlint:all,-options,-processing @@ -141,7 +146,7 @@ cfn - generate + generate ${cfn.generate.args} ${project.basedir} @@ -178,7 +183,7 @@ org.jacoco jacoco-maven-plugin - 0.8.4 + 0.8.12 **/BaseConfiguration* @@ -237,4 +242,4 @@ - + \ No newline at end of file diff --git a/aws-kafkaconnect-connector/resource-role.yaml b/aws-kafkaconnect-connector/resource-role.yaml index d21e2a7..baf8be5 100644 --- a/aws-kafkaconnect-connector/resource-role.yaml +++ b/aws-kafkaconnect-connector/resource-role.yaml @@ -41,6 +41,9 @@ Resources: - "kafkaconnect:DeleteConnector" - "kafkaconnect:DescribeConnector" - "kafkaconnect:ListConnectors" + - "kafkaconnect:ListTagsForResource" + - "kafkaconnect:TagResource" + - "kafkaconnect:UntagResource" - "kafkaconnect:UpdateConnector" - "logs:CreateLogDelivery" - "logs:DeleteLogDelivery" diff --git a/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/ClientBuilder.java b/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/ClientBuilder.java index 1f76ca8..f9458b0 100644 --- a/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/ClientBuilder.java +++ b/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/ClientBuilder.java @@ -1,29 +1,48 @@ package software.amazon.kafkaconnect.connector; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.core.retry.backoff.BackoffStrategy; +import software.amazon.awssdk.core.retry.backoff.EqualJitterBackoffStrategy; +import software.amazon.awssdk.core.retry.conditions.RetryCondition; +import software.amazon.awssdk.core.retry.RetryPolicy; import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; import software.amazon.cloudformation.LambdaWrapper; import java.net.URI; +import java.time.Duration; public class ClientBuilder { private static final String CN_PARTITION = "aws-cn"; private static final String CN_SUFFIX = ".cn"; private static final String SERVICE_ENDPOINT_TEMPLATE = "https://kafkaconnect.%s.amazonaws.com"; + private static final BackoffStrategy BACKOFF_THROTTLING_STRATEGY = EqualJitterBackoffStrategy.builder() + .baseDelay(Duration.ofMillis(1200)) + .maxBackoffTime(Duration.ofSeconds(45)) + .build(); + + private static final RetryPolicy RETRY_POLICY = RetryPolicy.builder() + .numRetries(10) + .retryCondition(RetryCondition.defaultRetryCondition()) + .backoffStrategy(BACKOFF_THROTTLING_STRATEGY) + .throttlingBackoffStrategy(BACKOFF_THROTTLING_STRATEGY) + .build(); + private ClientBuilder() { } - //It is recommended to use static HTTP client so less memory is consumed. public static KafkaConnectClient getClient(final String awsPartition, final String awsRegion) { return KafkaConnectClient .builder() .httpClient(LambdaWrapper.HTTP_CLIENT) .endpointOverride(getServiceEndpoint(awsPartition, awsRegion)) + .overrideConfiguration(ClientOverrideConfiguration.builder() + .retryPolicy(RETRY_POLICY) + .build()) .build(); } private static URI getServiceEndpoint(final String partition, final String region) { - // all cfn endpoints hit prod service endpoint in the same region final String serviceEndpoint = String.format(SERVICE_ENDPOINT_TEMPLATE, region); return URI.create( partition.equals(CN_PARTITION) ? serviceEndpoint + CN_SUFFIX : serviceEndpoint); diff --git a/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/Configuration.java b/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/Configuration.java index 4e05f90..3449214 100644 --- a/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/Configuration.java +++ b/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/Configuration.java @@ -1,8 +1,22 @@ package software.amazon.kafkaconnect.connector; +import java.util.Map; + public class Configuration extends BaseConfiguration { public Configuration() { super("aws-kafkaconnect-connector.json"); } + + /** + * Providers should implement this method if their resource has a 'Tags' property to define resource-level tags + * @return + */ + public Map resourceDefinedTags(final ResourceModel resourceModel) { + if (resourceModel.getTags() == null) { + return null; + } else { + return TagHelper.convertToMap(resourceModel.getTags()); + } + } } diff --git a/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/CreateHandler.java b/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/CreateHandler.java index 15ad219..deb4eb4 100644 --- a/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/CreateHandler.java +++ b/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/CreateHandler.java @@ -71,9 +71,11 @@ protected ProgressEvent handleRequest( this.logger = logger; - return ProgressEvent.progress(request.getDesiredResourceState(), callbackContext) + final ResourceModel model = request.getDesiredResourceState(); + + return ProgressEvent.progress(model, callbackContext) .then(progress -> - initiateCreateConnector(proxy, proxyClient, progress, "AWS-KafkaConnect-Connector::Create")) + initiateCreateConnector(proxy, proxyClient, progress, "AWS-KafkaConnect-Connector::Create", request)) .then(progress -> stabilize(proxy, proxyClient, progress, "AWS-KafkaConnect-Connector::PostCreateStabilize")) .then(progress -> readHandler.handleRequest(proxy, request, callbackContext, proxyClient, logger)); @@ -83,10 +85,12 @@ private ProgressEvent initiateCreateConnector( final AmazonWebServicesClientProxy proxy, final ProxyClient proxyClient, final ProgressEvent progress, - final String callGraph) { + final String callGraph, + final ResourceHandlerRequest request) { return proxy.initiate(callGraph, proxyClient, progress.getResourceModel(), progress.getCallbackContext()) - .translateToServiceRequest(translator::translateToCreateRequest) + .translateToServiceRequest(_resourceModel -> translator.translateToCreateRequest(_resourceModel, + TagHelper.generateTagsForCreate(request))) .makeServiceCall(this::runCreateConnector) .done(this::setConnectorArn); } diff --git a/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/ExceptionTranslator.java b/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/ExceptionTranslator.java index a333e2c..a76d321 100644 --- a/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/ExceptionTranslator.java +++ b/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/ExceptionTranslator.java @@ -36,7 +36,7 @@ public BaseHandlerException translateToCfnException( } if (exception instanceof BadRequestException) { - return new CfnInvalidRequestException(ResourceModel.TYPE_NAME, exception); + return new CfnInvalidRequestException(exception.getMessage(), exception); } if (exception instanceof ConflictException) { @@ -51,6 +51,6 @@ public BaseHandlerException translateToCfnException( return new CfnAccessDeniedException(ResourceModel.TYPE_NAME, exception); } - return new CfnGeneralServiceException(exception.getMessage(), exception); + return new CfnGeneralServiceException(exception); } } diff --git a/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/ReadHandler.java b/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/ReadHandler.java index 658d1ec..885698a 100644 --- a/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/ReadHandler.java +++ b/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/ReadHandler.java @@ -4,12 +4,15 @@ import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; import software.amazon.awssdk.services.kafkaconnect.model.DescribeConnectorRequest; import software.amazon.awssdk.services.kafkaconnect.model.DescribeConnectorResponse; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceResponse; import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; import software.amazon.cloudformation.proxy.Logger; import software.amazon.cloudformation.proxy.ProgressEvent; import software.amazon.cloudformation.proxy.ProxyClient; import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import java.util.Map; + public class ReadHandler extends BaseHandlerStd { private Logger logger; private final ExceptionTranslator exceptionTranslator; @@ -46,16 +49,16 @@ protected ProgressEvent handleRequest( request.getDesiredResourceState(), callbackContext) .translateToServiceRequest(translator::translateToReadRequest) - - .makeServiceCall(this::describeConnector) + .makeServiceCall(this::describeConnectorWithTags) .done(responseModel -> ProgressEvent.defaultSuccessHandler(responseModel)); } - private ResourceModel describeConnector( + private ResourceModel describeConnectorWithTags( final DescribeConnectorRequest describeConnectorRequest, final ProxyClient proxyClient) { DescribeConnectorResponse describeConnectorResponse; + Map connectorTags; final String identifier = describeConnectorRequest.connectorArn(); final KafkaConnectClient kafkaConnectClient = proxyClient.client(); @@ -66,6 +69,14 @@ private ResourceModel describeConnector( throw exceptionTranslator.translateToCfnException(e, identifier); } + try { + final ListTagsForResourceResponse listTagsForResourceResponse = + TagHelper.listTags(describeConnectorRequest.connectorArn(), kafkaConnectClient, proxyClient); + connectorTags = listTagsForResourceResponse.tags(); + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + logger.log( String.format( "%s [%s] has successfully been read.", @@ -73,6 +84,10 @@ private ResourceModel describeConnector( identifier ) ); - return translator.translateFromReadResponse(describeConnectorResponse); + + final ResourceModel readResponse = translator.translateFromReadResponse(describeConnectorResponse); + readResponse.setTags(TagHelper.convertToSet(connectorTags)); + + return readResponse; } } diff --git a/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/TagHelper.java b/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/TagHelper.java new file mode 100644 index 0000000..0bc6050 --- /dev/null +++ b/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/TagHelper.java @@ -0,0 +1,192 @@ +package software.amazon.kafkaconnect.connector; + +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.ObjectUtils; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceResponse; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +public class TagHelper { + /** + * convertToMap + * + * Converts a collection of Tag objects to a tag-name -> tag-value map. + * + * Note: Tag objects with null tag values will not be included in the output + * map. + * + * @param tags Collection of tags to convert + * @return Converted Map of tags + */ + public static Map convertToMap(final Collection tags) { + if (CollectionUtils.isEmpty(tags)) { + return Collections.emptyMap(); + } + return tags.stream() + .filter(tag -> tag.getValue() != null) + .collect(Collectors.toMap( + Tag::getKey, + Tag::getValue, + (oldValue, newValue) -> newValue)); + } + + /** + * convertToSet + * + * Converts a tag map to a set of Tag objects. + * + * Note: Like convertToMap, convertToSet filters out value-less tag entries. + * + * @param tagMap Map of tags to convert + * @return Set of Tag objects + */ + public static Set convertToSet(final Map tagMap) { + if (MapUtils.isEmpty(tagMap)) { + return Collections.emptySet(); + } + return tagMap.entrySet().stream() + .filter(tag -> tag.getValue() != null) + .map(tag -> Tag.builder() + .key(tag.getKey()) + .value(tag.getValue()) + .build()) + .collect(Collectors.toSet()); + } + + public static ListTagsForResourceResponse listTags(final String arn, final KafkaConnectClient kafkaConnectClient, + final ProxyClient proxyClient) { + final ListTagsForResourceRequest listTagsForResourceRequest = ListTagsForResourceRequest.builder() + .resourceArn(arn) + .build(); + + return proxyClient.injectCredentialsAndInvokeV2(listTagsForResourceRequest, + kafkaConnectClient::listTagsForResource); + } + + /** + * generateTagsForCreate + * + * Generate tags to put into resource creation request. + * This includes user defined tags and system tags as well. + */ + public static Map generateTagsForCreate(final ResourceHandlerRequest handlerRequest) { + final Map tagMap = new HashMap<>(); + + // merge system tags with desired resource tags if your service supports CloudFormation system tags + if(handlerRequest.getSystemTags() != null){ + tagMap.putAll(handlerRequest.getSystemTags()); + } + + // get desired stack level tags from handlerRequest + if(handlerRequest.getDesiredResourceTags() != null) { + tagMap.putAll(handlerRequest.getDesiredResourceTags()); + } + + // get resource level tags from resource model based on your tag property name + if(handlerRequest.getDesiredResourceState() != null && handlerRequest.getDesiredResourceState().getTags() != null){ + tagMap.putAll(convertToMap(handlerRequest.getDesiredResourceState().getTags())); + } + + return Collections.unmodifiableMap(tagMap); + } + + /** + * shouldUpdateTags + * + * Determines whether user defined tags have been changed during update. + */ + public static boolean shouldUpdateTags(final ResourceHandlerRequest handlerRequest) { + final Map previousTags = getPreviouslyAttachedTags(handlerRequest); + final Map desiredTags = getNewDesiredTags(handlerRequest); + return ObjectUtils.notEqual(previousTags, desiredTags); + } + + /** + * getPreviouslyAttachedTags + * + * If stack tags and resource tags are not merged together in Configuration class, + * we will get previous attached user defined tags from both handlerRequest.getPreviousResourceTags (stack tags) + * and handlerRequest.getPreviousResourceState (resource tags). + */ + public static Map getPreviouslyAttachedTags(final ResourceHandlerRequest handlerRequest) { + final Map previousTags = new HashMap<>(); + + // get previous system tags if your service supports CloudFormation system tags + if (handlerRequest.getPreviousSystemTags() != null) { + previousTags.putAll(handlerRequest.getPreviousSystemTags()); + } + + // get previous stack level tags from handlerRequest + if (handlerRequest.getPreviousResourceTags() != null) { + previousTags.putAll(handlerRequest.getPreviousResourceTags()); + } + + // get resource level tags from previous resource state based on your tag property name + if (handlerRequest.getPreviousResourceState() != null && handlerRequest.getPreviousResourceState().getTags() != null) { + previousTags.putAll(convertToMap(handlerRequest.getPreviousResourceState().getTags())); + } + + return previousTags; + } + + /** + * getNewDesiredTags + * + * If stack tags and resource tags are not merged together in Configuration class, + * we will get new user defined tags from both resource model and previous stack tags. + */ + public static Map getNewDesiredTags(final ResourceHandlerRequest handlerRequest) { + final Map desiredTags = new HashMap<>(); + + // merge system tags with desired resource tags if your service supports CloudFormation system tags + if (handlerRequest.getSystemTags() != null) { + desiredTags.putAll(handlerRequest.getSystemTags()); + } + + // get desired stack level tags from handlerRequest + if (handlerRequest.getDesiredResourceTags() != null) { + desiredTags.putAll(handlerRequest.getDesiredResourceTags()); + } + + // get resource level tags from resource model based on your tag property name + desiredTags.putAll(convertToMap(handlerRequest.getDesiredResourceState().getTags())); + return desiredTags; + } + + /** + * generateTagsToAdd + * + * Determines the tags the customer desired to define or redefine. + */ + public static Map generateTagsToAdd(final Map previousTags, final Map desiredTags) { + return desiredTags.entrySet().stream() + .filter(e -> !previousTags.containsKey(e.getKey()) || !Objects.equals(previousTags.get(e.getKey()), e.getValue())) + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue)); + } + + /** + * getTagsToRemove + * + * Determines the tags the customer desired to remove from the function. + */ + public static Set generateTagsToRemove(final Map previousTags, final Map desiredTags) { + final Set desiredTagNames = desiredTags.keySet(); + + return previousTags.keySet().stream() + .filter(tagName -> !desiredTagNames.contains(tagName)) + .collect(Collectors.toSet()); + } +} diff --git a/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/Translator.java b/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/Translator.java index 12822c8..6238896 100644 --- a/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/Translator.java +++ b/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/Translator.java @@ -25,6 +25,8 @@ import software.amazon.awssdk.services.kafkaconnect.model.ScaleInPolicyUpdate; import software.amazon.awssdk.services.kafkaconnect.model.ScaleOutPolicyDescription; import software.amazon.awssdk.services.kafkaconnect.model.ScaleOutPolicyUpdate; +import software.amazon.awssdk.services.kafkaconnect.model.TagResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.UntagResourceRequest; import software.amazon.awssdk.services.kafkaconnect.model.UpdateConnectorRequest; import software.amazon.awssdk.services.kafkaconnect.model.VpcDescription; import software.amazon.awssdk.services.kafkaconnect.model.WorkerConfigurationDescription; @@ -33,7 +35,9 @@ import java.util.Collection; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -54,7 +58,8 @@ public Translator() { * @param model resource model * @return createConnectorRequest the kafkaconnect request to create a resource */ - public CreateConnectorRequest translateToCreateRequest(final ResourceModel model) { + public CreateConnectorRequest translateToCreateRequest(final ResourceModel model, + final Map tagsForCreate) { return CreateConnectorRequest.builder() .capacity(resourceCapacityToSdkCapacity(model.getCapacity())) .connectorConfiguration(model.getConnectorConfiguration()) @@ -72,6 +77,7 @@ public CreateConnectorRequest translateToCreateRequest(final ResourceModel model .logDelivery(resourceLogDeliveryToSdkLogDelivery(model.getLogDelivery())) .serviceExecutionRoleArn(model.getServiceExecutionRoleArn()) .workerConfiguration(resourceWorkerConfigurationToSdkWorkerConfiguration(model.getWorkerConfiguration())) + .tags(tagsForCreate) .build(); } @@ -567,4 +573,31 @@ private static WorkerConfiguration sdkWorkerConfigurationDescriptionToResourceWo .workerConfigurationArn(workerConfigurationDescription.workerConfigurationArn()) .build(); } + + /** + * Request to add tags to a resource + * + * @param model resource model + * @return awsRequest the aws service request to create a resource + */ + static TagResourceRequest tagResourceRequest(final ResourceModel model, final Map addedTags) { + return TagResourceRequest.builder() + .resourceArn(model.getConnectorArn()) + .tags(addedTags) + .build(); + } + + /** + * Request to add tags to a resource + * + * @param model resource model + * @return awsRequest the aws service request to create a resource + */ + static UntagResourceRequest untagResourceRequest(final ResourceModel model, final Set removedTags) { + return UntagResourceRequest.builder() + .resourceArn(model.getConnectorArn()) + .tagKeys(removedTags) + .build(); + + } } diff --git a/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/UpdateHandler.java b/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/UpdateHandler.java index 6a07124..d855ef6 100644 --- a/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/UpdateHandler.java +++ b/aws-kafkaconnect-connector/src/main/java/software/amazon/kafkaconnect/connector/UpdateHandler.java @@ -6,6 +6,8 @@ import software.amazon.awssdk.services.kafkaconnect.model.ConnectorState; import software.amazon.awssdk.services.kafkaconnect.model.DescribeConnectorRequest; import software.amazon.awssdk.services.kafkaconnect.model.DescribeConnectorResponse; +import software.amazon.awssdk.services.kafkaconnect.model.TagResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.UntagResourceRequest; import software.amazon.awssdk.services.kafkaconnect.model.UpdateConnectorRequest; import software.amazon.awssdk.services.kafkaconnect.model.UpdateConnectorResponse; import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; @@ -19,7 +21,9 @@ import software.amazon.cloudformation.proxy.delay.Constant; import java.time.Duration; +import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.function.BiFunction; import java.util.function.Function; @@ -79,6 +83,8 @@ protected ProgressEvent handleRequest( return ProgressEvent.progress(request.getDesiredResourceState(), callbackContext) .then(progress -> verifyUpdatable(proxy, proxyClient, progress, "AWS-KafkaConnect-Connector::PreUpdateCheck")) + .then(progress -> updateTags(proxyClient, progress, request)) + .then(progress -> verifyNonCreateOnlyFieldsHaveToBeUpdated(proxy, proxyClient, progress, request, callbackContext)) .then(progress -> initiateUpdateConnector(proxy, proxyClient, progress, "AWS-KafkaConnect-Connector::Update")) .then(progress -> @@ -94,7 +100,7 @@ private ProgressEvent verifyUpdatable( return proxy.initiate(callGraph, proxyClient, progress.getResourceModel(), progress.getCallbackContext()) .translateToServiceRequest(translator::translateToReadRequest) - .makeServiceCall(this::verifyResourceRunning) + .makeServiceCall(this::verifyResourceExists) .done(this::verifyUpdateFieldsNotCreateOnly); } @@ -140,7 +146,7 @@ private ProgressEvent verifyUpdateFieldsNotCreat return ProgressEvent.progress(updateRequest, callbackContext); } - private DescribeConnectorResponse verifyResourceRunning( + private DescribeConnectorResponse verifyResourceExists( final DescribeConnectorRequest describeConnectorRequest, final ProxyClient proxyClient) { @@ -148,13 +154,68 @@ private DescribeConnectorResponse verifyResourceRunning( describeConnectorRequest, proxyClient, DESCRIBE_STATE_FAILURE_MESSAGE_PATTERN, exceptionTranslator); final String identifier = describeConnectorRequest.connectorArn(); - if (ConnectorState.RUNNING != describeConnectorResponse.connectorState()) { - throw new CfnNotUpdatableException(ResourceModel.TYPE_NAME, identifier); + logger.log(String.format("Resource %s with ID %s exists", ResourceModel.TYPE_NAME, identifier)); + + return describeConnectorResponse; + } + + private ProgressEvent updateTags(final ProxyClient proxyClient, + final ProgressEvent progress, ResourceHandlerRequest request) { + + final ResourceModel desiredModel = request.getDesiredResourceState(); + final String identifier = desiredModel.getConnectorArn(); + final CallbackContext callbackContext = progress.getCallbackContext(); + + if (TagHelper.shouldUpdateTags(request)) { + final Map previousTags = TagHelper.getPreviouslyAttachedTags(request); + final Map desiredTags = TagHelper.getNewDesiredTags(request); + final Map addedTags = TagHelper.generateTagsToAdd(previousTags, desiredTags); + final Set removedTags = TagHelper.generateTagsToRemove(previousTags, desiredTags); + + // calculate tags to remove based on key only + if (!removedTags.isEmpty()) { + + final UntagResourceRequest untagResourceRequest = Translator.untagResourceRequest(desiredModel, removedTags); + try { + proxyClient.injectCredentialsAndInvokeV2(untagResourceRequest, proxyClient.client()::untagResource); + logger.log(String.format("Removed %d tags", removedTags.size())); + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + } + + // calculate tags to update based on Tags (key + value) + if (!addedTags.isEmpty()) { + final TagResourceRequest tagResourceRequest = Translator.tagResourceRequest(desiredModel, addedTags); + try { + proxyClient.injectCredentialsAndInvokeV2(tagResourceRequest, proxyClient.client()::tagResource); + logger.log(String.format("Added %d tags", addedTags.size())); + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + } } - logger.log(String.format("State of resource %s with ID %s is RUNNING", ResourceModel.TYPE_NAME, identifier)); + return ProgressEvent.progress(desiredModel, callbackContext); + } - return describeConnectorResponse; + + private ProgressEvent verifyNonCreateOnlyFieldsHaveToBeUpdated( + final AmazonWebServicesClientProxy proxy, + final ProxyClient proxyClient, + final ProgressEvent progress, + final ResourceHandlerRequest request, + final CallbackContext callbackContext) { + final ResourceModel desiredModel = progress.getResourceModel(); + final ResourceModel previousModel = request.getPreviousResourceState(); + final boolean isCapacityEqual = desiredModel.getCapacity().equals(previousModel.getCapacity()); + final boolean nonCreateOnlyFieldsHaveToBeUpdated = !(isCapacityEqual); + + if(nonCreateOnlyFieldsHaveToBeUpdated) { + return ProgressEvent.progress(desiredModel, callbackContext); + } + + return readHandler.handleRequest(proxy, request, callbackContext, proxyClient, logger); } private ProgressEvent initiateUpdateConnector( diff --git a/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/AbstractTestBase.java b/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/AbstractTestBase.java index 90695ea..c338cea 100644 --- a/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/AbstractTestBase.java +++ b/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/AbstractTestBase.java @@ -1,5 +1,7 @@ package software.amazon.kafkaconnect.connector; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.function.Function; import software.amazon.awssdk.awscore.AwsRequest; @@ -17,6 +19,13 @@ public class AbstractTestBase { protected static final Credentials MOCK_CREDENTIALS = new Credentials("accessKey", "secretKey", "token"); protected static final LoggerProxy logger = new LoggerProxy(); + protected static final Map TAGS = new HashMap() { + { + put("TEST_TAG1", "TEST_TAG_VALUE1"); + put("TEST_TAG2", "TEST_TAG_VALUE2"); + } + }; + static ProxyClient proxyStub( final AmazonWebServicesClientProxy proxy, final KafkaConnectClient kafkaConnectClient) { diff --git a/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/ConfigurationTest.java b/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/ConfigurationTest.java new file mode 100644 index 0000000..22b897f --- /dev/null +++ b/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/ConfigurationTest.java @@ -0,0 +1,37 @@ +package software.amazon.kafkaconnect.connector; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ConfigurationTest extends AbstractTestBase { + + private Configuration configuration; + + @BeforeEach + public void setup(){ + configuration = new Configuration(); + } + + @Test + public void test_resourceDefinedTags_whenTagsAreNull() { + final ResourceModel model = ResourceModel.builder().tags(null).build(); + + final Map response = configuration.resourceDefinedTags(model); + + assertThat(response).isNull(); + } + + @Test + public void test_resourceDefinedTags_whenTagsAreNotNull() { + final ResourceModel model = ResourceModel.builder().tags(TagHelper.convertToSet(TAGS)).build(); + + final Map response = configuration.resourceDefinedTags(model); + + assertThat(response).isNotNull(); + assertThat(response).isEqualTo(TagHelper.convertToMap(model.getTags())); + } +} \ No newline at end of file diff --git a/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/CreateHandlerTest.java b/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/CreateHandlerTest.java index 4e7c6a4..4a6f061 100644 --- a/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/CreateHandlerTest.java +++ b/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/CreateHandlerTest.java @@ -21,6 +21,8 @@ import software.amazon.awssdk.services.kafkaconnect.model.KafkaClusterClientAuthenticationType; import software.amazon.awssdk.services.kafkaconnect.model.KafkaClusterEncryptionInTransit; import software.amazon.awssdk.services.kafkaconnect.model.KafkaClusterEncryptionInTransitType; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceResponse; import software.amazon.awssdk.services.kafkaconnect.model.Plugin; import software.amazon.awssdk.services.kafkaconnect.model.ProvisionedCapacity; import software.amazon.awssdk.services.kafkaconnect.model.Vpc; @@ -84,7 +86,7 @@ public void tear_down() { @Test public void handleRequest_success() { final ResourceModel resourceModel = TestData.getResourceModel(); - when(translator.translateToCreateRequest(resourceModel)) + when(translator.translateToCreateRequest(resourceModel, TagHelper.convertToMap(resourceModel.getTags()))) .thenReturn(TestData.CREATE_CONNECTOR_REQUEST); when(proxyClient.injectCredentialsAndInvokeV2( TestData.CREATE_CONNECTOR_REQUEST, kafkaConnectClient::createConnector) @@ -98,17 +100,22 @@ public void handleRequest_success() { )).thenReturn(describeConnectorResponse); when(translator.translateFromReadResponse(describeConnectorResponse)) .thenReturn(TestData.RESOURCE_MODEL_WITH_ARN); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, + kafkaConnectClient::listTagsForResource)).thenReturn(TestData.LIST_TAGS_FOR_RESOURCE_RESPONSE); + final ResourceHandlerRequest request = TestData.getResourceHandlerRequest(resourceModel); final ProgressEvent response = handler.handleRequest( - proxy, TestData.getResourceHandlerRequest(resourceModel), new CallbackContext(), proxyClient, logger); + proxy, request, new CallbackContext(), proxyClient, logger); assertThat(response).isEqualTo(TestData.DESCRIBE_RESPONSE); + assertThat(response.getResourceModel().getTags()) + .isEqualTo(request.getDesiredResourceState().getTags()); } @Test public void handleRequest_afterNDescribeConnectors_success() { final ResourceModel resourceModel = TestData.getResourceModel(); - when(translator.translateToCreateRequest(resourceModel)) + when(translator.translateToCreateRequest(resourceModel, TagHelper.convertToMap(resourceModel.getTags()))) .thenReturn(TestData.CREATE_CONNECTOR_REQUEST); when(proxyClient.injectCredentialsAndInvokeV2( TestData.CREATE_CONNECTOR_REQUEST, kafkaConnectClient::createConnector) @@ -124,11 +131,16 @@ public void handleRequest_afterNDescribeConnectors_success() { .thenReturn(describeRunningConnectorResponse); when(translator.translateFromReadResponse(describeRunningConnectorResponse)) .thenReturn(TestData.RESOURCE_MODEL_WITH_ARN); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, + kafkaConnectClient::listTagsForResource)).thenReturn(TestData.LIST_TAGS_FOR_RESOURCE_RESPONSE); + final ResourceHandlerRequest request = TestData.getResourceHandlerRequest(resourceModel); final ProgressEvent response = handler.handleRequest( - proxy, TestData.getResourceHandlerRequest(resourceModel), new CallbackContext(), proxyClient, logger); + proxy, request, new CallbackContext(), proxyClient, logger); assertThat(response).isEqualTo(TestData.DESCRIBE_RESPONSE); + assertThat(response.getResourceModel().getTags()) + .isEqualTo(request.getDesiredResourceState().getTags()); } @Test @@ -136,7 +148,7 @@ public void handleRequest_throwsAlreadyExistsException_whenConnectorExists() { final ResourceModel resourceModel = TestData.getResourceModel(); final ConflictException cException = ConflictException.builder().build(); final CfnAlreadyExistsException cfnException = new CfnAlreadyExistsException(cException); - when(translator.translateToCreateRequest(resourceModel)) + when(translator.translateToCreateRequest(resourceModel, TagHelper.convertToMap(resourceModel.getTags()))) .thenReturn(TestData.CREATE_CONNECTOR_REQUEST); when(proxyClient.injectCredentialsAndInvokeV2( TestData.CREATE_CONNECTOR_REQUEST, @@ -192,8 +204,9 @@ public void handleRequest_throwsGeneralServiceException_whenConnectorReturnsUnex public void handleRequest_throwsGeneralServiceException_whenDescribeConnectorThrowsException() { final AwsServiceException cException = AwsServiceException.builder() .message(TestData.EXCEPTION_MESSAGE).build(); - when(translator.translateToCreateRequest(TestData.getResourceModel())) - .thenReturn(TestData.CREATE_CONNECTOR_REQUEST); + when(translator.translateToCreateRequest(TestData.getResourceModel(), + TagHelper.convertToMap(TestData.getResourceModel().getTags()))) + .thenReturn(TestData.CREATE_CONNECTOR_REQUEST); when(proxyClient.injectCredentialsAndInvokeV2( TestData.CREATE_CONNECTOR_REQUEST, kafkaConnectClient::createConnector) ).thenReturn(TestData.CREATE_CONNECTOR_RESPONSE); @@ -209,7 +222,7 @@ public void handleRequest_throwsGeneralServiceException_whenDescribeConnectorThr } private void setupMocksToReturnConnectorState(final ConnectorState connectorState) { - when(translator.translateToCreateRequest(TestData.RESOURCE_MODEL)) + when(translator.translateToCreateRequest(TestData.RESOURCE_MODEL, TagHelper.convertToMap(TestData.RESOURCE_MODEL.getTags()))) .thenReturn(TestData.CREATE_CONNECTOR_REQUEST); when(proxyClient.injectCredentialsAndInvokeV2( TestData.CREATE_CONNECTOR_REQUEST, kafkaConnectClient::createConnector) @@ -262,6 +275,7 @@ private static class TestData { private static final ResourceModel RESOURCE_MODEL = ResourceModel .builder() .connectorName(CONNECTOR_NAME) + .tags(TagHelper.convertToSet(TAGS)) .build(); private static final CreateConnectorRequest CREATE_CONNECTOR_REQUEST = @@ -299,6 +313,7 @@ private static class TestData { .build()) .build())) .serviceExecutionRoleArn(SERVICE_EXECUTION_ROLE_ARN) + .tags(TAGS) .build(); private static final CreateConnectorResponse CREATE_CONNECTOR_RESPONSE = @@ -317,6 +332,7 @@ private static class TestData { private static final ResourceModel RESOURCE_MODEL_WITH_ARN = ResourceModel.builder() .connectorName(CONNECTOR_NAME) .connectorArn(CONNECTOR_ARN) + .tags(TagHelper.convertToSet(TAGS)) .build(); private static final ProgressEvent DESCRIBE_RESPONSE = @@ -330,6 +346,7 @@ private static final ResourceHandlerRequest getResourceHandlerReq return ResourceHandlerRequest.builder() .desiredResourceState(resourceModel) + .desiredResourceTags(TAGS) .build(); } @@ -337,6 +354,7 @@ private static final ResourceModel getResourceModel() { return ResourceModel .builder() .connectorName(CONNECTOR_NAME) + .tags(TagHelper.convertToSet(TAGS)) .build(); } @@ -348,5 +366,15 @@ private static DescribeConnectorResponse describeResponseWithState(final Connect .connectorState(state) .build(); } + + private static final ListTagsForResourceRequest LIST_TAGS_FOR_RESOURCE_REQUEST = + ListTagsForResourceRequest.builder() + .resourceArn(CONNECTOR_ARN) + .build(); + + private static final ListTagsForResourceResponse LIST_TAGS_FOR_RESOURCE_RESPONSE = + ListTagsForResourceResponse.builder() + .tags(TAGS) + .build(); } } diff --git a/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/ExceptionTranslatorTest.java b/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/ExceptionTranslatorTest.java index 88e3889..9f139b5 100644 --- a/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/ExceptionTranslatorTest.java +++ b/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/ExceptionTranslatorTest.java @@ -43,7 +43,7 @@ public void translateToCfnException_BadRequestException_MapsToCfnInvalidRequestE .build(); runTranslateToCfnExceptionAndVerifyOutput(exception, CfnInvalidRequestException.class, - "Invalid request provided: AWS::KafkaConnect::Connector"); + "Invalid request provided: " + TEST_MESSAGE); } @Test @@ -83,8 +83,7 @@ public void translateToCfnException_Other_MapsToCfnGeneralServiceException() { .message(TEST_MESSAGE) .build(); - runTranslateToCfnExceptionAndVerifyOutput(exception, CfnGeneralServiceException.class, - "Error occurred during operation '" + TEST_MESSAGE + "'."); + runTranslateToCfnExceptionAndVerifyOutput(exception, CfnGeneralServiceException.class, TEST_MESSAGE); } private void runTranslateToCfnExceptionAndVerifyOutput(final AwsServiceException exception, diff --git a/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/ReadHandlerTest.java b/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/ReadHandlerTest.java index e3933e1..701cbb7 100644 --- a/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/ReadHandlerTest.java +++ b/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/ReadHandlerTest.java @@ -1,6 +1,7 @@ package software.amazon.kafkaconnect.connector; import java.time.Duration; +import java.util.HashMap; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -13,6 +14,8 @@ import software.amazon.awssdk.services.kafkaconnect.model.ConnectorState; import software.amazon.awssdk.services.kafkaconnect.model.DescribeConnectorRequest; import software.amazon.awssdk.services.kafkaconnect.model.DescribeConnectorResponse; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceResponse; import software.amazon.awssdk.services.kafkaconnect.model.NotFoundException; import software.amazon.cloudformation.exceptions.CfnNotFoundException; import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; @@ -61,14 +64,16 @@ public void tear_down() { } @Test - public void handleRequest_returnsConnectorWhenResourceModelIsPassed_success() { + public void handleRequest_returnsConnectorWhenResourceModelIsPassedAndNonEmptyTags_success() { when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) .thenReturn(TestData.DESCRIBE_CONNECTOR_REQUEST); when(proxyClient.injectCredentialsAndInvokeV2(TestData.DESCRIBE_CONNECTOR_REQUEST, kafkaConnectClient::describeConnector)) .thenReturn(TestData.DESCRIBE_CONNECTOR_RESPONSE); when(translator.translateFromReadResponse(TestData.DESCRIBE_CONNECTOR_RESPONSE)) - .thenReturn(TestData.RESPONSE_RESOURCE_MODEL); + .thenReturn(TestData.RESPONSE_RESOURCE_MODEL_EMPTY_TAGS); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, + kafkaConnectClient::listTagsForResource)).thenReturn(TestData.LIST_TAGS_FOR_RESOURCE_RESPONSE); final ProgressEvent response = handler.handleRequest(proxy, TestData.RESOURCE_HANDLER_REQUEST, new CallbackContext(), proxyClient, logger); @@ -76,6 +81,24 @@ public void handleRequest_returnsConnectorWhenResourceModelIsPassed_success() { assertThat(response).isEqualTo(TestData.EXPECTED_RESPONSE); } + @Test + public void handleRequest_returnsConnectorWhenResourceModelIsPassedAndEmptyTags_success() { + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_CONNECTOR_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.DESCRIBE_CONNECTOR_REQUEST, + kafkaConnectClient::describeConnector)) + .thenReturn(TestData.DESCRIBE_CONNECTOR_RESPONSE); + when(translator.translateFromReadResponse(TestData.DESCRIBE_CONNECTOR_RESPONSE)) + .thenReturn(TestData.RESPONSE_RESOURCE_MODEL_EMPTY_TAGS); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, + kafkaConnectClient::listTagsForResource)).thenReturn(TestData.LIST_TAGS_FOR_RESOURCE_RESPONSE_EMPTY_TAGS); + + final ProgressEvent response = handler.handleRequest(proxy, + TestData.RESOURCE_HANDLER_REQUEST, new CallbackContext(), proxyClient, logger); + + assertThat(response).isEqualTo(TestData.EXPECTED_RESPONSE_EMPTY_TAGS); + } + @Test public void handleRequest_throwsCfnNotFoundException_whenDescribeConnectorFails() { final NotFoundException serviceException = NotFoundException.builder().build(); @@ -94,6 +117,27 @@ public void handleRequest_throwsCfnNotFoundException_whenDescribeConnectorFails( assertThat(exception).isEqualTo(cfnException); } + @Test + public void handleRequest_throwsCfnNotFoundException_whenListTagsForResourceFails() { + final NotFoundException serviceException = NotFoundException.builder().build(); + final CfnNotFoundException cfnException = new CfnNotFoundException(serviceException); + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_CONNECTOR_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.DESCRIBE_CONNECTOR_REQUEST, + kafkaConnectClient::describeConnector)) + .thenReturn(TestData.DESCRIBE_CONNECTOR_RESPONSE); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, + kafkaConnectClient::listTagsForResource)).thenThrow(serviceException); + when(exceptionTranslator.translateToCfnException(serviceException, TestData.CONNECTOR_ARN)) + .thenReturn(cfnException); + + final CfnNotFoundException exception = assertThrows(CfnNotFoundException.class, () -> handler + .handleRequest(proxy, TestData.RESOURCE_HANDLER_REQUEST, new CallbackContext(), proxyClient, logger) + ); + + assertThat(exception).isEqualTo(cfnException); + } + private static class TestData { private static final String CONNECTOR_NAME = "unit-test-connector"; private static final String CONNECTOR_ARN = @@ -103,8 +147,15 @@ private static class TestData { .builder() .connectorArn(CONNECTOR_ARN) .connectorName(CONNECTOR_NAME) + .tags(TagHelper.convertToSet(TAGS)) .build(); + private static final ResourceModel RESPONSE_RESOURCE_MODEL_EMPTY_TAGS = ResourceModel + .builder() + .connectorArn(CONNECTOR_ARN) + .connectorName(CONNECTOR_NAME) + .build(); + private static final ResourceModel RESOURCE_MODEL = ResourceModel .builder() .connectorArn(CONNECTOR_ARN) @@ -133,5 +184,26 @@ private static class TestData { .status(OperationStatus.SUCCESS) .resourceModel(RESPONSE_RESOURCE_MODEL) .build(); + + private static final ProgressEvent EXPECTED_RESPONSE_EMPTY_TAGS = + ProgressEvent.builder() + .status(OperationStatus.SUCCESS) + .resourceModel(RESPONSE_RESOURCE_MODEL_EMPTY_TAGS) + .build(); + + private static final ListTagsForResourceRequest LIST_TAGS_FOR_RESOURCE_REQUEST = + ListTagsForResourceRequest.builder() + .resourceArn(CONNECTOR_ARN) + .build(); + + private static final ListTagsForResourceResponse LIST_TAGS_FOR_RESOURCE_RESPONSE = + ListTagsForResourceResponse.builder() + .tags(TAGS) + .build(); + + private static final ListTagsForResourceResponse LIST_TAGS_FOR_RESOURCE_RESPONSE_EMPTY_TAGS = + ListTagsForResourceResponse.builder() + .tags(new HashMap<>()) + .build(); } } diff --git a/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/TranslatorTest.java b/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/TranslatorTest.java index fe77fd5..89980eb 100644 --- a/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/TranslatorTest.java +++ b/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/TranslatorTest.java @@ -33,6 +33,8 @@ import software.amazon.awssdk.services.kafkaconnect.model.ScaleInPolicyUpdate; import software.amazon.awssdk.services.kafkaconnect.model.ScaleOutPolicyDescription; import software.amazon.awssdk.services.kafkaconnect.model.ScaleOutPolicyUpdate; +import software.amazon.awssdk.services.kafkaconnect.model.TagResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.UntagResourceRequest; import software.amazon.awssdk.services.kafkaconnect.model.UpdateConnectorRequest; import software.amazon.awssdk.services.kafkaconnect.model.VpcDescription; import software.amazon.awssdk.services.kafkaconnect.model.WorkerConfigurationDescription; @@ -49,6 +51,7 @@ import static java.util.Arrays.asList; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertTrue; +import static software.amazon.kafkaconnect.connector.AbstractTestBase.TAGS; @ExtendWith(MockitoExtension.class) public class TranslatorTest { @@ -57,13 +60,17 @@ public class TranslatorTest { @Test public void translateToCreateRequest_fullConnector_success() { - compareCreateRequest(translator.translateToCreateRequest(TestData.FULL_RESOURCE_CREATE_MODEL), + compareCreateRequest( + translator.translateToCreateRequest(TestData.FULL_RESOURCE_CREATE_MODEL, + TagHelper.convertToMap(TestData.FULL_RESOURCE_CREATE_MODEL.getTags())), TestData.COMPLETE_CREATE_CONNECTOR_REQUEST); } @Test public void translateToCreateRequest_minimalConnector_success() { - compareCreateRequest(translator.translateToCreateRequest(TestData.MINIMAL_RESOURCE_CREATE_MODEL), + compareCreateRequest( + translator.translateToCreateRequest(TestData.MINIMAL_RESOURCE_CREATE_MODEL, + TagHelper.convertToMap(TestData.MINIMAL_RESOURCE_CREATE_MODEL.getTags())), TestData.MINIMAL_CREATE_CONNECTOR_REQUEST); } @@ -109,6 +116,20 @@ public void translateToUpdateRequest_success() { .isEqualTo(TestData.UPDATE_CONNECTOR_REQUEST); } + @Test + public void translateToTagResourceRequest_success() { + assertThat(Translator.tagResourceRequest(TestData.TAG_RESOURCE_REQUEST_RESOURCE_MODEL, TAGS)) + .isEqualTo(TestData.TAG_RESOURCE_REQUEST); + } + + @Test + public void translateToUntagResourceRequest_success() { + final Set tagsToUntag = new HashSet<>(); + tagsToUntag.add(TestData.TAG_KEY); + assertThat(Translator.untagResourceRequest(TestData.UNTAG_RESOURCE_REQUEST_RESOURCE_MODEL, tagsToUntag)) + .isEqualTo(TestData.UNTAG_RESOURCE_REQUEST); + } + private void compareCreateRequest(final CreateConnectorRequest request1, final CreateConnectorRequest request2) { // compare all fields without arrays assertThat(request1.capacity()).isEqualTo(request2.capacity()); @@ -187,6 +208,7 @@ private static class TestData { OffsetDateTime.parse("2021-03-04T14:03:40.818Z").toInstant(); private static final String CURRENT_VERSION = "AB1CDQEFGHZ5"; private static final String NEXT_TOKEN = "1234abcd"; + private static final String TAG_KEY = "key"; private static final int MAX_WORKER_COUNT = 10; private static final int MIN_WORKER_COUNT = 1; private static final int SCALE_IN_UTIL_PERCENT = 75; @@ -771,5 +793,27 @@ private static WorkerConfigurationDescription workerConfigurationDescription() { .workerConfigurationArn(WORKER_CONFIGURATION_ARN) .build(); } + + private static final ResourceModel TAG_RESOURCE_REQUEST_RESOURCE_MODEL = + ResourceModel.builder() + .connectorArn(CONNECTOR_ARN) + .build(); + + private static final ResourceModel UNTAG_RESOURCE_REQUEST_RESOURCE_MODEL = + ResourceModel.builder() + .connectorArn(CONNECTOR_ARN) + .build(); + + private static final TagResourceRequest TAG_RESOURCE_REQUEST = + TagResourceRequest.builder() + .tags(TAGS) + .resourceArn(CONNECTOR_ARN) + .build(); + + private static final UntagResourceRequest UNTAG_RESOURCE_REQUEST = + UntagResourceRequest.builder() + .tagKeys(TAG_KEY) + .resourceArn(CONNECTOR_ARN) + .build(); } } diff --git a/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/UpdateHandlerTest.java b/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/UpdateHandlerTest.java index f37e146..ca9921d 100644 --- a/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/UpdateHandlerTest.java +++ b/aws-kafkaconnect-connector/src/test/java/software/amazon/kafkaconnect/connector/UpdateHandlerTest.java @@ -18,10 +18,16 @@ import software.amazon.awssdk.services.kafkaconnect.model.KafkaClusterClientAuthenticationDescription; import software.amazon.awssdk.services.kafkaconnect.model.KafkaClusterDescription; import software.amazon.awssdk.services.kafkaconnect.model.KafkaClusterEncryptionInTransitDescription; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceResponse; import software.amazon.awssdk.services.kafkaconnect.model.NotFoundException; import software.amazon.awssdk.services.kafkaconnect.model.PluginDescription; import software.amazon.awssdk.services.kafkaconnect.model.ProvisionedCapacityDescription; import software.amazon.awssdk.services.kafkaconnect.model.ProvisionedCapacityUpdate; +import software.amazon.awssdk.services.kafkaconnect.model.TagResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.TagResourceResponse; +import software.amazon.awssdk.services.kafkaconnect.model.UntagResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.UntagResourceResponse; import software.amazon.awssdk.services.kafkaconnect.model.UpdateConnectorRequest; import software.amazon.awssdk.services.kafkaconnect.model.UpdateConnectorResponse; import software.amazon.awssdk.services.kafkaconnect.model.VpcDescription; @@ -41,12 +47,15 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import static java.util.Arrays.asList; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @@ -130,6 +139,8 @@ public void handleRequest_afterNRoundsOfUpdatingStabilization_success() { .thenReturn(updatingDescribeConnectorResponse) .thenReturn(updatingDescribeConnectorResponse) .thenReturn(runningDescribeConnectorResponse); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, + kafkaConnectClient::listTagsForResource)).thenReturn(TestData.LIST_TAGS_FOR_RESOURCE_RESPONSE); final ProgressEvent response = handler.handleRequest(proxy, TestData.resourceHandlerRequest(), new CallbackContext(), proxyClient, logger); @@ -139,6 +150,230 @@ public void handleRequest_afterNRoundsOfUpdatingStabilization_success() { assertThat(response).isEqualTo(expected); } + @Test + public void handleRequest_addNewTags_success() { + final Set tagsSet = new HashSet<>(); + tagsSet.add(Tag.builder().key(TestData.CONNECTOR_TAG_KEY).value(TestData.CONNECTOR_TAG_VALUE).build()); + final Map tagsMap = new HashMap<>(); + tagsMap.put(TestData.CONNECTOR_TAG_KEY, TestData.CONNECTOR_TAG_VALUE); + final ResourceModel model = TestData.resourceModelWithName(TestData.CONNECTOR_NAME).toBuilder() + .tags(tagsSet) + .build(); + final ResourceModel previousModel = TestData.resourceModelWithName(TestData.CONNECTOR_NAME).toBuilder() + .build(); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, + kafkaConnectClient::listTagsForResource)).thenReturn(ListTagsForResourceResponse + .builder() + .tags(tagsMap) + .build()); + when(translator.translateToReadRequest(model)) + .thenReturn(TestData.describeConnectorRequest()); + final DescribeConnectorResponse unchangedDescribeConnectorResponse = + TestData.unchangedDescribeConnectorResponse(TestData.unchangedCapacityDescription(), ConnectorState.RUNNING); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.describeConnectorRequest(), kafkaConnectClient::describeConnector + )).thenReturn(unchangedDescribeConnectorResponse); + when(translator.translateFromReadResponse(unchangedDescribeConnectorResponse)) + .thenReturn(model); + when(proxyClient.client().tagResource(any(TagResourceRequest.class))) + .thenReturn(TagResourceResponse.builder().build()); + + final ProgressEvent response = handler.handleRequest(proxy, + TestData.resourceHandlerRequest(model, previousModel), new CallbackContext(), proxyClient, logger); + + final ProgressEvent expected = + TestData.describeResponse(model); + assertThat(response).isEqualTo(expected); + verify(proxyClient.client(), times(1)).tagResource(any(TagResourceRequest.class)); + verify(proxyClient.client(), never()).untagResource(any(UntagResourceRequest.class)); + verify(proxyClient.client(), times(2)).describeConnector(any(DescribeConnectorRequest.class)); + verify(proxyClient.client(), times(1)).listTagsForResource(any(ListTagsForResourceRequest.class)); + } + + + @Test + public void handleRequest_updateTags_success() { + final Set tagsSet = new HashSet<>(); + tagsSet.add(Tag.builder().key(TestData.CONNECTOR_TAG_KEY).value(TestData.CONNECTOR_TAG_VALUE).build()); + final Set prevTagsSet = new HashSet<>(); + prevTagsSet.add(Tag.builder().key(TestData.CONNECTOR_TAG_KEY).value("OLD_VALUE").build()); + final Map tagsMap = new HashMap<>(); + tagsMap.put(TestData.CONNECTOR_TAG_KEY, TestData.CONNECTOR_TAG_VALUE); + final ResourceModel model = TestData.resourceModelWithName(TestData.CONNECTOR_NAME).toBuilder() + .tags(tagsSet) + .build(); + final ResourceModel previousModel = TestData.resourceModelWithName(TestData.CONNECTOR_NAME).toBuilder() + .tags(prevTagsSet) + .build(); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, + kafkaConnectClient::listTagsForResource)).thenReturn(ListTagsForResourceResponse + .builder() + .tags(tagsMap) + .build()); + when(translator.translateToReadRequest(model)) + .thenReturn(TestData.describeConnectorRequest()); + final DescribeConnectorResponse unchangedDescribeConnectorResponse = + TestData.unchangedDescribeConnectorResponse(TestData.unchangedCapacityDescription(), ConnectorState.RUNNING); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.describeConnectorRequest(), kafkaConnectClient::describeConnector + )).thenReturn(unchangedDescribeConnectorResponse); + when(translator.translateFromReadResponse(unchangedDescribeConnectorResponse)) + .thenReturn(model); + when(proxyClient.client().tagResource(any(TagResourceRequest.class))) + .thenReturn(TagResourceResponse.builder().build()); + + final ProgressEvent response = handler.handleRequest(proxy, + TestData.resourceHandlerRequest(model, previousModel), new CallbackContext(), proxyClient, logger); + + final ProgressEvent expected = + TestData.describeResponse(model); + assertThat(response).isEqualTo(expected); + verify(proxyClient.client(), times(2)).describeConnector(any(DescribeConnectorRequest.class)); + verify(proxyClient.client(), times(1)).listTagsForResource(any(ListTagsForResourceRequest.class)); + verify(proxyClient.client(), never()).untagResource(any(UntagResourceRequest.class)); + verify(proxyClient.client(), times(1)).tagResource(any(TagResourceRequest.class)); + } + + @Test + public void handleRequest_removeTags_success() { + final Set tagsSet = new HashSet<>(); + tagsSet.add(Tag.builder().key(TestData.CONNECTOR_TAG_KEY).value(TestData.CONNECTOR_TAG_VALUE).build()); + final Map tagsMap = new HashMap<>(); + tagsMap.put(TestData.CONNECTOR_TAG_KEY, TestData.CONNECTOR_TAG_VALUE); + final ResourceModel model = TestData.resourceModelWithName(TestData.CONNECTOR_NAME).toBuilder() + .build(); + final ResourceModel previousModel = TestData.resourceModelWithName(TestData.CONNECTOR_NAME).toBuilder() + .tags(tagsSet) + .build(); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, + kafkaConnectClient::listTagsForResource)).thenReturn(ListTagsForResourceResponse + .builder() + .build()); + when(translator.translateToReadRequest(model)) + .thenReturn(TestData.describeConnectorRequest()); + final DescribeConnectorResponse unchangedDescribeConnectorResponse = + TestData.unchangedDescribeConnectorResponse(TestData.unchangedCapacityDescription(), ConnectorState.RUNNING); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.describeConnectorRequest(), kafkaConnectClient::describeConnector + )).thenReturn(unchangedDescribeConnectorResponse); + when(translator.translateFromReadResponse(unchangedDescribeConnectorResponse)) + .thenReturn(model); + when(proxyClient.client().untagResource(any(UntagResourceRequest.class))) + .thenReturn(UntagResourceResponse.builder().build()); + + final ProgressEvent response = handler.handleRequest(proxy, + TestData.resourceHandlerRequest(model, previousModel), new CallbackContext(), proxyClient, logger); + + final ProgressEvent expected = + TestData.describeResponse(model); + assertThat(response).isEqualTo(expected); + verify(proxyClient.client(), times(2)).describeConnector(any(DescribeConnectorRequest.class)); + verify(proxyClient.client(), times(1)).listTagsForResource(any(ListTagsForResourceRequest.class)); + verify(proxyClient.client(), times(1)).untagResource(any(UntagResourceRequest.class)); + verify(proxyClient.client(), never()).tagResource(any(TagResourceRequest.class)); + } + + @Test + public void handleRequest_addRemoveTags_success() { + final String tagKeyRemove = "TEST_KEY_REMOVE"; + final String tagValueRemove = "TEST_VALUE_REMOVE"; + final Set tagsSet = new HashSet<>(); + tagsSet.add(Tag.builder().key(TestData.CONNECTOR_TAG_KEY).value(TestData.CONNECTOR_TAG_VALUE).build()); + final Set prevTagsSet = new HashSet<>(); + prevTagsSet.add(Tag.builder().key(tagKeyRemove).value(tagValueRemove).build()); + final Map tagsMap = new HashMap<>(); + tagsMap.put(TestData.CONNECTOR_TAG_KEY, TestData.CONNECTOR_TAG_VALUE); + final ResourceModel model = TestData.resourceModelWithName(TestData.CONNECTOR_NAME).toBuilder() + .tags(tagsSet) + .build(); + final ResourceModel previousModel = TestData.resourceModelWithName(TestData.CONNECTOR_NAME).toBuilder() + .tags(prevTagsSet) + .build(); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, + kafkaConnectClient::listTagsForResource)).thenReturn(ListTagsForResourceResponse + .builder() + .tags(tagsMap) + .build()); + when(translator.translateToReadRequest(model)) + .thenReturn(TestData.describeConnectorRequest()); + final DescribeConnectorResponse unchangedDescribeConnectorResponse = + TestData.unchangedDescribeConnectorResponse(TestData.unchangedCapacityDescription(), ConnectorState.RUNNING); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.describeConnectorRequest(), kafkaConnectClient::describeConnector + )).thenReturn(unchangedDescribeConnectorResponse); + when(translator.translateFromReadResponse(unchangedDescribeConnectorResponse)) + .thenReturn(model); + when(proxyClient.client().untagResource(any(UntagResourceRequest.class))) + .thenReturn(UntagResourceResponse.builder().build()); + + final ProgressEvent response = handler.handleRequest(proxy, + TestData.resourceHandlerRequest(model, previousModel), new CallbackContext(), proxyClient, logger); + + final ProgressEvent expected = + TestData.describeResponse(model); + assertThat(response).isEqualTo(expected); + verify(proxyClient.client(), times(2)).describeConnector(any(DescribeConnectorRequest.class)); + verify(proxyClient.client(), times(1)).listTagsForResource(any(ListTagsForResourceRequest.class)); + verify(proxyClient.client(), times(1)).untagResource(any(UntagResourceRequest.class)); + verify(proxyClient.client(), times(1)).tagResource(any(TagResourceRequest.class)); + } + + @Test + public void handleRequest_updateBothTagsAndCapacity_success() { + final Set tagsSet = new HashSet<>(); + tagsSet.add(Tag.builder().key(TestData.CONNECTOR_TAG_KEY).value(TestData.CONNECTOR_TAG_VALUE).build()); + final Map tagsMap = new HashMap<>(); + tagsMap.put(TestData.CONNECTOR_TAG_KEY, TestData.CONNECTOR_TAG_VALUE); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, + kafkaConnectClient::listTagsForResource)).thenReturn(ListTagsForResourceResponse + .builder() + .tags(tagsMap) + .build()); + final ResourceModel requestResourceModel = TestData.resourceModelWithCapacity( + TestData.updatedCapacityOnlyProvisionedCapacity()).toBuilder() + .tags(tagsSet).build(); + final DescribeConnectorResponse unchangedDescribeConnectorResponse = + TestData.unchangedDescribeConnectorResponse(TestData.unchangedCapacityDescription(), + ConnectorState.RUNNING); + final ResourceModel unchangedDescribeResponseTranslatedToResourceModel = + TestData.resourceModelWithCapacity(TestData.unchangedCapacity()); + final DescribeConnectorResponse updatedDescribeConnectorResponse = + TestData.updatedDescribeConnectorResponse(TestData.capacityDescriptionOnlyProvisionedCapacity(), + ConnectorState.RUNNING); + final ResourceModel updatedResourceModel = + TestData.resourceModelWithCapacity(TestData.updatedCapacityOnlyProvisionedCapacity()).toBuilder() + .tags(tagsSet).build(); + final DescribeConnectorRequest describeConnectorRequest = TestData.describeConnectorRequest(); + setupTranslateToReadMockWithMultipleInputs(requestResourceModel, + unchangedDescribeResponseTranslatedToResourceModel, describeConnectorRequest); + when(proxyClient.injectCredentialsAndInvokeV2( + describeConnectorRequest, + kafkaConnectClient::describeConnector + )) + .thenReturn(unchangedDescribeConnectorResponse) + .thenReturn(unchangedDescribeConnectorResponse) + .thenReturn(updatedDescribeConnectorResponse); + setupTranslateFromReadMockWithMultipleInputs( + asList(unchangedDescribeConnectorResponse, updatedDescribeConnectorResponse), + asList(unchangedDescribeResponseTranslatedToResourceModel, updatedResourceModel)); + setupMocksForUpdateConnectorSuccess(updatedResourceModel); + when(proxyClient.client().tagResource(any(TagResourceRequest.class))) + .thenReturn(TagResourceResponse.builder().build()); + + final ProgressEvent response = handler.handleRequest(proxy, + TestData.resourceHandlerRequest(requestResourceModel, unchangedDescribeResponseTranslatedToResourceModel), + new CallbackContext(), proxyClient, logger); + + final ProgressEvent expected = + TestData.describeResponse(updatedResourceModel); + assertThat(response).isEqualTo(expected); + verify(proxyClient.client(), times(4)).describeConnector(any(DescribeConnectorRequest.class)); + verify(proxyClient.client(), times(1)).updateConnector(any(UpdateConnectorRequest.class)); + verify(proxyClient.client(), times(1)).listTagsForResource(any(ListTagsForResourceRequest.class)); + verify(proxyClient.client(), never()).untagResource(any(UntagResourceRequest.class)); + verify(proxyClient.client(), times(1)).tagResource(any(TagResourceRequest.class)); + } + @Test public void handlerRequest_throwsCfnDoNotExistException_whenConnectorDoesNotExist() { final NotFoundException serviceException = NotFoundException.builder().build(); @@ -258,24 +493,6 @@ public void handleRequest_throwsCfnGeneralServiceException_whenUpdateAutoScaling "update %s due to update failure. Resource reverted to previous state'.", ResourceModel.TYPE_NAME)); } - @Test - public void handleRequest_throwsCfnNotUpdatableException_whenConnectorNotRunning() { - final ResourceModel resourceModel = TestData.resourceModelWithCapacity(TestData - .updatedCapacityOnlyProvisionedCapacity()); - final DescribeConnectorResponse describeConnectorResponse = TestData.unchangedDescribeConnectorResponse( - TestData.capacityDescriptionOnlyProvisionedCapacity(), ConnectorState.FAILED); - final DescribeConnectorRequest describeConnectorRequest = TestData.describeConnectorRequest(); - when(translator.translateToReadRequest(resourceModel)).thenReturn(describeConnectorRequest); - when(proxyClient.injectCredentialsAndInvokeV2( - describeConnectorRequest, - kafkaConnectClient::describeConnector) - ).thenReturn(describeConnectorResponse); - - runHandlerAndAssertExceptionThrownWithMessage(TestData.resourceHandlerRequest(), - CfnNotUpdatableException.class, String.format("Resource of type '%s' with identifier '%s' is not " + - "updatable with parameters provided.", ResourceModel.TYPE_NAME, TestData.CONNECTOR_ARN)); - } - @Test public void handlerRequest_throwsCfnNotUpdatableException_whenUpdateCreateOnlyProperty() { setupDescribeMocksForSuccess(TestData.resourceModelWithName(TestData.CONNECTOR_NAME), @@ -397,10 +614,10 @@ private void runHandlerAndAssertExceptionThrownWithMessage( private void runSuccessfulHandleRequest(final ResourceModel requestResourceModel) { final DescribeConnectorResponse unchangedDescribeConnectorResponse = - TestData.unchangedDescribeConnectorResponse(TestData.capacityDescriptionOnlyProvisionedCapacity(), + TestData.unchangedDescribeConnectorResponse(TestData.unchangedCapacityDescription(), ConnectorState.RUNNING); final ResourceModel unchangedDescribeResponseTranslatedToResourceModel = - TestData.resourceModelWithCapacity(TestData.updatedCapacityOnlyProvisionedCapacity()); + TestData.resourceModelWithCapacity(TestData.unchangedCapacity()); final DescribeConnectorResponse updatedDescribeConnectorResponse = TestData.updatedDescribeConnectorResponse(TestData.capacityDescriptionOnlyProvisionedCapacity(), ConnectorState.RUNNING); @@ -419,7 +636,9 @@ private void runSuccessfulHandleRequest(final ResourceModel requestResourceModel setupTranslateFromReadMockWithMultipleInputs( asList(unchangedDescribeConnectorResponse, updatedDescribeConnectorResponse), asList(unchangedDescribeResponseTranslatedToResourceModel, updatedResourceModel)); - setupMocksForUpdateConnectorSuccess(unchangedDescribeResponseTranslatedToResourceModel); + setupMocksForUpdateConnectorSuccess(updatedResourceModel); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, + kafkaConnectClient::listTagsForResource)).thenReturn(TestData.LIST_TAGS_FOR_RESOURCE_RESPONSE); final ProgressEvent response = handler.handleRequest(proxy, TestData.resourceHandlerRequest(), new CallbackContext(), proxyClient, logger); @@ -528,12 +747,15 @@ private static class TestData { ResourceHandlerRequest .builder() .desiredResourceState(resourceModelWithCapacity(capacityOnlyAutoscaling())) + .previousResourceState(resourceModelWithCapacity(UNCHANGED_CAPACITY_ONLY_AUTOSCALING)) .build(); private static final ResourceHandlerRequest CREATE_ONLY_UPDATE_HANDLER_REQUEST = ResourceHandlerRequest .builder() .desiredResourceState(resourceModelWithName(CONNECTOR_NAME)) .build(); + private static final String CONNECTOR_TAG_KEY = "unit-test-key"; + private static final String CONNECTOR_TAG_VALUE = "unit-test-value"; private static Capacity unchangedCapacity() { return Capacity.builder() @@ -600,9 +822,19 @@ private static ResourceHandlerRequest resourceHandlerRequest() { return ResourceHandlerRequest .builder() .desiredResourceState(resourceModelWithCapacity(updatedCapacityOnlyProvisionedCapacity())) + .previousResourceState(resourceModelWithCapacity(unchangedCapacity())) .build(); } + private static ResourceHandlerRequest resourceHandlerRequest(final ResourceModel desiredMorel, + final ResourceModel previousModel) { + return ResourceHandlerRequest + .builder() + .desiredResourceState(desiredMorel) + .previousResourceState(previousModel) + .build(); + } + private static UpdateConnectorResponse updateConnectorResponse() { return UpdateConnectorResponse.builder() .connectorArn(CONNECTOR_ARN) @@ -616,7 +848,7 @@ private static DescribeConnectorRequest describeConnectorRequest() { .build(); } - private static final ProgressEvent describeResponse( + private static ProgressEvent describeResponse( final ResourceModel resourceModel){ return ProgressEvent.builder() @@ -690,5 +922,16 @@ private static DescribeConnectorResponse describeConnectorResponse( .serviceExecutionRoleArn(SERVICE_EXECUTION_ROLE_ARN) .build(); } + + private static final ListTagsForResourceRequest LIST_TAGS_FOR_RESOURCE_REQUEST = + ListTagsForResourceRequest.builder() + .resourceArn(CONNECTOR_ARN) + .build(); + + private static final ListTagsForResourceResponse LIST_TAGS_FOR_RESOURCE_RESPONSE = + ListTagsForResourceResponse + .builder() + .tags(TAGS) + .build(); } } diff --git a/aws-kafkaconnect-connector/template.yml b/aws-kafkaconnect-connector/template.yml index bf3876c..83ee12b 100644 --- a/aws-kafkaconnect-connector/template.yml +++ b/aws-kafkaconnect-connector/template.yml @@ -5,19 +5,19 @@ Description: AWS SAM template for the AWS::KafkaConnect::Connector resource type Globals: Function: Timeout: 180 # docker start-up times can be long for SAM CLI - MemorySize: 256 + MemorySize: 512 Resources: TypeFunction: Type: AWS::Serverless::Function Properties: Handler: software.amazon.kafkaconnect.connector.HandlerWrapper::handleRequest - Runtime: java8 - CodeUri: ./target/aws-kafkaconnect-connector-handler-1.0-SNAPSHOT.jar + Runtime: java17 + CodeUri: ./target/aws-kafkaconnect-connector-1.0.jar TestEntrypoint: Type: AWS::Serverless::Function Properties: Handler: software.amazon.kafkaconnect.connector.HandlerWrapper::testEntrypoint - Runtime: java8 - CodeUri: ./target/aws-kafkaconnect-connector-handler-1.0-SNAPSHOT.jar + Runtime: java17 + CodeUri: ./target/aws-kafkaconnect-connector-1.0.jar diff --git a/aws-kafkaconnect-customplugin/.gitignore b/aws-kafkaconnect-customplugin/.gitignore new file mode 100644 index 0000000..faa9259 --- /dev/null +++ b/aws-kafkaconnect-customplugin/.gitignore @@ -0,0 +1,23 @@ +# macOS +.DS_Store +._* + +# Maven outputs +.classpath + +# IntelliJ +*.iml +.idea +out.java +out/ +.settings +.project + +# auto-generated files +target/ + +# our logs +rpdk.log* + +# contains credentials +sam-tests/ diff --git a/aws-kafkaconnect-customplugin/.rpdk-config b/aws-kafkaconnect-customplugin/.rpdk-config new file mode 100644 index 0000000..9598d4c --- /dev/null +++ b/aws-kafkaconnect-customplugin/.rpdk-config @@ -0,0 +1,30 @@ +{ + "artifact_type": "RESOURCE", + "typeName": "AWS::KafkaConnect::CustomPlugin", + "language": "java", + "runtime": "java17", + "entrypoint": "software.amazon.kafkaconnect.customplugin.HandlerWrapper::handleRequest", + "testEntrypoint": "software.amazon.kafkaconnect.customplugin.HandlerWrapper::testEntrypoint", + "settings": { + "version": false, + "subparser_name": null, + "verbose": 0, + "force": false, + "type_name": null, + "artifact_type": null, + "endpoint_url": null, + "region": null, + "target_schemas": [], + "profile": null, + "namespace": [ + "software", + "amazon", + "kafkaconnect", + "customplugin" + ], + "codegen_template_path": "guided_aws", + "protocolVersion": "2.0.0" + }, + "logProcessorEnabled": "true", + "executableEntrypoint": "software.amazon.kafkaconnect.customplugin.HandlerWrapperExecutable" +} diff --git a/aws-kafkaconnect-customplugin/README.md b/aws-kafkaconnect-customplugin/README.md new file mode 100644 index 0000000..cefabc3 --- /dev/null +++ b/aws-kafkaconnect-customplugin/README.md @@ -0,0 +1,12 @@ +# AWS::KafkaConnect::CustomPlugin + +Congratulations on starting development! Next steps: + +1. Write the JSON schema describing your resource, `aws-kafkaconnect-customplugin.json` +1. Implement your resource handlers. + +The RPDK will automatically generate the correct resource model from the schema whenever the project is built via Maven. You can also do this manually with the following command: `cfn generate`. + +> Please don't modify files under `target/generated-sources/rpdk`, as they will be automatically overwritten. + +The code uses [Lombok](https://projectlombok.org/), and [you may have to install IDE integrations](https://projectlombok.org/setup/overview) to enable auto-complete for Lombok-annotated classes. diff --git a/aws-kafkaconnect-customplugin/aws-kafkaconnect-customplugin.json b/aws-kafkaconnect-customplugin/aws-kafkaconnect-customplugin.json new file mode 100644 index 0000000..b72f8b3 --- /dev/null +++ b/aws-kafkaconnect-customplugin/aws-kafkaconnect-customplugin.json @@ -0,0 +1,199 @@ +{ + "typeName": "AWS::KafkaConnect::CustomPlugin", + "description": "An example resource schema demonstrating some basic constructs and validation rules.", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-rpdk.git", + "definitions": { + "Tag": { + "description": "A key-value pair to associate with a resource.", + "type": "object", + "properties": { + "Key": { + "type": "string", + "description": "The key name of the tag. You can specify a value that is 1 to 128 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "type": "string", + "description": "The value for the tag. You can specify a value that is 0 to 256 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", + "minLength": 0, + "maxLength": 256 + } + }, + "required": [ + "Key", + "Value" + ], + "additionalProperties": false + }, + "CustomPluginFileDescription": { + "description": "Details about the custom plugin file.", + "type": "object", + "additionalProperties": false, + "properties": { + "FileMd5": { + "description": "The hex-encoded MD5 checksum of the custom plugin file. You can use it to validate the file.", + "type": "string" + }, + "FileSize": { + "description": "The size in bytes of the custom plugin file. You can use it to validate the file.", + "type": "integer", + "format": "int64" + } + } + }, + "CustomPluginLocation": { + "description": "Information about the location of a custom plugin.", + "type": "object", + "additionalProperties": false, + "properties": { + "S3Location": { + "$ref": "#/definitions/S3Location" + } + }, + "required": [ + "S3Location" + ] + }, + "S3Location": { + "description": "The S3 bucket Amazon Resource Name (ARN), file key, and object version of the plugin file stored in Amazon S3.", + "type": "object", + "additionalProperties": false, + "properties": { + "BucketArn": { + "type": "string", + "description": "The Amazon Resource Name (ARN) of an S3 bucket." + }, + "FileKey": { + "type": "string", + "description": "The file key for an object in an S3 bucket." + }, + "ObjectVersion": { + "type": "string", + "description": "The version of an object in an S3 bucket." + } + }, + "required": [ + "BucketArn", + "FileKey" + ] + } + }, + "properties": { + "Name": { + "description": "The name of the custom plugin.", + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "Description": { + "description": "A summary description of the custom plugin.", + "type": "string", + "maxLength": 1024 + }, + "CustomPluginArn": { + "description": "The Amazon Resource Name (ARN) of the custom plugin to use.", + "type": "string", + "pattern": "arn:(aws|aws-us-gov|aws-cn):kafkaconnect:.*" + }, + "ContentType": { + "description": "The type of the plugin file.", + "type": "string", + "enum": [ + "JAR", + "ZIP" + ] + }, + "FileDescription": { + "$ref": "#/definitions/CustomPluginFileDescription" + }, + "Location": { + "$ref": "#/definitions/CustomPluginLocation" + }, + "Revision": { + "description": "The revision of the custom plugin.", + "type": "integer", + "format": "int64" + }, + "Tags": { + "description": "An array of key-value pairs to apply to this resource.", + "type": "array", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + } + }, + "additionalProperties": false, + "required": [ + "Name", + "ContentType", + "Location" + ], + "primaryIdentifier": [ + "/properties/CustomPluginArn" + ], + "additionalIdentifiers": [ + [ + "/properties/Name" + ] + ], + "readOnlyProperties": [ + "/properties/CustomPluginArn", + "/properties/Revision", + "/properties/FileDescription" + ], + "createOnlyProperties": [ + "/properties/Name", + "/properties/Description", + "/properties/ContentType", + "/properties/Location" + ], + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, + "handlers": { + "create": { + "permissions": [ + "kafkaconnect:DescribeCustomPlugin", + "kafkaconnect:ListTagsForResource", + "kafkaconnect:CreateCustomPlugin", + "kafkaconnect:TagResource", + "s3:GetObject", + "s3:GetObjectVersion", + "s3:GetObjectAttributes", + "s3:GetObjectVersionAttributes" + ] + }, + "read": { + "permissions": [ + "kafkaconnect:DescribeCustomPlugin", + "kafkaconnect:ListTagsForResource" + ] + }, + "update": { + "permissions": [ + "kafkaconnect:DescribeCustomPlugin", + "kafkaconnect:ListTagsForResource", + "kafkaconnect:TagResource", + "kafkaconnect:UntagResource" + ] + }, + "delete": { + "permissions": [ + "kafkaconnect:DeleteCustomPlugin", + "kafkaconnect:DescribeCustomPlugin" + ] + }, + "list": { + "permissions": [ + "kafkaconnect:ListCustomPlugins" + ] + } + } +} \ No newline at end of file diff --git a/aws-kafkaconnect-customplugin/checkstyle.xml b/aws-kafkaconnect-customplugin/checkstyle.xml new file mode 100644 index 0000000..631567a --- /dev/null +++ b/aws-kafkaconnect-customplugin/checkstyle.xml @@ -0,0 +1,360 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/aws-kafkaconnect-customplugin/docs/README.md b/aws-kafkaconnect-customplugin/docs/README.md new file mode 100644 index 0000000..e11eb2a --- /dev/null +++ b/aws-kafkaconnect-customplugin/docs/README.md @@ -0,0 +1,132 @@ +# AWS::KafkaConnect::CustomPlugin + +An example resource schema demonstrating some basic constructs and validation rules. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Type" : "AWS::KafkaConnect::CustomPlugin",
+    "Properties" : {
+        "Name" : String,
+        "Description" : String,
+        "ContentType" : String,
+        "FileDescription" : CustomPluginFileDescription,
+        "Location" : CustomPluginLocation,
+        "Tags" : [ Tag, ... ]
+    }
+}
+
+ +### YAML + +
+Type: AWS::KafkaConnect::CustomPlugin
+Properties:
+    Name: String
+    Description: String
+    ContentType: String
+    FileDescription: CustomPluginFileDescription
+    Location: CustomPluginLocation
+    Tags: 
+      - Tag
+
+ +## Properties + +#### Name + +The name of the custom plugin. + +_Required_: Yes + +_Type_: String + +_Minimum Length_: 1 + +_Maximum Length_: 128 + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### Description + +A summary description of the custom plugin. + +_Required_: No + +_Type_: String + +_Maximum Length_: 1024 + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### ContentType + +The type of the plugin file. + +_Required_: Yes + +_Type_: String + +_Allowed Values_: JAR | ZIP + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### FileDescription + +Details about the custom plugin file. + +_Required_: No + +_Type_: CustomPluginFileDescription + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Location + +Information about the location of a custom plugin. + +_Required_: Yes + +_Type_: CustomPluginLocation + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Tags + +An array of key-value pairs to apply to this resource. + +_Required_: No + +_Type_: List of Tag + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +## Return Values + +### Ref + +When you pass the logical ID of this resource to the intrinsic `Ref` function, Ref returns the CustomPluginArn. + +### Fn::GetAtt + +The `Fn::GetAtt` intrinsic function returns a value for a specified attribute of this type. The following are the available attributes and sample return values. + +For more information about using the `Fn::GetAtt` intrinsic function, see [Fn::GetAtt](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html). + +#### CustomPluginArn + +The Amazon Resource Name (ARN) of the custom plugin to use. + +#### Revision + +The revision of the custom plugin. + +#### FileDescription + +Details about the custom plugin file. + diff --git a/aws-kafkaconnect-customplugin/docs/custompluginfiledescription.md b/aws-kafkaconnect-customplugin/docs/custompluginfiledescription.md new file mode 100644 index 0000000..6efdf07 --- /dev/null +++ b/aws-kafkaconnect-customplugin/docs/custompluginfiledescription.md @@ -0,0 +1,46 @@ +# AWS::KafkaConnect::CustomPlugin CustomPluginFileDescription + +Details about the custom plugin file. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "FileMd5" : String,
+    "FileSize" : Integer
+}
+
+ +### YAML + +
+FileMd5: String
+FileSize: Integer
+
+ +## Properties + +#### FileMd5 + +The hex-encoded MD5 checksum of the custom plugin file. You can use it to validate the file. + +_Required_: No + +_Type_: String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### FileSize + +The size in bytes of the custom plugin file. You can use it to validate the file. + +_Required_: No + +_Type_: Integer + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-customplugin/docs/custompluginlocation.md b/aws-kafkaconnect-customplugin/docs/custompluginlocation.md new file mode 100644 index 0000000..533cc65 --- /dev/null +++ b/aws-kafkaconnect-customplugin/docs/custompluginlocation.md @@ -0,0 +1,34 @@ +# AWS::KafkaConnect::CustomPlugin CustomPluginLocation + +Information about the location of a custom plugin. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "S3Location" : S3Location
+}
+
+ +### YAML + +
+S3Location: S3Location
+
+ +## Properties + +#### S3Location + +The S3 bucket Amazon Resource Name (ARN), file key, and object version of the plugin file stored in Amazon S3. + +_Required_: Yes + +_Type_: S3Location + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-customplugin/docs/s3location.md b/aws-kafkaconnect-customplugin/docs/s3location.md new file mode 100644 index 0000000..a8dab96 --- /dev/null +++ b/aws-kafkaconnect-customplugin/docs/s3location.md @@ -0,0 +1,58 @@ +# AWS::KafkaConnect::CustomPlugin S3Location + +The S3 bucket Amazon Resource Name (ARN), file key, and object version of the plugin file stored in Amazon S3. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "BucketArn" : String,
+    "FileKey" : String,
+    "ObjectVersion" : String
+}
+
+ +### YAML + +
+BucketArn: String
+FileKey: String
+ObjectVersion: String
+
+ +## Properties + +#### BucketArn + +The Amazon Resource Name (ARN) of an S3 bucket. + +_Required_: Yes + +_Type_: String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### FileKey + +The file key for an object in an S3 bucket. + +_Required_: Yes + +_Type_: String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### ObjectVersion + +The version of an object in an S3 bucket. + +_Required_: No + +_Type_: String + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-customplugin/docs/tag.md b/aws-kafkaconnect-customplugin/docs/tag.md new file mode 100644 index 0000000..8e42c7f --- /dev/null +++ b/aws-kafkaconnect-customplugin/docs/tag.md @@ -0,0 +1,52 @@ +# AWS::KafkaConnect::CustomPlugin Tag + +A key-value pair to associate with a resource. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Key" : String,
+    "Value" : String
+}
+
+ +### YAML + +
+Key: String
+Value: String
+
+ +## Properties + +#### Key + +The key name of the tag. You can specify a value that is 1 to 128 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -. + +_Required_: Yes + +_Type_: String + +_Minimum Length_: 1 + +_Maximum Length_: 128 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Value + +The value for the tag. You can specify a value that is 0 to 256 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -. + +_Required_: Yes + +_Type_: String + +_Maximum Length_: 256 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-customplugin/lombok.config b/aws-kafkaconnect-customplugin/lombok.config new file mode 100644 index 0000000..7a21e88 --- /dev/null +++ b/aws-kafkaconnect-customplugin/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true diff --git a/aws-kafkaconnect-customplugin/pom.xml b/aws-kafkaconnect-customplugin/pom.xml new file mode 100644 index 0000000..d579a85 --- /dev/null +++ b/aws-kafkaconnect-customplugin/pom.xml @@ -0,0 +1,259 @@ + + + 4.0.0 + + software.amazon.kafkaconnect.customplugin + aws-kafkaconnect-customplugin-handler + aws-kafkaconnect-customplugin-handler + 1.0-SNAPSHOT + jar + + + 17 + ${java.version} + ${java.version} + UTF-8 + UTF-8 + + + + + + + + + software.amazon.awssdk + bom + 2.25.17 + pom + import + + + + + + + + software.amazon.cloudformation + aws-cloudformation-rpdk-java-plugin + [2.0.0,3.0.0) + + + + org.projectlombok + lombok + 1.18.32 + provided + + + + org.apache.logging.log4j + log4j-api + 2.17.1 + + + + org.apache.logging.log4j + log4j-core + 2.17.1 + + + + org.apache.logging.log4j + log4j-slf4j-impl + 2.17.1 + + + + + software.amazon.awssdk + kafkaconnect + + + + + org.assertj + assertj-core + 3.12.2 + test + + + + org.junit.jupiter + junit-jupiter + 5.5.0-M1 + test + + + + org.mockito + mockito-core + 3.6.0 + test + + + + org.mockito + mockito-junit-jupiter + 3.6.0 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.12.1 + + + -Xlint:all,-options,-processing + -Werror + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.3 + + false + + + *:* + + **/Log4j2Plugins.dat + + + + + + + package + + shade + + + + + + org.codehaus.mojo + exec-maven-plugin + 1.6.0 + + + generate + generate-sources + + exec + + + cfn + generate ${cfn.generate.args} + ${project.basedir} + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.0.0 + + + add-source + generate-sources + + add-source + + + + ${project.basedir}/target/generated-sources/rpdk + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 2.4 + + + maven-surefire-plugin + 3.0.0-M3 + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + **/BaseConfiguration* + **/BaseHandler* + **/HandlerWrapper* + **/ResourceModel* + + + + + + prepare-agent + + + + report + test + + report + + + + jacoco-check + + check + + + + + PACKAGE + + + BRANCH + COVEREDRATIO + 0.8 + + + INSTRUCTION + COVEREDRATIO + 0.8 + + + + + + + + + + + + ${project.basedir} + + aws-kafkaconnect-customplugin.json + + + + ${project.basedir}/target/loaded-target-schemas + + **/*.json + + + + + \ No newline at end of file diff --git a/aws-kafkaconnect-customplugin/resource-role.yaml b/aws-kafkaconnect-customplugin/resource-role.yaml new file mode 100644 index 0000000..5ac9f16 --- /dev/null +++ b/aws-kafkaconnect-customplugin/resource-role.yaml @@ -0,0 +1,48 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: > + This CloudFormation template creates a role assumed by CloudFormation + during CRUDL operations to mutate resources on behalf of the customer. + +Resources: + ExecutionRole: + Type: AWS::IAM::Role + Properties: + MaxSessionDuration: 8400 + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: resources.cloudformation.amazonaws.com + Action: sts:AssumeRole + Condition: + StringEquals: + aws:SourceAccount: + Ref: AWS::AccountId + StringLike: + aws:SourceArn: + Fn::Sub: arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:type/resource/AWS-KafkaConnect-CustomPlugin/* + Path: "/" + Policies: + - PolicyName: ResourceTypePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - "kafkaconnect:CreateCustomPlugin" + - "kafkaconnect:DeleteCustomPlugin" + - "kafkaconnect:DescribeCustomPlugin" + - "kafkaconnect:ListCustomPlugins" + - "kafkaconnect:ListTagsForResource" + - "kafkaconnect:TagResource" + - "kafkaconnect:UntagResource" + - "s3:GetObject" + - "s3:GetObjectAttributes" + - "s3:GetObjectVersion" + - "s3:GetObjectVersionAttributes" + Resource: "*" +Outputs: + ExecutionRoleArn: + Value: + Fn::GetAtt: ExecutionRole.Arn diff --git a/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/BaseHandlerStd.java b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/BaseHandlerStd.java new file mode 100644 index 0000000..0a3c5b3 --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/BaseHandlerStd.java @@ -0,0 +1,36 @@ +package software.amazon.kafkaconnect.customplugin; + +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +// Placeholder for the functionality that could be shared across Create/Read/Update/Delete/List +// Handlers + +public abstract class BaseHandlerStd extends BaseHandler { + @Override + public final ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final Logger logger) { + + return handleRequest( + proxy, + request, + callbackContext != null ? callbackContext : new CallbackContext(), + proxy.newProxy( + () -> ClientBuilder.getClient(request.getAwsPartition(), request.getRegion())), + logger); + } + + protected abstract ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger); +} diff --git a/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/CallbackContext.java b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/CallbackContext.java new file mode 100644 index 0000000..38c8ae4 --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/CallbackContext.java @@ -0,0 +1,10 @@ +package software.amazon.kafkaconnect.customplugin; + +import software.amazon.cloudformation.proxy.StdCallbackContext; + +@lombok.Getter +@lombok.Setter +@lombok.ToString +@lombok.EqualsAndHashCode(callSuper = true) +public class CallbackContext extends StdCallbackContext { +} diff --git a/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/ClientBuilder.java b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/ClientBuilder.java new file mode 100644 index 0000000..eab4e24 --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/ClientBuilder.java @@ -0,0 +1,50 @@ +package software.amazon.kafkaconnect.customplugin; + +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.core.retry.backoff.BackoffStrategy; +import software.amazon.awssdk.core.retry.backoff.EqualJitterBackoffStrategy; +import software.amazon.awssdk.core.retry.conditions.RetryCondition; +import software.amazon.awssdk.core.retry.RetryPolicy; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.cloudformation.LambdaWrapper; + +import java.net.URI; +import java.time.Duration; + +public class ClientBuilder { + private static final String CN_PARTITION = "aws-cn"; + private static final String CN_SUFFIX = ".cn"; + private static final String SERVICE_ENDPOINT_TEMPLATE = "https://kafkaconnect.%s.amazonaws.com"; + + private static final BackoffStrategy BACKOFF_THROTTLING_STRATEGY = EqualJitterBackoffStrategy.builder() + .baseDelay(Duration.ofMillis(1200)) + .maxBackoffTime(Duration.ofSeconds(45)) + .build(); + + private static final RetryPolicy RETRY_POLICY = RetryPolicy.builder() + .numRetries(10) + .retryCondition(RetryCondition.defaultRetryCondition()) + .backoffStrategy(BACKOFF_THROTTLING_STRATEGY) + .throttlingBackoffStrategy(BACKOFF_THROTTLING_STRATEGY) + .build(); + + private ClientBuilder() { + } + + public static KafkaConnectClient getClient(final String awsPartition, final String awsRegion) { + return KafkaConnectClient + .builder() + .httpClient(LambdaWrapper.HTTP_CLIENT) + .endpointOverride(getServiceEndpoint(awsPartition, awsRegion)) + .overrideConfiguration(ClientOverrideConfiguration.builder() + .retryPolicy(RETRY_POLICY) + .build()) + .build(); + } + + private static URI getServiceEndpoint(final String partition, final String region) { + final String serviceEndpoint = String.format(SERVICE_ENDPOINT_TEMPLATE, region); + return URI.create( + partition.equals(CN_PARTITION) ? serviceEndpoint + CN_SUFFIX : serviceEndpoint); + } +} diff --git a/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/Configuration.java b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/Configuration.java new file mode 100644 index 0000000..d68084b --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/Configuration.java @@ -0,0 +1,8 @@ +package software.amazon.kafkaconnect.customplugin; + +class Configuration extends BaseConfiguration { + + public Configuration() { + super("aws-kafkaconnect-customplugin.json"); + } +} diff --git a/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/CreateHandler.java b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/CreateHandler.java new file mode 100644 index 0000000..d1f9761 --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/CreateHandler.java @@ -0,0 +1,201 @@ +package software.amazon.kafkaconnect.customplugin; + +import java.time.Duration; +import java.util.function.BiFunction; +import java.util.function.Function; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.CreateCustomPluginRequest; +import software.amazon.awssdk.services.kafkaconnect.model.CreateCustomPluginResponse; +import software.amazon.awssdk.services.kafkaconnect.model.CustomPluginState; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginResponse; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnResourceConflictException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.cloudformation.proxy.delay.Constant; + +public class CreateHandler extends BaseHandlerStd { + private static final Constant BACK_OFF_DELAY = + Constant.of().timeout(Duration.ofHours(1L)).delay(Duration.ofSeconds(30L)).build(); + private static final BiFunction, ResourceModel> EMPTY_CALL = + (model, proxyClient) -> model; + private static final String CUSTOM_PLUGIN_STATE_FAILURE_MESSAGE_PATTERN = + "%s create request accepted but failed to get state due to: %s"; + private static final String CUSTOM_PLUGIN_STATE_SUCCESS_MESSAGE_PATTERN = + "Create state of resource %s with ID %s is %s"; + + private Logger logger; + private final ExceptionTranslator exceptionTranslator; + private final Translator translator; + private final ReadHandler readHandler; + + public CreateHandler() { + this(new ExceptionTranslator(), new Translator(), new ReadHandler()); + } + + /** + * Constructor used for unit testing + * + * @param exceptionTranslator + * @param translator + * @param readHandler + */ + CreateHandler( + final ExceptionTranslator exceptionTranslator, + final Translator translator, + final ReadHandler readHandler) { + + this.exceptionTranslator = exceptionTranslator; + this.translator = translator; + this.readHandler = readHandler; + } + + @Override + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + + final ResourceModel model = request.getDesiredResourceState(); + + return ProgressEvent.progress(model, callbackContext) + .then( + progress -> initiateCreateCustomPlugin( + proxy, proxyClient, progress, "AWS-KafkaConnect-CustomPlugin::Create", request)) + .then( + progress -> stabilize( + proxy, + proxyClient, + progress, + "AWS-KafkaConnect-CustomPlugin::PostCreateStabilize")) + .then( + progress -> readHandler.handleRequest(proxy, request, callbackContext, proxyClient, logger)); + } + + private ProgressEvent initiateCreateCustomPlugin( + final AmazonWebServicesClientProxy proxy, + final ProxyClient proxyClient, + final ProgressEvent progress, + final String callGraph, + final ResourceHandlerRequest request) { + return proxy + .initiate( + callGraph, proxyClient, progress.getResourceModel(), progress.getCallbackContext()) + .translateToServiceRequest(_resourceModel -> translator.translateToCreateRequest(_resourceModel, + TagHelper.generateTagsForCreate(request))) + .makeServiceCall(this::runCreateCustomPlugin) + .done(this::setCustomPluginArn); + } + + private ProgressEvent setCustomPluginArn( + final CreateCustomPluginRequest createCustomPluginRequest, + final CreateCustomPluginResponse createCustomPluginResponse, + final ProxyClient kafkaConnectClientProxyClient, + final ResourceModel resourceModel, + final CallbackContext callbackContext) { + resourceModel.setCustomPluginArn(createCustomPluginResponse.customPluginArn()); + return ProgressEvent.progress(resourceModel, callbackContext); + } + + private CreateCustomPluginResponse runCreateCustomPlugin( + final CreateCustomPluginRequest createCustomPluginRequest, + final ProxyClient proxyClient) { + final KafkaConnectClient kafkaConnectClient = proxyClient.client(); + final String identifier = createCustomPluginRequest.name(); + CreateCustomPluginResponse createCustomPluginResponse; + try { + createCustomPluginResponse = + proxyClient.injectCredentialsAndInvokeV2( + createCustomPluginRequest, kafkaConnectClient::createCustomPlugin); + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + + logger.log(String.format("%s [%s] created successfully.", ResourceModel.TYPE_NAME, identifier)); + return createCustomPluginResponse; + } + + private ProgressEvent stabilize( + final AmazonWebServicesClientProxy proxy, + final ProxyClient proxyClient, + final ProgressEvent progress, + final String callGraph) { + + return proxy + .initiate( + callGraph, proxyClient, progress.getResourceModel(), progress.getCallbackContext()) + .translateToServiceRequest(Function.identity()) + .backoffDelay(BACK_OFF_DELAY) + .makeServiceCall(EMPTY_CALL) + .stabilize( + (request, response, client, model, callbackContext) -> isStabilized(proxyClient, response)) + .progress(); + } + + private boolean isStabilized( + final ProxyClient proxyClient, final ResourceModel model) { + final KafkaConnectClient kafkaConnectClient = proxyClient.client(); + final CustomPluginState customPluginState = + getCustomPluginState( + kafkaConnectClient, + translator.translateToReadRequest(model), + proxyClient, + logger, + CUSTOM_PLUGIN_STATE_FAILURE_MESSAGE_PATTERN, + CUSTOM_PLUGIN_STATE_SUCCESS_MESSAGE_PATTERN); + switch (customPluginState) { + case ACTIVE: + return true; + case CREATING: + return false; + case CREATE_FAILED: + throw new CfnGeneralServiceException( + String.format("Couldn't create %s due to create failure", ResourceModel.TYPE_NAME)); + case DELETING: + throw new CfnResourceConflictException( + ResourceModel.TYPE_NAME, + model.getCustomPluginArn(), + String.format("Another process is deleting this %s", ResourceModel.TYPE_NAME)); + default: + throw new CfnGeneralServiceException( + String.format( + "%s create request accepted but current state is unknown", + ResourceModel.TYPE_NAME)); + } + } + + private CustomPluginState getCustomPluginState( + final KafkaConnectClient kafkaConnectClient, + final DescribeCustomPluginRequest describeCustomPluginRequest, + final ProxyClient proxyClient, + final Logger logger, + final String failureMessagePattern, + final String successMessagePattern) { + try { + final DescribeCustomPluginResponse describeCustomPluginResponse = + proxyClient.injectCredentialsAndInvokeV2( + describeCustomPluginRequest, kafkaConnectClient::describeCustomPlugin); + final CustomPluginState customPluginState = describeCustomPluginResponse.customPluginState(); + logger.log( + String.format( + successMessagePattern, + ResourceModel.TYPE_NAME, + describeCustomPluginRequest.customPluginArn(), + customPluginState == null ? "unknown" : customPluginState.toString())); + + return customPluginState; + } catch (final AwsServiceException e) { + throw new CfnGeneralServiceException( + String.format(failureMessagePattern, ResourceModel.TYPE_NAME, e.getMessage()), e); + } + } +} diff --git a/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/DeleteHandler.java b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/DeleteHandler.java new file mode 100644 index 0000000..00e89de --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/DeleteHandler.java @@ -0,0 +1,156 @@ +package software.amazon.kafkaconnect.customplugin; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.CustomPluginState; +import software.amazon.awssdk.services.kafkaconnect.model.DeleteCustomPluginRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DeleteCustomPluginResponse; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginResponse; +import software.amazon.awssdk.services.kafkaconnect.model.NotFoundException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnNotStabilizedException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class DeleteHandler extends BaseHandlerStd { + private Logger logger; + + private final Translator translator; + private final ExceptionTranslator exceptionTranslator; + + public DeleteHandler() { + this(new ExceptionTranslator(), new Translator()); + } + + /** + * Constructor used for unit testing + * + * @param translator + */ + DeleteHandler(final ExceptionTranslator exceptionTranslator, final Translator translator) { + this.translator = translator; + this.exceptionTranslator = exceptionTranslator; + } + + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + final ResourceModel model = request.getDesiredResourceState(); + + return ProgressEvent.progress(model, callbackContext) + .then( + progress -> proxy + .initiate( + "AWS-KafkaConnect-CustomPlugin::ValidateResourceExists", + proxyClient, + model, + callbackContext) + .translateToServiceRequest(translator::translateToReadRequest) + .makeServiceCall(this::validateResourceExists).progress()) + .then( + progress -> proxy + .initiate( + "AWS-KafkaConnect-CustomPlugin::Delete", + proxyClient, + model, + callbackContext) + .translateToServiceRequest(translator::translateToDeleteRequest) + .makeServiceCall(this::deleteCustomPlugin) + .stabilize( + (awsRequest, awsResponse, client, awsModel, context) -> isStabilized(awsRequest, client, + awsModel)) + .done( + (awsRequest, awsResponse, client, awsModel, context) -> ProgressEvent + .defaultSuccessHandler(null))); + } + + private DescribeCustomPluginResponse validateResourceExists( + DescribeCustomPluginRequest describeCustomPluginRequest, + ProxyClient proxyClient) { + DescribeCustomPluginResponse describeCustomPluginResponse; + if (describeCustomPluginRequest.customPluginArn() == null) { + throw new CfnNotFoundException(ResourceModel.TYPE_NAME, null); + } + final String identifier = describeCustomPluginRequest.customPluginArn(); + try { + final KafkaConnectClient kafkaConnectClient = proxyClient.client(); + describeCustomPluginResponse = + proxyClient.injectCredentialsAndInvokeV2( + describeCustomPluginRequest, kafkaConnectClient::describeCustomPlugin); + } catch (final NotFoundException e) { + logger.log( + String.format("%s with arn: %s does not exist!", ResourceModel.TYPE_NAME, identifier)); + throw exceptionTranslator.translateToCfnException(e, identifier); + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + logger.log( + String.format( + "Validated %s with arn: %s name: %s exists!", + ResourceModel.TYPE_NAME, identifier, describeCustomPluginResponse.name())); + return describeCustomPluginResponse; + } + + private DeleteCustomPluginResponse deleteCustomPlugin( + final DeleteCustomPluginRequest deleteCustomPluginRequest, + final ProxyClient proxyClient) { + DeleteCustomPluginResponse deleteCustomPluginResponse; + final String identifier = deleteCustomPluginRequest.customPluginArn(); + try { + final KafkaConnectClient kafkaConnectClient = proxyClient.client(); + deleteCustomPluginResponse = + proxyClient.injectCredentialsAndInvokeV2( + deleteCustomPluginRequest, kafkaConnectClient::deleteCustomPlugin); + logger.log( + String.format( + "Initiated delete procedure for %s with arn: %s", + ResourceModel.TYPE_NAME, identifier)); + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + return deleteCustomPluginResponse; + } + + private boolean isStabilized( + final DeleteCustomPluginRequest deleteCustomPluginRequest, + final ProxyClient proxyClient, + final ResourceModel model) { + final String identifier = deleteCustomPluginRequest.customPluginArn(); + try { + final KafkaConnectClient kafkaConnectClient = proxyClient.client(); + final CustomPluginState customPluginState = + proxyClient + .injectCredentialsAndInvokeV2( + translator.translateToReadRequest(model), + kafkaConnectClient::describeCustomPlugin) + .customPluginState(); + switch (customPluginState) { + case DELETING: + logger.log( + String.format( + "%s with arn: %s is being deleted...", ResourceModel.TYPE_NAME, identifier)); + return false; + default: + logger.log( + String.format( + "%s with arn: %s reached unexpected state: %s", + ResourceModel.TYPE_NAME, identifier, customPluginState)); + throw new CfnNotStabilizedException(ResourceModel.TYPE_NAME, identifier); + } + } catch (final NotFoundException e) { + logger.log(String.format("Deleted %s with arn: %s", ResourceModel.TYPE_NAME, identifier)); + return true; + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + } +} diff --git a/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/ExceptionTranslator.java b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/ExceptionTranslator.java new file mode 100644 index 0000000..f439203 --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/ExceptionTranslator.java @@ -0,0 +1,54 @@ +package software.amazon.kafkaconnect.customplugin; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.model.BadRequestException; +import software.amazon.awssdk.services.kafkaconnect.model.ConflictException; +import software.amazon.awssdk.services.kafkaconnect.model.InternalServerErrorException; +import software.amazon.awssdk.services.kafkaconnect.model.NotFoundException; +import software.amazon.awssdk.services.kafkaconnect.model.UnauthorizedException; +import software.amazon.awssdk.services.kafkaconnect.model.TooManyRequestsException; +import software.amazon.cloudformation.exceptions.*; + +public class ExceptionTranslator { + + public ExceptionTranslator() { + } + + /** + * Translation for exceptions coming from SDK having no additional messaging or clarification + * needs to Cfn exceptions. + * + * @param exception SDK exception to translate + * @param identifier Resource identifying field + * @return Cfn equivalent exception + */ + public BaseHandlerException translateToCfnException( + final AwsServiceException exception, final String identifier) { + + if (exception instanceof NotFoundException) { + return new CfnNotFoundException(ResourceModel.TYPE_NAME, identifier, exception); + } + + if (exception instanceof BadRequestException) { + return new CfnInvalidRequestException(exception.getMessage(), exception); + } + + if (exception instanceof ConflictException) { + return new CfnAlreadyExistsException(ResourceModel.TYPE_NAME, identifier, exception); + } + + if (exception instanceof InternalServerErrorException) { + return new CfnInternalFailureException(exception); + } + + if (exception instanceof UnauthorizedException) { + return new CfnAccessDeniedException(ResourceModel.TYPE_NAME, exception); + } + + if (exception instanceof TooManyRequestsException) { + return new CfnServiceLimitExceededException(exception); + } + + return new CfnGeneralServiceException(exception); + } +} diff --git a/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/ListHandler.java b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/ListHandler.java new file mode 100644 index 0000000..6c0f903 --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/ListHandler.java @@ -0,0 +1,68 @@ +package software.amazon.kafkaconnect.customplugin; + +import java.util.List; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.ListCustomPluginsRequest; +import software.amazon.awssdk.services.kafkaconnect.model.ListCustomPluginsResponse; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class ListHandler extends BaseHandlerStd { + private final ExceptionTranslator exceptionTranslator; + private final Translator translator; + + public ListHandler() { + this(new ExceptionTranslator(), new Translator()); + } + + /** + * Constructor used for unit testing + * + * @param exceptionTranslator + * @param translator + */ + ListHandler(final ExceptionTranslator exceptionTranslator, final Translator translator) { + this.exceptionTranslator = exceptionTranslator; + this.translator = translator; + } + + @Override + public ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + final ListCustomPluginsRequest listCustomPluginsRequest = + translator.translateToListRequest(request.getNextToken()); + + final KafkaConnectClient kafkaConnectClient = proxyClient.client(); + + ListCustomPluginsResponse listCustomPluginsResponse; + + try { + listCustomPluginsResponse = + proxyClient.injectCredentialsAndInvokeV2( + listCustomPluginsRequest, kafkaConnectClient::listCustomPlugins); + } catch (final AwsServiceException e) { + final String identifier = request.getAwsAccountId(); + throw exceptionTranslator.translateToCfnException(e, identifier); + } + + final List models = + translator.translateFromListResponse(listCustomPluginsResponse); + final String nextToken = listCustomPluginsResponse.nextToken(); + + return ProgressEvent.builder() + .resourceModels(models) + .nextToken(nextToken) + .status(OperationStatus.SUCCESS) + .build(); + } +} diff --git a/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/ReadHandler.java b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/ReadHandler.java new file mode 100644 index 0000000..0f9a834 --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/ReadHandler.java @@ -0,0 +1,89 @@ +package software.amazon.kafkaconnect.customplugin; + +import java.util.Map; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginResponse; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceResponse; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class ReadHandler extends BaseHandlerStd { + private Logger logger; + private final ExceptionTranslator exceptionTranslator; + private final Translator translator; + + public ReadHandler() { + this(new ExceptionTranslator(), new Translator()); + } + + /** + * Constructor used for unit testing + * + * @param exceptionTranslator + * @param translator + */ + ReadHandler(final ExceptionTranslator exceptionTranslator, final Translator translator) { + this.exceptionTranslator = exceptionTranslator; + this.translator = translator; + } + + @Override + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + + return proxy + .initiate( + "AWS-KafkaConnect-CustomPlugin::Read", + proxyClient, + request.getDesiredResourceState(), + callbackContext) + .translateToServiceRequest(translator::translateToReadRequest) + .makeServiceCall(this::describeCustomPluginWithTags) + .done(responseModel -> ProgressEvent.defaultSuccessHandler(responseModel)); + } + + private ResourceModel describeCustomPluginWithTags( + final DescribeCustomPluginRequest describeCustomPluginRequest, + final ProxyClient proxyClient) { + + DescribeCustomPluginResponse describeCustomPluginResponse; + Map customPluginTags; + final String identifier = describeCustomPluginRequest.customPluginArn(); + final KafkaConnectClient kafkaConnectClient = proxyClient.client(); + + try { + describeCustomPluginResponse = + proxyClient.injectCredentialsAndInvokeV2( + describeCustomPluginRequest, kafkaConnectClient::describeCustomPlugin); + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + + try { + final ListTagsForResourceResponse listTagsForResourceResponse = + TagHelper.listTags( + describeCustomPluginRequest.customPluginArn(), kafkaConnectClient, proxyClient); + customPluginTags = listTagsForResourceResponse.tags(); + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + + logger.log( + String.format("%s [%s] has successfully been read.", ResourceModel.TYPE_NAME, identifier)); + + ResourceModel readResponse = translator.translateFromReadResponse(describeCustomPluginResponse); + readResponse.setTags(TagHelper.convertToList(customPluginTags)); + return readResponse; + } +} diff --git a/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/TagHelper.java b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/TagHelper.java new file mode 100644 index 0000000..7cc1244 --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/TagHelper.java @@ -0,0 +1,200 @@ +package software.amazon.kafkaconnect.customplugin; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.ObjectUtils; + +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceResponse; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class TagHelper { + /** + * Converts a collection of Tag objects to a tag-name : tag-value map. + * + *

Note: Tag objects with null tag values will not be included in the output map. + * + * @param tags Collection of tags to convert. + * @return Map of Tag objects. + */ + public static Map convertToMap(final Collection tags) { + if (CollectionUtils.isEmpty(tags)) { + return Collections.emptyMap(); + } + return tags.stream() + .filter(tag -> tag.getValue() != null) + .collect(Collectors.toMap(Tag::getKey, Tag::getValue, (oldValue, newValue) -> newValue)); + } + + /** + * Converts a tag map to a list of Tag objects. + * + *

Note: Like convertToMap, convertToList filters out value-less tag entries. + * + * @param tagMap Map of tags to convert. + * @return List of Tag objects. + */ + public static List convertToList(final Map tagMap) { + if (MapUtils.isEmpty(tagMap)) { + return Collections.emptyList(); + } + return tagMap.entrySet().stream() + .filter(tag -> tag.getValue() != null) + .map(tag -> Tag.builder().key(tag.getKey()).value(tag.getValue()).build()) + .collect(Collectors.toList()); + } + + /** + * Executes listTagsForResource SDK client call for the specified resource ARN. + * + * @param arn Resource ARN to list tags for. + * @param kafkaConnectClient AWS KafkaConnect client to use. + * @param proxyClient Proxy client to use for providing credentials. + * @return ListTagsForResourceResponse from the SDK client call. + */ + public static ListTagsForResourceResponse listTags( + final String arn, + final KafkaConnectClient kafkaConnectClient, + final ProxyClient proxyClient) { + final ListTagsForResourceRequest listTagsForResourceRequest = + ListTagsForResourceRequest.builder().resourceArn(arn).build(); + + return proxyClient.injectCredentialsAndInvokeV2( + listTagsForResourceRequest, kafkaConnectClient::listTagsForResource); + } + + /** + * generateTagsForCreate + * + * Generate tags to put into resource creation request. + * This includes user defined tags and system tags as well. + */ + public static Map generateTagsForCreate( + final ResourceHandlerRequest handlerRequest) { + final Map tagMap = new HashMap<>(); + + // merge system tags with desired resource tags if your service supports CloudFormation system tags + if (handlerRequest.getSystemTags() != null) { + tagMap.putAll(handlerRequest.getSystemTags()); + } + + // get desired stack level tags from handlerRequest + if (handlerRequest.getDesiredResourceTags() != null) { + tagMap.putAll(handlerRequest.getDesiredResourceTags()); + } + + // get resource level tags from resource model based on your tag property name + if (handlerRequest.getDesiredResourceState() != null + && handlerRequest.getDesiredResourceState().getTags() != null) { + tagMap.putAll(convertToMap(handlerRequest.getDesiredResourceState().getTags())); + } + + return Collections.unmodifiableMap(tagMap); + } + + /** + * shouldUpdateTags + * + * Determines whether user defined tags have been changed during update. + */ + public static boolean shouldUpdateTags(final ResourceHandlerRequest handlerRequest) { + final Map previousTags = getPreviouslyAttachedTags(handlerRequest); + final Map desiredTags = getNewDesiredTags(handlerRequest); + return ObjectUtils.notEqual(previousTags, desiredTags); + } + + /** + * getPreviouslyAttachedTags + * + * If stack tags and resource tags are not merged together in Configuration class, + * we will get previous attached user defined tags from both handlerRequest.getPreviousResourceTags (stack tags) + * and handlerRequest.getPreviousResourceState (resource tags). + */ + public static Map getPreviouslyAttachedTags( + final ResourceHandlerRequest handlerRequest) { + final Map previousTags = new HashMap<>(); + + // get previous system tags if your service supports CloudFormation system tags + if (handlerRequest.getPreviousSystemTags() != null) { + previousTags.putAll(handlerRequest.getPreviousSystemTags()); + } + + // get previous stack level tags from handlerRequest + if (handlerRequest.getPreviousResourceTags() != null) { + previousTags.putAll(handlerRequest.getPreviousResourceTags()); + } + + // get resource level tags from previous resource state based on your tag property name + if (handlerRequest.getPreviousResourceState() != null + && handlerRequest.getPreviousResourceState().getTags() != null) { + previousTags.putAll(convertToMap(handlerRequest.getPreviousResourceState().getTags())); + } + + return previousTags; + } + + /** + * getNewDesiredTags + * + * If stack tags and resource tags are not merged together in Configuration class, + * we will get new user defined tags from both resource model and previous stack tags. + */ + public static Map getNewDesiredTags(final ResourceHandlerRequest handlerRequest) { + final Map desiredTags = new HashMap<>(); + + // merge system tags with desired resource tags if your service supports CloudFormation system tags + if (handlerRequest.getSystemTags() != null) { + desiredTags.putAll(handlerRequest.getSystemTags()); + } + + // get desired stack level tags from handlerRequest + if (handlerRequest.getDesiredResourceTags() != null) { + desiredTags.putAll(handlerRequest.getDesiredResourceTags()); + } + + // get resource level tags from resource model based on your tag property name + desiredTags.putAll(convertToMap(handlerRequest.getDesiredResourceState().getTags())); + return desiredTags; + } + + /** + * Generates a map of tags to be added or modified in the resource. + * + * @param previousTags + * @param desiredTags + * @return + */ + public static Map generateTagsToAdd( + final Map previousTags, final Map desiredTags) { + return desiredTags.entrySet().stream() + .filter( + desiredTag -> !previousTags.containsKey(desiredTag.getKey()) + || !Objects.equals( + previousTags.get(desiredTag.getKey()), desiredTag.getValue())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + /** + * getTagsToRemove + * + * Determines the tags the customer desired to remove from the function. + */ + public static Set generateTagsToRemove(final Map previousTags, + final Map desiredTags) { + final Set desiredTagNames = desiredTags.keySet(); + + return previousTags.keySet().stream() + .filter(tagName -> !desiredTagNames.contains(tagName)) + .collect(Collectors.toSet()); + } +} diff --git a/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/Translator.java b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/Translator.java new file mode 100644 index 0000000..da628ca --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/Translator.java @@ -0,0 +1,204 @@ +package software.amazon.kafkaconnect.customplugin; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import software.amazon.awssdk.services.kafkaconnect.model.CreateCustomPluginRequest; +import software.amazon.awssdk.services.kafkaconnect.model.CustomPluginLocationDescription; +import software.amazon.awssdk.services.kafkaconnect.model.DeleteCustomPluginRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginResponse; +import software.amazon.awssdk.services.kafkaconnect.model.ListCustomPluginsRequest; +import software.amazon.awssdk.services.kafkaconnect.model.ListCustomPluginsResponse; +import software.amazon.awssdk.services.kafkaconnect.model.S3LocationDescription; +import software.amazon.awssdk.services.kafkaconnect.model.TagResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.UntagResourceRequest; + +/** + * This class is a centralized placeholder for - api request construction - object translation + * to/from aws sdk - resource model construction for read/list handlers + */ +public class Translator { + + /** + * Request to create a resource + * + * @param model resource model + * @return createCustomPluginRequest the kafkaconnect request to create a resource + */ + public CreateCustomPluginRequest translateToCreateRequest(final ResourceModel model, + final Map tagsForCreate) { + return CreateCustomPluginRequest.builder() + .contentType(model.getContentType()) + .description(model.getDescription()) + .location(resourceCustomPluginLocationToSdkCustomPluginLocation(model.getLocation())) + .name(model.getName()) + .tags(tagsForCreate) + .build(); + } + + /** + * Request to read a resource + * + * @param model resource model + * @return describeCustomPluginRequest the kafkaconnect request to describe a resource + */ + public DescribeCustomPluginRequest translateToReadRequest(final ResourceModel model) { + return DescribeCustomPluginRequest.builder() + .customPluginArn(model.getCustomPluginArn()) + .build(); + } + + /** + * Translates resource object from sdk into a resource model + * + * @param describeCustomPluginResponse the kafkaconnect describe resource response + * @return model resource model + */ + public ResourceModel translateFromReadResponse( + final DescribeCustomPluginResponse describeCustomPluginResponse) { + return ResourceModel.builder() + .name(describeCustomPluginResponse.name()) + .description(describeCustomPluginResponse.description()) + .customPluginArn(describeCustomPluginResponse.customPluginArn()) + .fileDescription( + sdkCustomPluginFileDescriptionToResourceCustomPluginFileDescription( + describeCustomPluginResponse.latestRevision().fileDescription())) + .location( + sdkCustomPluginLocationDescriptionToResourceCustomPluginLocation( + describeCustomPluginResponse.latestRevision().location())) + .contentType(describeCustomPluginResponse.latestRevision().contentTypeAsString()) + .revision(describeCustomPluginResponse.latestRevision().revision()) + .build(); + } + + /** + * Request to delete a resource. + * + * @param model resource model + * @return awsRequest the aws service request to delete a resource + */ + public DeleteCustomPluginRequest translateToDeleteRequest(final ResourceModel model) { + return DeleteCustomPluginRequest.builder().customPluginArn(model.getCustomPluginArn()).build(); + } + + /** + * Request to list resources + * + * @param nextToken token passed to the aws service list resources request + * @return listCustomPluginsRequest the kafkaconnect request to list resources within aws account + */ + ListCustomPluginsRequest translateToListRequest(final String nextToken) { + return ListCustomPluginsRequest.builder().nextToken(nextToken).build(); + } + + /** + * Translates custom plugins from sdk into a resource model with primary identifier only. This is + * as per contract for list handlers. + * + *

Reference - + * https://docs.aws.amazon.com/cloudformation-cli/latest/userguide/resource-type-test-contract.html#resource-type-test-contract-list + * + * @param listCustomPluginsResponse the kafkaconnect list resources response + * @return list of resource models + */ + public List translateFromListResponse( + final ListCustomPluginsResponse listCustomPluginsResponse) { + return streamOfOrEmpty(listCustomPluginsResponse.customPlugins()) + .map( + customPlugin -> ResourceModel.builder().customPluginArn(customPlugin.customPluginArn()).build()) + .collect(Collectors.toList()); + } + + protected static Stream streamOfOrEmpty(final Collection collection) { + return Optional.ofNullable(collection).map(Collection::stream).orElseGet(Stream::empty); + } + + /** + * Request to add tags to a resource. + * + * @param model resource model + * @return awsRequest the aws service request to create a resource + */ + static TagResourceRequest translateToTagRequest( + final ResourceModel model, final Map addedTags) { + return TagResourceRequest.builder() + .resourceArn(model.getCustomPluginArn()) + .tags(addedTags) + .build(); + } + + /** + * Request to remove tags from a resource. + * + * @param model resource model + * @return awsRequest the aws service request to create a resource + */ + static UntagResourceRequest translateToUntagRequest( + final ResourceModel model, final Set removedTags) { + return UntagResourceRequest.builder() + .resourceArn(model.getCustomPluginArn()) + .tagKeys(removedTags) + .build(); + } + + protected static CustomPluginFileDescription sdkCustomPluginFileDescriptionToResourceCustomPluginFileDescription( + final software.amazon.awssdk.services.kafkaconnect.model.CustomPluginFileDescription customPluginFileDescription) { + + return customPluginFileDescription == null + ? null + : CustomPluginFileDescription.builder() + .fileMd5(customPluginFileDescription.fileMd5()) + .fileSize(customPluginFileDescription.fileSize()) + .build(); + } + + protected static CustomPluginLocation sdkCustomPluginLocationDescriptionToResourceCustomPluginLocation( + final CustomPluginLocationDescription customPluginLocationDescription) { + + return customPluginLocationDescription == null + ? null + : CustomPluginLocation.builder() + .s3Location( + sdkS3LocationDescriptionToResourceS3Location( + customPluginLocationDescription.s3Location())) + .build(); + } + + protected static S3Location sdkS3LocationDescriptionToResourceS3Location( + final S3LocationDescription s3LocationDescription) { + + return s3LocationDescription == null + ? null + : S3Location.builder() + .bucketArn(s3LocationDescription.bucketArn()) + .fileKey(s3LocationDescription.fileKey()) + .objectVersion(s3LocationDescription.objectVersion()) + .build(); + } + + protected static software.amazon.awssdk.services.kafkaconnect.model.CustomPluginLocation resourceCustomPluginLocationToSdkCustomPluginLocation( + final CustomPluginLocation customPluginLocation) { + return customPluginLocation == null + ? null + : software.amazon.awssdk.services.kafkaconnect.model.CustomPluginLocation.builder() + .s3Location(resourceS3LocationToSdkS3Location(customPluginLocation.getS3Location())) + .build(); + } + + protected static software.amazon.awssdk.services.kafkaconnect.model.S3Location resourceS3LocationToSdkS3Location( + final S3Location s3Location) { + return s3Location == null + ? null + : software.amazon.awssdk.services.kafkaconnect.model.S3Location.builder() + .bucketArn(s3Location.getBucketArn()) + .fileKey(s3Location.getFileKey()) + .objectVersion(s3Location.getObjectVersion()) + .build(); + } +} diff --git a/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/UpdateHandler.java b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/UpdateHandler.java new file mode 100644 index 0000000..27651e8 --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/main/java/software/amazon/kafkaconnect/customplugin/UpdateHandler.java @@ -0,0 +1,207 @@ +package software.amazon.kafkaconnect.customplugin; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginResponse; +import software.amazon.awssdk.services.kafkaconnect.model.TagResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.UntagResourceRequest; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnNotUpdatableException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class UpdateHandler extends BaseHandlerStd { + + private Logger logger; + + private final Translator translator; + private final ExceptionTranslator exceptionTranslator; + private final ReadHandler readHandler; + + public UpdateHandler() { + this(new ExceptionTranslator(), new Translator(), new ReadHandler()); + } + + /** + * This is constructor is used for unit testing. + * + * @param exceptionTranslator + * @param translator + * @param readHandler + */ + UpdateHandler( + final ExceptionTranslator exceptionTranslator, + final Translator translator, + final ReadHandler readHandler) { + this.translator = translator; + this.exceptionTranslator = exceptionTranslator; + this.readHandler = readHandler; + } + + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + this.logger = logger; + + final ResourceModel desiredModel = request.getDesiredResourceState(); + final ResourceModel previousModel = request.getPreviousResourceState(); + + return ProgressEvent.progress(desiredModel, callbackContext) + .then( + progress -> proxy + .initiate( + "AWS-KafkaConnect-CustomPlugin::Update::ValidateResourceExists", + proxyClient, + desiredModel, + callbackContext) + .translateToServiceRequest(translator::translateToReadRequest) + .makeServiceCall(this::validateResourceExists) + .progress()) + .then(progress -> verifyNonUpdatableFields(desiredModel, previousModel, progress)) + .then(progress -> updateTags(proxyClient, progress, request)) + .then( + progress -> readHandler.handleRequest(proxy, request, callbackContext, proxyClient, logger)); + } + + private DescribeCustomPluginResponse validateResourceExists( + DescribeCustomPluginRequest describeCustomPluginRequest, + ProxyClient proxyClient) { + DescribeCustomPluginResponse describeCustomPluginResponse; + if (describeCustomPluginRequest.customPluginArn() == null) { + throw new CfnNotFoundException(ResourceModel.TYPE_NAME, null); + } + + try { + final KafkaConnectClient kafkaConnectClient = proxyClient.client(); + describeCustomPluginResponse = + proxyClient.injectCredentialsAndInvokeV2( + describeCustomPluginRequest, kafkaConnectClient::describeCustomPlugin); + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException( + e, describeCustomPluginRequest.customPluginArn()); + } + + logger.log( + String.format( + "Validated Custom Plugin exists with name: %s", describeCustomPluginResponse.name())); + return describeCustomPluginResponse; + } + + /** + * Checks if the CREATE ONLY fields have been updated and throws an exception if it is the case. + * + * @param currentModel The current resource model. + * @param previousModel The previous resource model. + * @param progress + * @return + */ + private ProgressEvent verifyNonUpdatableFields( + ResourceModel currentModel, + ResourceModel previousModel, + ProgressEvent progress) { + if (previousModel != null) { + // Check READ ONLY fields. + final boolean isCustomPluginArnEqual = + Optional.ofNullable(currentModel.getCustomPluginArn()) + .equals(Optional.ofNullable(previousModel.getCustomPluginArn())); + final boolean isRevisionEqual = + Optional.ofNullable(currentModel.getRevision()) + .equals(Optional.ofNullable(previousModel.getRevision())); + final boolean isFileDescriptionEqual = + Optional.ofNullable(currentModel.getFileDescription()) + .equals(Optional.ofNullable(previousModel.getFileDescription())); + // Check CREATE ONLY fields. + final boolean isNameEqual = + Optional.ofNullable(currentModel.getName()) + .equals(Optional.ofNullable(previousModel.getName())); + final boolean isDescriptionEqual = + Optional.ofNullable(currentModel.getDescription()) + .equals(Optional.ofNullable(previousModel.getDescription())); + final boolean isContentTypeEqual = + Optional.ofNullable(currentModel.getContentType()) + .equals(Optional.ofNullable(previousModel.getContentType())); + final boolean isLocationEqual = + Optional.ofNullable(currentModel.getLocation()) + .equals(Optional.ofNullable(previousModel.getLocation())); + if (!(isCustomPluginArnEqual + && isRevisionEqual + && isFileDescriptionEqual + && isNameEqual + && isDescriptionEqual + && isContentTypeEqual + && isLocationEqual)) { + throw new CfnNotUpdatableException( + ResourceModel.TYPE_NAME, currentModel.getCustomPluginArn()); + } + } + logger.log( + String.format( + "Verified non-updatable fields for CustomPlugin resource with arn: %s", + currentModel.getCustomPluginArn())); + return progress; + } + + /** + * Updates the tag for the CustomPlugin. This will remove the tags which are no longer needed and + * add new tags. + * + * @param proxyClient KafkaConnectClient to be used for updating tags + * @param progress + * @param request + * @return + */ + private ProgressEvent updateTags( + final ProxyClient proxyClient, + final ProgressEvent progress, + ResourceHandlerRequest request) { + final ResourceModel desiredModel = request.getDesiredResourceState(); + final String identifier = desiredModel.getCustomPluginArn(); + + if (TagHelper.shouldUpdateTags(request)) { + final Map previousTags = TagHelper.getPreviouslyAttachedTags(request); + final Map desiredTags = TagHelper.getNewDesiredTags(request); + final Map addedTags = TagHelper.generateTagsToAdd(previousTags, desiredTags); + final Set removedTags = TagHelper.generateTagsToRemove(previousTags, desiredTags); + final KafkaConnectClient kafkaConnectClient = proxyClient.client(); + + if (!removedTags.isEmpty()) { + final UntagResourceRequest untagResourceRequest = + Translator.translateToUntagRequest(desiredModel, removedTags); + try { + proxyClient.injectCredentialsAndInvokeV2( + untagResourceRequest, kafkaConnectClient::untagResource); + logger.log( + String.format( + "CustomPlugin removed %d tags from arn: %s", removedTags.size(), identifier)); + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + } + + if (!addedTags.isEmpty()) { + final TagResourceRequest tagResourceRequest = + Translator.translateToTagRequest(desiredModel, addedTags); + try { + proxyClient.injectCredentialsAndInvokeV2( + tagResourceRequest, kafkaConnectClient::tagResource); + logger.log( + String.format("CustomPlugin added %d tags to arn: %s", addedTags.size(), identifier)); + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + } + } + return ProgressEvent.progress(desiredModel, progress.getCallbackContext()); + } +} diff --git a/aws-kafkaconnect-customplugin/src/resources/log4j2.xml b/aws-kafkaconnect-customplugin/src/resources/log4j2.xml new file mode 100644 index 0000000..5657daf --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/resources/log4j2.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/AbstractTestBase.java b/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/AbstractTestBase.java new file mode 100644 index 0000000..3ca246e --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/AbstractTestBase.java @@ -0,0 +1,72 @@ +package software.amazon.kafkaconnect.customplugin; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import software.amazon.awssdk.awscore.AwsRequest; +import software.amazon.awssdk.awscore.AwsResponse; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.pagination.sync.SdkIterable; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Credentials; +import software.amazon.cloudformation.proxy.LoggerProxy; +import software.amazon.cloudformation.proxy.ProxyClient; + +public class AbstractTestBase { + protected static final Credentials MOCK_CREDENTIALS = + new Credentials("accessKey", "secretKey", "token"); + protected static final LoggerProxy logger = new LoggerProxy(); + + protected static final Map TAGS = + new HashMap() { + { + put("TEST_TAG1", "TEST_TAG_VALUE1"); + put("TEST_TAG2", "TEST_TAG_VALUE2"); + } + }; + + static ProxyClient proxyStub( + final AmazonWebServicesClientProxy proxy, final KafkaConnectClient kafkaConnectClient) { + return new ProxyClient() { + @Override + public ResponseT injectCredentialsAndInvokeV2( + RequestT request, Function requestFunction) { + return proxy.injectCredentialsAndInvokeV2(request, requestFunction); + } + + @Override + public CompletableFuture injectCredentialsAndInvokeV2Async( + RequestT request, Function> requestFunction) { + throw new UnsupportedOperationException(); + } + + @Override + public > IterableT injectCredentialsAndInvokeIterableV2( + RequestT request, Function requestFunction) { + return proxy.injectCredentialsAndInvokeIterableV2(request, requestFunction); + } + + @Override + public ResponseInputStream injectCredentialsAndInvokeV2InputStream( + RequestT requestT, Function> function) { + + throw new UnsupportedOperationException(); + } + + @Override + public ResponseBytes injectCredentialsAndInvokeV2Bytes( + RequestT requestT, Function> function) { + + throw new UnsupportedOperationException(); + } + + @Override + public KafkaConnectClient client() { + return kafkaConnectClient; + } + }; + } +} diff --git a/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/CreateHandlerTest.java b/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/CreateHandlerTest.java new file mode 100644 index 0000000..51c75e7 --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/CreateHandlerTest.java @@ -0,0 +1,388 @@ +package software.amazon.kafkaconnect.customplugin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.ConflictException; +import software.amazon.awssdk.services.kafkaconnect.model.CreateCustomPluginRequest; +import software.amazon.awssdk.services.kafkaconnect.model.CreateCustomPluginResponse; +import software.amazon.awssdk.services.kafkaconnect.model.CustomPluginContentType; +import software.amazon.awssdk.services.kafkaconnect.model.CustomPluginLocation; +import software.amazon.awssdk.services.kafkaconnect.model.CustomPluginLocationDescription; +import software.amazon.awssdk.services.kafkaconnect.model.CustomPluginRevisionSummary; +import software.amazon.awssdk.services.kafkaconnect.model.CustomPluginState; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginResponse; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceResponse; +import software.amazon.awssdk.services.kafkaconnect.model.S3Location; +import software.amazon.awssdk.services.kafkaconnect.model.S3LocationDescription; +import software.amazon.awssdk.services.kafkaconnect.model.StateDescription; +import software.amazon.cloudformation.exceptions.CfnAlreadyExistsException; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnResourceConflictException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +@ExtendWith(MockitoExtension.class) +public class CreateHandlerTest extends AbstractTestBase { + + @Mock + private KafkaConnectClient kafkaConnectClient; + + @Mock + private ExceptionTranslator exceptionTranslator; + + @Mock + private Translator translator; + + private ReadHandler readHandler; + + private AmazonWebServicesClientProxy proxy; + + private ProxyClient proxyClient; + + private CreateHandler handler; + + @BeforeEach + public void setup() { + proxy = + new AmazonWebServicesClientProxy( + logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + proxyClient = proxyStub(proxy, kafkaConnectClient); + readHandler = new ReadHandler(exceptionTranslator, translator); + handler = new CreateHandler(exceptionTranslator, translator, readHandler); + } + + @AfterEach + public void tear_down() { + verify(kafkaConnectClient, atLeastOnce()).serviceName(); + verifyNoMoreInteractions(kafkaConnectClient, exceptionTranslator, translator); + } + + @Test + public void handleRequest_success() { + final ResourceModel resourceModel = TestData.getResourceModel(); + when(translator.translateToCreateRequest(resourceModel, TagHelper.convertToMap(resourceModel.getTags()))) + .thenReturn(TestData.CREATE_CUSTOM_PLUGIN_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.CREATE_CUSTOM_PLUGIN_REQUEST, kafkaConnectClient::createCustomPlugin)) + .thenReturn(TestData.CREATE_CUSTOM_PLUGIN_RESPONSE); + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL_WITH_ARN)) + .thenReturn(TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST); + final DescribeCustomPluginResponse describeCustomPluginResponse = + TestData.FULL_DESCRIBE_CUSTOM_PLUGIN_RESPONSE; + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST, kafkaConnectClient::describeCustomPlugin)) + .thenReturn( + describeCustomPluginResponse.toBuilder().customPluginState(CustomPluginState.CREATING).build()) + .thenReturn(describeCustomPluginResponse); + when(translator.translateFromReadResponse(describeCustomPluginResponse)) + .thenReturn(TestData.RESOURCE_MODEL_WITH_ARN); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, kafkaConnectClient::listTagsForResource)) + .thenReturn(TestData.LIST_TAGS_FOR_RESOURCE_RESPONSE); + + final ResourceHandlerRequest request = + TestData.getResourceHandlerRequest(resourceModel); + final ProgressEvent response = + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + + assertThat(response).isEqualTo(TestData.DESCRIBE_RESPONSE); + assertThat(response.getResourceModel().getTags()) + .isEqualTo(request.getDesiredResourceState().getTags()); + } + + @Test + public void handleRequest_throwsAlreadyExistsException_whenCustomPluginExists() { + final ResourceModel resourceModel = TestData.getResourceModel(); + final ConflictException cException = ConflictException.builder().build(); + final CfnAlreadyExistsException cfnException = new CfnAlreadyExistsException(cException); + when(translator.translateToCreateRequest(resourceModel, TagHelper.convertToMap(resourceModel.getTags()))) + .thenReturn(TestData.CREATE_CUSTOM_PLUGIN_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.CREATE_CUSTOM_PLUGIN_REQUEST, kafkaConnectClient::createCustomPlugin)) + .thenThrow(cException); + when(exceptionTranslator.translateToCfnException(cException, TestData.CUSTOM_PLUGIN_NAME)) + .thenReturn(cfnException); + final CfnAlreadyExistsException exception = + assertThrows( + CfnAlreadyExistsException.class, + () -> handler.handleRequest( + proxy, + TestData.getResourceHandlerRequest(resourceModel), + new CallbackContext(), + proxyClient, + logger)); + + assertThat(exception).isEqualTo(cfnException); + } + + public void handleRequest_afterNDescribeCustomPlugins_success() { + final ResourceModel resourceModel = TestData.getResourceModel(); + when(translator.translateToCreateRequest(resourceModel, TagHelper.convertToMap(resourceModel.getTags()))) + .thenReturn(TestData.CREATE_CUSTOM_PLUGIN_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.CREATE_CUSTOM_PLUGIN_REQUEST, kafkaConnectClient::createCustomPlugin)) + .thenReturn(TestData.CREATE_CUSTOM_PLUGIN_RESPONSE); + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL_WITH_ARN)) + .thenReturn(TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST); + final DescribeCustomPluginResponse describeRunningCustomPluginResponse = + TestData.describeResponseWithState(CustomPluginState.ACTIVE); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST, kafkaConnectClient::describeCustomPlugin)) + .thenReturn(TestData.describeResponseWithState(CustomPluginState.CREATING)) + .thenReturn(TestData.describeResponseWithState(CustomPluginState.CREATING)) + .thenReturn(describeRunningCustomPluginResponse); + when(translator.translateFromReadResponse(describeRunningCustomPluginResponse)) + .thenReturn(TestData.RESOURCE_MODEL_WITH_ARN); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, kafkaConnectClient::listTagsForResource)) + .thenReturn(TestData.LIST_TAGS_FOR_RESOURCE_RESPONSE); + + final ResourceHandlerRequest request = + TestData.getResourceHandlerRequest(resourceModel); + final ProgressEvent response = + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + + assertThat(response).isEqualTo(TestData.DESCRIBE_RESPONSE); + assertThat(response.getResourceModel().getTags()) + .isEqualTo(request.getDesiredResourceState().getTags()); + } + + @Test + public void handleRequest_throwsGeneralServiceException_whenDescribeCustomPluginThrowsException() { + final AwsServiceException cException = + AwsServiceException.builder().message(TestData.EXCEPTION_MESSAGE).build(); + when(translator.translateToCreateRequest(TestData.getResourceModel(), + TagHelper.convertToMap(TestData.getResourceModel().getTags()))) + .thenReturn(TestData.CREATE_CUSTOM_PLUGIN_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.CREATE_CUSTOM_PLUGIN_REQUEST, kafkaConnectClient::createCustomPlugin)) + .thenReturn(TestData.CREATE_CUSTOM_PLUGIN_RESPONSE); + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL_WITH_ARN)) + .thenReturn(TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST, kafkaConnectClient::describeCustomPlugin)) + .thenThrow(cException); + + runHandlerAndAssertExceptionThrownWithMessage( + CfnGeneralServiceException.class, + "Error occurred during operation 'AWS::KafkaConnect::CustomPlugin create request " + + "accepted but failed to get state due to: " + + TestData.EXCEPTION_MESSAGE + + "'."); + } + + @Test + public void handleRequest_throwsGeneralServiceException_whenCustomPluginsStateFails() { + setupMocksToReturnCustomPluginState(CustomPluginState.CREATE_FAILED); + + runHandlerAndAssertExceptionThrownWithMessage( + CfnGeneralServiceException.class, + "Error occurred during operation 'Couldn't create AWS::KafkaConnect::CustomPlugin " + + "due to create failure'."); + } + + @Test + public void handleRequest_throwsResourceConflictException_whenCustomPluginStartsDeletingDuringCreate() { + setupMocksToReturnCustomPluginState(CustomPluginState.DELETING); + + runHandlerAndAssertExceptionThrownWithMessage( + CfnResourceConflictException.class, + "Resource of type 'AWS::KafkaConnect::CustomPlugin' with identifier '" + + TestData.CUSTOM_PLUGIN_ARN + + "' has a conflict. Reason: Another process is deleting this AWS::KafkaConnect::CustomPlugin."); + } + + @Test + public void handleRequest_throwsGeneralServiceException_whenCustomPluginReturnsUnexpectedState() { + setupMocksToReturnCustomPluginState(CustomPluginState.UNKNOWN_TO_SDK_VERSION); + + runHandlerAndAssertExceptionThrownWithMessage( + CfnGeneralServiceException.class, + "Error occurred during operation 'AWS::KafkaConnect::CustomPlugin create request accepted " + + "but current state is unknown'."); + } + + private void setupMocksToReturnCustomPluginState(final CustomPluginState customPluginState) { + when(translator.translateToCreateRequest(TestData.getResourceModel(), + TagHelper.convertToMap(TestData.getResourceModel().getTags()))) + .thenReturn(TestData.CREATE_CUSTOM_PLUGIN_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.CREATE_CUSTOM_PLUGIN_REQUEST, kafkaConnectClient::createCustomPlugin)) + .thenReturn(TestData.CREATE_CUSTOM_PLUGIN_RESPONSE); + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL_WITH_ARN)) + .thenReturn(TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST, kafkaConnectClient::describeCustomPlugin)) + .thenReturn(TestData.describeResponseWithState(customPluginState)); + } + + private void runHandlerAndAssertExceptionThrownWithMessage( + final Class expectedExceptionClass, final String expectedMessage) { + + final Exception exception = + assertThrows( + expectedExceptionClass, + () -> handler.handleRequest( + proxy, + TestData.getResourceHandlerRequest(TestData.getResourceModel()), + new CallbackContext(), + proxyClient, + logger)); + + assertThat(exception.getMessage()).isEqualTo(expectedMessage); + } + + private static class TestData { + private static final String CUSTOM_PLUGIN_NAME = "unit-test-custom-plugin"; + private static final String CUSTOM_PLUGIN_DESCRIPTION = + "Unit testing custom plugin description"; + + private static final long CUSTOM_PLUGIN_REVISION = 1L; + private static final String CUSTOM_PLUGIN_S3_FILE_KEY = "file-key"; + + private static final String CUSTOM_PLUGIN_PROPERTIES_FILE_CONTENT = "propertiesFileContent"; + + private static final String CUSTOM_PLUGIN_ARN = + "arn:aws:kafkaconnect:us-east-1:1111111111:custom-plugin/unit-test-custom-plugin"; + private static final Instant CUSTOM_PLUGIN_CREATION_TIME = + OffsetDateTime.parse("2021-03-04T14:03:40.818Z").toInstant(); + private static final String CUSTOM_PLUGIN_LOCATION_BUCKET_ARN = "arn:aws:s3:::unit-test-bucket"; + private static final String CUSTOM_PLUGIN_LOCATION_FILE_KEY = "unit-test-file-key.zip"; + private static final String CUSTOM_PLUGIN_LOCATION_OBJECT_VERSION = "1"; + private static final String CUSTOM_PLUGIN_STATE_CODE = "custom-plugin-state-code"; + private static final String CUSTOM_PLUGIN_STATE_DESCRIPTION = "custom-plugin-state-description"; + private static final String CUSTOM_PLUGIN_S3_OBJECT_VERSION = "object-version"; + private static final String CUSTOM_PLUGIN_S3_BUCKET_ARN = "bucket-arn"; + private static final String CUSTOM_PLUGIN_FILE_MD5 = "abcd1234"; + private static final long CUSTOM_PLUGIN_FILE_SIZE = 123456L; + private static final String EXCEPTION_MESSAGE = "Exception message"; + private static final DescribeCustomPluginResponse FULL_DESCRIBE_CUSTOM_PLUGIN_RESPONSE = + DescribeCustomPluginResponse.builder() + .creationTime(CUSTOM_PLUGIN_CREATION_TIME) + .customPluginArn(CUSTOM_PLUGIN_ARN) + .customPluginState(CustomPluginState.ACTIVE) + .name(CUSTOM_PLUGIN_NAME) + .description(CUSTOM_PLUGIN_DESCRIPTION) + .stateDescription( + StateDescription.builder() + .code(CUSTOM_PLUGIN_STATE_CODE) + .message(CUSTOM_PLUGIN_STATE_DESCRIPTION) + .build()) + .latestRevision( + CustomPluginRevisionSummary.builder() + .contentType(CustomPluginContentType.ZIP) + .creationTime(CUSTOM_PLUGIN_CREATION_TIME) + .description(CUSTOM_PLUGIN_DESCRIPTION) + .fileDescription( + software.amazon.awssdk.services.kafkaconnect.model.CustomPluginFileDescription.builder() + .fileMd5(CUSTOM_PLUGIN_FILE_MD5) + .fileSize(CUSTOM_PLUGIN_FILE_SIZE) + .build()) + .location( + CustomPluginLocationDescription.builder() + .s3Location( + S3LocationDescription.builder() + .bucketArn(CUSTOM_PLUGIN_S3_BUCKET_ARN) + .fileKey(CUSTOM_PLUGIN_S3_FILE_KEY) + .objectVersion(CUSTOM_PLUGIN_S3_OBJECT_VERSION) + .build()) + .build()) + .revision(CUSTOM_PLUGIN_REVISION) + .build()) + .build(); + private static final CustomPluginLocation CUSTOM_PLUGIN_LOCATION = + CustomPluginLocation.builder() + .s3Location( + S3Location.builder() + .bucketArn(CUSTOM_PLUGIN_LOCATION_BUCKET_ARN) + .fileKey(CUSTOM_PLUGIN_LOCATION_FILE_KEY) + .objectVersion(CUSTOM_PLUGIN_LOCATION_OBJECT_VERSION) + .build()) + .build(); + + private static final CreateCustomPluginRequest CREATE_CUSTOM_PLUGIN_REQUEST = + CreateCustomPluginRequest.builder() + .contentType(CUSTOM_PLUGIN_PROPERTIES_FILE_CONTENT) + .description(CUSTOM_PLUGIN_DESCRIPTION) + .location(CUSTOM_PLUGIN_LOCATION) + .name(CUSTOM_PLUGIN_NAME) + .build(); + + private static final CreateCustomPluginResponse CREATE_CUSTOM_PLUGIN_RESPONSE = + CreateCustomPluginResponse.builder() + .customPluginArn(CUSTOM_PLUGIN_ARN) + .customPluginState(CustomPluginState.ACTIVE) + .name(CUSTOM_PLUGIN_NAME) + .revision(CUSTOM_PLUGIN_REVISION) + .build(); + + private static final DescribeCustomPluginRequest DESCRIBE_CUSTOM_PLUGIN_REQUEST = + DescribeCustomPluginRequest.builder().customPluginArn(CUSTOM_PLUGIN_ARN).build(); + + private static final ResourceModel RESOURCE_MODEL_WITH_ARN = + ResourceModel.builder() + .name(CUSTOM_PLUGIN_NAME) + .customPluginArn(CUSTOM_PLUGIN_ARN) + .tags(TagHelper.convertToList(TAGS)) + .build(); + + private static DescribeCustomPluginResponse describeResponseWithState( + final CustomPluginState state) { + return DescribeCustomPluginResponse.builder() + .customPluginArn(CUSTOM_PLUGIN_ARN) + .name(CUSTOM_PLUGIN_NAME) + .customPluginState(state) + .build(); + } + + private static final ProgressEvent DESCRIBE_RESPONSE = + ProgressEvent.builder() + .resourceModel(RESOURCE_MODEL_WITH_ARN) + .status(OperationStatus.SUCCESS) + .build(); + + private static ResourceHandlerRequest getResourceHandlerRequest( + final ResourceModel resourceModel) { + + return ResourceHandlerRequest.builder() + .desiredResourceState(resourceModel) + .desiredResourceTags(TAGS) + .build(); + } + + private static ResourceModel getResourceModel() { + return ResourceModel.builder() + .name(CUSTOM_PLUGIN_NAME) + .tags(TagHelper.convertToList(TAGS)) + .build(); + } + + private static final ListTagsForResourceRequest LIST_TAGS_FOR_RESOURCE_REQUEST = + ListTagsForResourceRequest.builder().resourceArn(CUSTOM_PLUGIN_ARN).build(); + + private static final ListTagsForResourceResponse LIST_TAGS_FOR_RESOURCE_RESPONSE = + ListTagsForResourceResponse.builder().tags(TAGS).build(); + } +} diff --git a/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/DeleteHandlerTest.java b/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/DeleteHandlerTest.java new file mode 100644 index 0000000..5d0d2c4 --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/DeleteHandlerTest.java @@ -0,0 +1,332 @@ +package software.amazon.kafkaconnect.customplugin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.BadRequestException; +import software.amazon.awssdk.services.kafkaconnect.model.CustomPluginState; +import software.amazon.awssdk.services.kafkaconnect.model.DeleteCustomPluginRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DeleteCustomPluginResponse; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginResponse; +import software.amazon.awssdk.services.kafkaconnect.model.NotFoundException; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnNotStabilizedException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +@ExtendWith(MockitoExtension.class) +public class DeleteHandlerTest extends AbstractTestBase { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + private KafkaConnectClient kafkaConnectClient; + + @Mock + private ExceptionTranslator exceptionTranslator; + + @Mock + private Translator translator; + + private DeleteHandler handler; + + @BeforeEach + public void setup() { + proxy = + new AmazonWebServicesClientProxy( + logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + proxyClient = proxyStub(proxy, kafkaConnectClient); + handler = new DeleteHandler(exceptionTranslator, translator); + } + + @AfterEach + public void tear_down() { + verify(kafkaConnectClient, atLeastOnce()).serviceName(); + verifyNoMoreInteractions(kafkaConnectClient, exceptionTranslator, translator); + } + + @Test + public void test_handleRequest_success() { + final DescribeCustomPluginRequest describeCustomPluginRequest = + TestData.createDescribeCustomPluginRequest(); + final ResourceModel resourceModel = TestData.createResourceModel(); + + when(translator.translateToReadRequest(resourceModel)).thenReturn(describeCustomPluginRequest); + when(proxyClient.injectCredentialsAndInvokeV2( + describeCustomPluginRequest, kafkaConnectClient::describeCustomPlugin)) + .thenReturn( + TestData.createDescribeCustomPluginResponse(CustomPluginState.ACTIVE)) // First call to + // validate resource + .thenReturn( + TestData.createDescribeCustomPluginResponse( + CustomPluginState.DELETING)) // Second call to + // stabalize + // resource + .thenThrow(NotFoundException.class); // Third call to finalise deletion of the resource + when(kafkaConnectClient.deleteCustomPlugin(any(DeleteCustomPluginRequest.class))) + .thenReturn(TestData.createDeleteCustomPluginResponse()); + when(translator.translateToDeleteRequest(resourceModel)) + .thenReturn(TestData.createDeleteCustomPluginRequest()); + + final ProgressEvent response = + handler.handleRequest( + proxy, + TestData.createResourceHandlerRequest(resourceModel), + new CallbackContext(), + proxyClient, + logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isNull(); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + verify(kafkaConnectClient, times(1)).deleteCustomPlugin(any(DeleteCustomPluginRequest.class)); + verify(kafkaConnectClient, times(3)) + .describeCustomPlugin(any(DescribeCustomPluginRequest.class)); + } + + @Test + public void test_handleRequest_failure_dueToBadRequest() { + final DescribeCustomPluginRequest describeCustomPluginRequest = + TestData.createDescribeCustomPluginRequest(); + final ResourceModel resourceModel = TestData.createResourceModel(); + final BadRequestException serviceException = BadRequestException.builder().build(); + final CfnInvalidRequestException cfnException = + new CfnInvalidRequestException(serviceException); + + when(translator.translateToReadRequest(resourceModel)).thenReturn(describeCustomPluginRequest); + when(proxyClient.injectCredentialsAndInvokeV2( + describeCustomPluginRequest, kafkaConnectClient::describeCustomPlugin)) + .thenThrow(serviceException); + when(exceptionTranslator.translateToCfnException(serviceException, TestData.CUSTOM_PLUGIN_ARN)) + .thenReturn(cfnException); + + assertThrows( + CfnInvalidRequestException.class, + () -> handler.handleRequest( + proxy, + TestData.createResourceHandlerRequest(resourceModel), + new CallbackContext(), + proxyClient, + logger)); + + verify(kafkaConnectClient, times(0)).deleteCustomPlugin(any(DeleteCustomPluginRequest.class)); + verify(kafkaConnectClient, times(1)) + .describeCustomPlugin(any(DescribeCustomPluginRequest.class)); + } + + @Test + public void test_handleRequest_failure_dueToEmptyCustomPluginArn() { + final DescribeCustomPluginRequest describeCustomPluginRequest = + DescribeCustomPluginRequest.builder().customPluginArn(null).build(); + final ResourceModel resourceModel = TestData.createResourceModel(); + + when(translator.translateToReadRequest(resourceModel)).thenReturn(describeCustomPluginRequest); + + assertThrows( + CfnNotFoundException.class, + () -> handler.handleRequest( + proxy, + TestData.createResourceHandlerRequest(resourceModel), + new CallbackContext(), + proxyClient, + logger)); + + verify(kafkaConnectClient, times(0)).deleteCustomPlugin(any(DeleteCustomPluginRequest.class)); + verify(kafkaConnectClient, times(0)) + .describeCustomPlugin(any(DescribeCustomPluginRequest.class)); + } + + @Test + public void test_handleRequest_failure_dueToCustomPluginNotFound() { + final DescribeCustomPluginRequest describeCustomPluginRequest = TestData.createDescribeCustomPluginRequest(); + final ResourceModel resourceModel = TestData.createResourceModel(); + final NotFoundException serviceException = NotFoundException.builder().build(); + final CfnNotFoundException cfnException = new CfnNotFoundException(serviceException); + + when(translator.translateToReadRequest(resourceModel)).thenReturn(describeCustomPluginRequest); + when(proxyClient.injectCredentialsAndInvokeV2( + describeCustomPluginRequest, kafkaConnectClient::describeCustomPlugin)) + .thenThrow(serviceException); + when(exceptionTranslator.translateToCfnException(serviceException, TestData.CUSTOM_PLUGIN_ARN)) + .thenReturn(cfnException); + + assertThrows( + CfnNotFoundException.class, + () -> handler.handleRequest( + proxy, + TestData.createResourceHandlerRequest(resourceModel), + new CallbackContext(), + proxyClient, + logger)); + + verify(kafkaConnectClient, times(0)).deleteCustomPlugin(any(DeleteCustomPluginRequest.class)); + verify(kafkaConnectClient, times(1)) + .describeCustomPlugin(any(DescribeCustomPluginRequest.class)); + } + + @Test + public void test_handleRequest_failure_alreadyDeleted() { + final DescribeCustomPluginRequest describeCustomPluginRequest = + TestData.createDescribeCustomPluginRequest(); + final ResourceModel resourceModel = TestData.createResourceModel(); + final NotFoundException serviceException = NotFoundException.builder().build(); + final CfnNotFoundException cfnException = new CfnNotFoundException(serviceException); + + when(translator.translateToReadRequest(resourceModel)).thenReturn(describeCustomPluginRequest); + when(proxyClient.injectCredentialsAndInvokeV2( + describeCustomPluginRequest, kafkaConnectClient::describeCustomPlugin)) + .thenReturn(TestData.createDescribeCustomPluginResponse(CustomPluginState.ACTIVE)); + when(kafkaConnectClient.deleteCustomPlugin(any(DeleteCustomPluginRequest.class))) + .thenThrow(serviceException); + when(exceptionTranslator.translateToCfnException(serviceException, TestData.CUSTOM_PLUGIN_ARN)) + .thenReturn(cfnException); + when(translator.translateToDeleteRequest(resourceModel)) + .thenReturn(TestData.createDeleteCustomPluginRequest()); + + assertThrows( + CfnNotFoundException.class, + () -> handler.handleRequest( + proxy, + TestData.createResourceHandlerRequest(resourceModel), + new CallbackContext(), + proxyClient, + logger)); + + verify(kafkaConnectClient, times(1)).deleteCustomPlugin(any(DeleteCustomPluginRequest.class)); + verify(kafkaConnectClient, times(1)) + .describeCustomPlugin(any(DescribeCustomPluginRequest.class)); + } + + @Test + public void test_handleRequest_failure_stabilizeHandlesServiceExceptions() { + final DescribeCustomPluginRequest describeCustomPluginRequest = + TestData.createDescribeCustomPluginRequest(); + final ResourceModel resourceModel = TestData.createResourceModel(); + + when(translator.translateToReadRequest(resourceModel)).thenReturn(describeCustomPluginRequest); + when(proxyClient.injectCredentialsAndInvokeV2( + describeCustomPluginRequest, kafkaConnectClient::describeCustomPlugin)) + .thenReturn(TestData.createDescribeCustomPluginResponse(CustomPluginState.ACTIVE)) + .thenThrow(AwsServiceException.builder().build()); + when(kafkaConnectClient.deleteCustomPlugin(any(DeleteCustomPluginRequest.class))) + .thenReturn(TestData.createDeleteCustomPluginResponse()); + when(translator.translateToDeleteRequest(resourceModel)) + .thenReturn(TestData.createDeleteCustomPluginRequest()); + + final ProgressEvent response = + handler.handleRequest( + proxy, + TestData.createResourceHandlerRequest(resourceModel), + new CallbackContext(), + proxyClient, + logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.FAILED); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModels()).isNull(); + + verify(kafkaConnectClient, times(1)).deleteCustomPlugin(any(DeleteCustomPluginRequest.class)); + verify(kafkaConnectClient, times(2)) + .describeCustomPlugin(any(DescribeCustomPluginRequest.class)); + verify(exceptionTranslator, times(1)) + .translateToCfnException(any(AwsServiceException.class), eq(TestData.CUSTOM_PLUGIN_ARN)); + } + + @Test + public void test_handleRequest_failure_stabilizeFails_dueToUnexpectedState() { + final DescribeCustomPluginRequest describeCustomPluginRequest = + TestData.createDescribeCustomPluginRequest(); + final ResourceModel resourceModel = TestData.createResourceModel(); + + when(translator.translateToReadRequest(resourceModel)).thenReturn(describeCustomPluginRequest); + when(proxyClient.injectCredentialsAndInvokeV2( + describeCustomPluginRequest, kafkaConnectClient::describeCustomPlugin)) + .thenReturn(TestData.createDescribeCustomPluginResponse(CustomPluginState.ACTIVE)) + .thenReturn( + TestData.createDescribeCustomPluginResponse(CustomPluginState.UNKNOWN_TO_SDK_VERSION)); + when(kafkaConnectClient.deleteCustomPlugin(any(DeleteCustomPluginRequest.class))) + .thenReturn(TestData.createDeleteCustomPluginResponse()); + when(translator.translateToDeleteRequest(resourceModel)) + .thenReturn(TestData.createDeleteCustomPluginRequest()); + + assertThrows( + CfnNotStabilizedException.class, + () -> handler.handleRequest( + proxy, + TestData.createResourceHandlerRequest(resourceModel), + new CallbackContext(), + proxyClient, + logger)); + + verify(kafkaConnectClient, times(1)).deleteCustomPlugin(any(DeleteCustomPluginRequest.class)); + verify(kafkaConnectClient, times(2)) + .describeCustomPlugin(any(DescribeCustomPluginRequest.class)); + } + + private static class TestData { + private static final String CUSTOM_PLUGIN_ARN = + "arn:aws:kafkaconnect:us-east-1:123456789:custom-plugin/unit-test-custom-plugin"; + + private static ResourceModel createResourceModel() { + return ResourceModel.builder().customPluginArn(CUSTOM_PLUGIN_ARN).build(); + } + + private static ResourceHandlerRequest createResourceHandlerRequest( + ResourceModel resourceModel) { + return ResourceHandlerRequest.builder() + .desiredResourceState(resourceModel) + .build(); + } + + private static DescribeCustomPluginRequest createDescribeCustomPluginRequest() { + return DescribeCustomPluginRequest.builder().customPluginArn(CUSTOM_PLUGIN_ARN).build(); + } + + private static DescribeCustomPluginResponse createDescribeCustomPluginResponse( + CustomPluginState state) { + return DescribeCustomPluginResponse.builder() + .customPluginArn(CUSTOM_PLUGIN_ARN) + .customPluginState(state) + .build(); + } + + private static DeleteCustomPluginRequest createDeleteCustomPluginRequest() { + return DeleteCustomPluginRequest.builder().customPluginArn(CUSTOM_PLUGIN_ARN).build(); + } + + private static DeleteCustomPluginResponse createDeleteCustomPluginResponse() { + return DeleteCustomPluginResponse.builder().customPluginArn(CUSTOM_PLUGIN_ARN).build(); + } + } +} diff --git a/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/ExceptionTranslatorTest.java b/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/ExceptionTranslatorTest.java new file mode 100644 index 0000000..8caf830 --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/ExceptionTranslatorTest.java @@ -0,0 +1,102 @@ +package software.amazon.kafkaconnect.customplugin; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.model.BadRequestException; +import software.amazon.awssdk.services.kafkaconnect.model.ConflictException; +import software.amazon.awssdk.services.kafkaconnect.model.NotFoundException; +import software.amazon.awssdk.services.kafkaconnect.model.TooManyRequestsException; +import software.amazon.awssdk.services.kafkaconnect.model.UnauthorizedException; +import software.amazon.awssdk.services.kafkaconnect.model.InternalServerErrorException; + +import software.amazon.cloudformation.exceptions.*; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +public class ExceptionTranslatorTest { + private static final String TEST_IDENTIFIER = "custom-plugin-test-name"; + private static final String TEST_MESSAGE = "test-message"; + private final ExceptionTranslator exceptionTranslator = new ExceptionTranslator(); + + @Test + public void translateToCfnException_NotFoundException_MapsToCfnNotFoundException() { + final NotFoundException exception = NotFoundException.builder() + .message(TEST_MESSAGE) + .build(); + + runTranslateToCfnExceptionAndVerifyOutput(exception, CfnNotFoundException.class, + "Resource of type 'AWS::KafkaConnect::CustomPlugin' with identifier 'custom-plugin-test-name' was not found."); + } + + @Test + public void translateToCfnException_BadRequestException_MapsToCfnInvalidRequestException() { + final BadRequestException exception = BadRequestException.builder() + .message(TEST_MESSAGE) + .build(); + + runTranslateToCfnExceptionAndVerifyOutput(exception, CfnInvalidRequestException.class, + "Invalid request provided: " + TEST_MESSAGE); + } + + @Test + public void translateToCfnException_ConflictException_MapsToCfnAlreadyExistsException() { + final ConflictException exception = ConflictException.builder() + .message(TEST_MESSAGE) + .build(); + + runTranslateToCfnExceptionAndVerifyOutput(exception, CfnAlreadyExistsException.class, + "Resource of type 'AWS::KafkaConnect::CustomPlugin' with identifier 'custom-plugin-test-name' " + + + "already exists."); + } + + @Test + public void translateToCfnException_InternalServerErrorException_MapsToCfnInternalFailureException() { + final InternalServerErrorException exception = InternalServerErrorException.builder() + .message(TEST_MESSAGE) + .build(); + + runTranslateToCfnExceptionAndVerifyOutput(exception, CfnInternalFailureException.class, + "Internal error occurred."); + } + + @Test + public void translateToCfnException_UnauthorizedException_MapsToCfnAccessDeniedException() { + final UnauthorizedException exception = UnauthorizedException.builder() + .message(TEST_MESSAGE) + .build(); + + runTranslateToCfnExceptionAndVerifyOutput(exception, CfnAccessDeniedException.class, + "Access denied for operation 'AWS::KafkaConnect::CustomPlugin'."); + } + + @Test + public void translateToCfnException_TooManyRequestsException_MapsToCfnServiceLimitExceededException() { + final TooManyRequestsException exception = TooManyRequestsException.builder() + .message(TEST_MESSAGE) + .build(); + + runTranslateToCfnExceptionAndVerifyOutput(exception, CfnServiceLimitExceededException.class, TEST_MESSAGE); + } + + @Test + public void translateToCfnException_Other_MapsToCfnGeneralServiceException() { + final AwsServiceException exception = AwsServiceException.builder() + .message(TEST_MESSAGE) + .build(); + + runTranslateToCfnExceptionAndVerifyOutput(exception, CfnGeneralServiceException.class, TEST_MESSAGE); + } + + private void runTranslateToCfnExceptionAndVerifyOutput(final AwsServiceException exception, + final Class expectedExceptionClass, final String expectedMessage) { + + final BaseHandlerException result = exceptionTranslator.translateToCfnException(exception, TEST_IDENTIFIER); + + assertThat(result.getClass()).isEqualTo(expectedExceptionClass); + assertThat(result.getMessage()).isEqualTo(expectedMessage); + } +} diff --git a/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/ListHandlerTest.java b/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/ListHandlerTest.java new file mode 100644 index 0000000..13dd7c7 --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/ListHandlerTest.java @@ -0,0 +1,165 @@ +package software.amazon.kafkaconnect.customplugin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.CustomPluginSummary; +import software.amazon.awssdk.services.kafkaconnect.model.ListCustomPluginsRequest; +import software.amazon.awssdk.services.kafkaconnect.model.ListCustomPluginsResponse; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +@ExtendWith(MockitoExtension.class) +public class ListHandlerTest extends AbstractTestBase { + @Mock + private KafkaConnectClient kafkaConnectClient; + + @Mock + private ExceptionTranslator exceptionTranslator; + + @Mock + private Translator translator; + + private AmazonWebServicesClientProxy proxy; + + private ProxyClient proxyClient; + + private ListHandler handler; + + @BeforeEach + public void setup() { + proxy = + new AmazonWebServicesClientProxy( + logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + proxyClient = proxyStub(proxy, kafkaConnectClient); + handler = new ListHandler(exceptionTranslator, translator); + } + + @AfterEach + public void tear_down() { + verifyNoMoreInteractions(kafkaConnectClient, exceptionTranslator, translator); + } + + @Test + public void handleRequest_success() { + when(translator.translateToListRequest(TestData.NEXT_TOKEN_1)) + .thenReturn(TestData.LIST_CUSTOM_PLUGINS_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.LIST_CUSTOM_PLUGINS_REQUEST, kafkaConnectClient::listCustomPlugins)) + .thenReturn(TestData.LIST_CUSTOM_PLUGINS_RESPONSE); + when(translator.translateFromListResponse(TestData.LIST_CUSTOM_PLUGINS_RESPONSE)) + .thenReturn(TestData.CUSTOM_PLUGIN_MODELS); + + final ProgressEvent response = + handler.handleRequest( + proxy, TestData.RESOURCE_HANDLER_REQUEST, new CallbackContext(), proxyClient, logger); + + assertThat(response).isEqualTo(TestData.SUCCESS_RESPONSE); + } + + @Test + public void handleRequest_throwsException_whenListCustomPluginsFails() { + final AwsServiceException serviceException = AwsServiceException.builder().build(); + final CfnGeneralServiceException cfnException = + new CfnGeneralServiceException(serviceException); + when(translator.translateToListRequest(TestData.NEXT_TOKEN_1)) + .thenReturn(TestData.LIST_CUSTOM_PLUGINS_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.LIST_CUSTOM_PLUGINS_REQUEST, kafkaConnectClient::listCustomPlugins)) + .thenThrow(serviceException); + when(exceptionTranslator.translateToCfnException(serviceException, TestData.AWS_ACCOUNT_ID)) + .thenReturn(cfnException); + + final CfnGeneralServiceException exception = + assertThrows( + CfnGeneralServiceException.class, + () -> handler.handleRequest( + proxy, + TestData.RESOURCE_HANDLER_REQUEST, + new CallbackContext(), + proxyClient, + logger)); + + assertThat(exception).isEqualTo(cfnException); + } + + private static class TestData { + private static final String NEXT_TOKEN_1 = "next-token-1"; + private static final String NEXT_TOKEN_2 = "next-token-2"; + private static final String AWS_ACCOUNT_ID = "1111111111"; + private static final String CUSTOM_PLUGIN_ARN_1 = + "arn:aws:kafkaconnect:us-east-1:123456789:custom-plugin/unit-test-custom-plugin-1"; + private static final String CUSTOM_PLUGIN_ARN_2 = + "arn:aws:kafkaconnect:us-east-1:123456789:custom-plugin/unit-test-custom-plugin-2"; + private static final String CUSTOM_PLUGIN_NAME_1 = "unit-test-custom-plugin-1"; + private static final String CUSTOM_PLUGIN_NAME_2 = "unit-test-custom-plugin-2"; + + private static final ListCustomPluginsRequest LIST_CUSTOM_PLUGINS_REQUEST = + ListCustomPluginsRequest.builder().nextToken(NEXT_TOKEN_1).build(); + + private static final ResourceModel MODEL = ResourceModel.builder().build(); + + private static final ResourceHandlerRequest RESOURCE_HANDLER_REQUEST = + ResourceHandlerRequest.builder() + .desiredResourceState(MODEL) + .nextToken(NEXT_TOKEN_1) + .awsAccountId(AWS_ACCOUNT_ID) + .build(); + + public static final List CUSTOM_PLUGIN_MODELS = + Arrays.asList( + buildResourceModel(CUSTOM_PLUGIN_NAME_1, CUSTOM_PLUGIN_ARN_1), + buildResourceModel(CUSTOM_PLUGIN_NAME_2, CUSTOM_PLUGIN_ARN_2)); + + private static final List CUSTOM_PLUGIN_SUMMARIES = + Arrays.asList( + buildCustomPluginSummary(CUSTOM_PLUGIN_NAME_1, CUSTOM_PLUGIN_ARN_1), + buildCustomPluginSummary(CUSTOM_PLUGIN_NAME_2, CUSTOM_PLUGIN_ARN_2)); + + private static final ProgressEvent SUCCESS_RESPONSE = + ProgressEvent.builder() + .resourceModels(CUSTOM_PLUGIN_MODELS) + .nextToken(NEXT_TOKEN_2) + .status(OperationStatus.SUCCESS) + .build(); + + private static final ListCustomPluginsResponse LIST_CUSTOM_PLUGINS_RESPONSE = + ListCustomPluginsResponse.builder() + .nextToken(TestData.NEXT_TOKEN_2) + .customPlugins(TestData.CUSTOM_PLUGIN_SUMMARIES) + .build(); + + private static final CustomPluginSummary buildCustomPluginSummary( + final String customPluginName, final String customPluginArn) { + return CustomPluginSummary.builder() + .customPluginArn(customPluginArn) + .name(customPluginName) + .build(); + } + + private static ResourceModel buildResourceModel( + final String customPluginName, final String customPluginArn) { + return ResourceModel.builder() + .customPluginArn(customPluginArn) + .name(customPluginName) + .build(); + } + } +} diff --git a/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/ReadHandlerTest.java b/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/ReadHandlerTest.java new file mode 100644 index 0000000..d57a742 --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/ReadHandlerTest.java @@ -0,0 +1,218 @@ +package software.amazon.kafkaconnect.customplugin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.HashMap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginResponse; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceResponse; +import software.amazon.awssdk.services.kafkaconnect.model.NotFoundException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +@ExtendWith(MockitoExtension.class) +public class ReadHandlerTest extends AbstractTestBase { + + @Mock + private KafkaConnectClient kafkaConnectClient; + + @Mock + private ExceptionTranslator exceptionTranslator; + + @Mock + private Translator translator; + + private AmazonWebServicesClientProxy proxy; + + private ProxyClient proxyClient; + + private ReadHandler handler; + + @BeforeEach + public void setup() { + proxy = + new AmazonWebServicesClientProxy( + logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + proxyClient = proxyStub(proxy, kafkaConnectClient); + handler = new ReadHandler(exceptionTranslator, translator); + } + + @AfterEach + public void tear_down() { + verify(kafkaConnectClient, atLeastOnce()).serviceName(); + verifyNoMoreInteractions(kafkaConnectClient, exceptionTranslator, translator); + } + + @Test + public void handleRequest_returnsCustomPluginWhenResourceModelIsPassedAndNonEmptyTags_success() { + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST, kafkaConnectClient::describeCustomPlugin)) + .thenReturn(TestData.DESCRIBE_CUSTOM_PLUGIN_RESPONSE); + when(translator.translateFromReadResponse(TestData.DESCRIBE_CUSTOM_PLUGIN_RESPONSE)) + .thenReturn(TestData.RESPONSE_RESOURCE_MODEL); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, kafkaConnectClient::listTagsForResource)) + .thenReturn(TestData.LIST_TAGS_FOR_RESOURCE_RESPONSE); + + final ProgressEvent response = + handler.handleRequest( + proxy, TestData.RESOURCE_HANDLER_REQUEST, new CallbackContext(), proxyClient, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel().getCustomPluginArn()) + .isEqualTo(TestData.CUSTOM_PLUGIN_ARN); + assertThat(response.getResourceModel().getName()).isEqualTo(TestData.CUSTOM_PLUGIN_NAME); + assertThat(response.getResourceModel().getTags()).isEqualTo(TagHelper.convertToList(TAGS)); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void handleRequest_returnsCustomPluginWhenResourceModelIsPassedAndEmptyTags_success() { + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST, kafkaConnectClient::describeCustomPlugin)) + .thenReturn(TestData.DESCRIBE_CUSTOM_PLUGIN_RESPONSE); + when(translator.translateFromReadResponse(TestData.DESCRIBE_CUSTOM_PLUGIN_RESPONSE)) + .thenReturn(TestData.RESPONSE_RESOURCE_MODEL_EMPTY_TAGS); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, kafkaConnectClient::listTagsForResource)) + .thenReturn(TestData.LIST_TAGS_FOR_RESOURCE_RESPONSE_EMPTY_TAGS); + + final ProgressEvent response = + handler.handleRequest( + proxy, TestData.RESOURCE_HANDLER_REQUEST, new CallbackContext(), proxyClient, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel().getCustomPluginArn()) + .isEqualTo(TestData.CUSTOM_PLUGIN_ARN); + assertThat(response.getResourceModel().getName()).isEqualTo(TestData.CUSTOM_PLUGIN_NAME); + assertThat(response.getResourceModel().getTags()).isNullOrEmpty(); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void handleRequest_throwsCfnNotFoundException_whenDescribeCustomPluginFails() { + final NotFoundException serviceException = NotFoundException.builder().build(); + final CfnNotFoundException cfnException = new CfnNotFoundException(serviceException); + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST, kafkaConnectClient::describeCustomPlugin)) + .thenThrow(serviceException); + when(exceptionTranslator.translateToCfnException(serviceException, TestData.CUSTOM_PLUGIN_ARN)) + .thenReturn(cfnException); + + final CfnNotFoundException exception = + assertThrows( + CfnNotFoundException.class, + () -> handler.handleRequest( + proxy, + TestData.RESOURCE_HANDLER_REQUEST, + new CallbackContext(), + proxyClient, + logger)); + + assertThat(exception).isEqualTo(cfnException); + } + + @Test + public void handleRequest_throwsCfnNotFoundException_whenListTagsForResourceFails() { + final NotFoundException serviceException = NotFoundException.builder().build(); + final CfnNotFoundException cfnException = new CfnNotFoundException(serviceException); + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST, kafkaConnectClient::describeCustomPlugin)) + .thenReturn(TestData.DESCRIBE_CUSTOM_PLUGIN_RESPONSE); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, kafkaConnectClient::listTagsForResource)) + .thenThrow(serviceException); + when(exceptionTranslator.translateToCfnException(serviceException, TestData.CUSTOM_PLUGIN_ARN)) + .thenReturn(cfnException); + + final CfnNotFoundException exception = + assertThrows( + CfnNotFoundException.class, + () -> handler.handleRequest( + proxy, + TestData.RESOURCE_HANDLER_REQUEST, + new CallbackContext(), + proxyClient, + logger)); + + assertThat(exception).isEqualTo(cfnException); + } + + private static class TestData { + private static final String CUSTOM_PLUGIN_ARN = + "arn:aws:kafkaconnect:us-east-1:123456789:custom-plugin/unit-test-custom-plugin"; + private static final String CUSTOM_PLUGIN_NAME = "unit-test-custom-plugin"; + + private static final ResourceModel RESPONSE_RESOURCE_MODEL = + ResourceModel.builder() + .customPluginArn(CUSTOM_PLUGIN_ARN) + .name(CUSTOM_PLUGIN_NAME) + .tags(TagHelper.convertToList(TAGS)) + .build(); + + private static final ResourceModel RESPONSE_RESOURCE_MODEL_EMPTY_TAGS = + ResourceModel.builder().customPluginArn(CUSTOM_PLUGIN_ARN).name(CUSTOM_PLUGIN_NAME).build(); + + private static final ResourceModel RESOURCE_MODEL = + ResourceModel.builder().customPluginArn(CUSTOM_PLUGIN_ARN).build(); + + private static final ResourceHandlerRequest RESOURCE_HANDLER_REQUEST = + ResourceHandlerRequest.builder() + .desiredResourceState(RESOURCE_MODEL) + .build(); + + private static final DescribeCustomPluginRequest DESCRIBE_CUSTOM_PLUGIN_REQUEST = + DescribeCustomPluginRequest.builder().customPluginArn(CUSTOM_PLUGIN_ARN).build(); + + private static final DescribeCustomPluginResponse DESCRIBE_CUSTOM_PLUGIN_RESPONSE = + DescribeCustomPluginResponse.builder() + .customPluginArn(CUSTOM_PLUGIN_ARN) + .name(CUSTOM_PLUGIN_NAME) + .build(); + + private static final ListTagsForResourceRequest LIST_TAGS_FOR_RESOURCE_REQUEST = + ListTagsForResourceRequest.builder().resourceArn(CUSTOM_PLUGIN_ARN).build(); + + private static final ListTagsForResourceResponse LIST_TAGS_FOR_RESOURCE_RESPONSE = + ListTagsForResourceResponse.builder().tags(TAGS).build(); + + private static final ListTagsForResourceResponse LIST_TAGS_FOR_RESOURCE_RESPONSE_EMPTY_TAGS = + ListTagsForResourceResponse.builder().tags(new HashMap<>()).build(); + } +} diff --git a/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/TranslatorTest.java b/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/TranslatorTest.java new file mode 100644 index 0000000..ee7cc6e --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/TranslatorTest.java @@ -0,0 +1,329 @@ +package software.amazon.kafkaconnect.customplugin; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.kafkaconnect.model.CreateCustomPluginRequest; +import software.amazon.awssdk.services.kafkaconnect.model.CustomPluginContentType; +import software.amazon.awssdk.services.kafkaconnect.model.CustomPluginLocationDescription; +import software.amazon.awssdk.services.kafkaconnect.model.CustomPluginRevisionSummary; +import software.amazon.awssdk.services.kafkaconnect.model.CustomPluginState; +import software.amazon.awssdk.services.kafkaconnect.model.CustomPluginSummary; +import software.amazon.awssdk.services.kafkaconnect.model.DeleteCustomPluginRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginResponse; +import software.amazon.awssdk.services.kafkaconnect.model.ListCustomPluginsRequest; +import software.amazon.awssdk.services.kafkaconnect.model.ListCustomPluginsResponse; +import software.amazon.awssdk.services.kafkaconnect.model.S3LocationDescription; +import software.amazon.awssdk.services.kafkaconnect.model.StateDescription; +import software.amazon.awssdk.services.kafkaconnect.model.TagResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.UntagResourceRequest; + +@ExtendWith(MockitoExtension.class) +public class TranslatorTest extends AbstractTestBase { + + private Translator translator = new Translator(); + + @Test + public void translateToCreateRequest_success() { + assertThat(translator.translateToCreateRequest(TestData.CREATE_REQUEST_RESOURCE_MODEL, + TagHelper.convertToMap(TestData.CREATE_REQUEST_RESOURCE_MODEL.getTags()))) + .isEqualTo(TestData.CREATE_CUSTOM_PLUGIN_REQUEST); + } + + @Test + public void translateToReadRequest_success() { + assertThat(translator.translateToReadRequest(TestData.READ_REQUEST_RESOURCE_MODEL)) + .isEqualTo(TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST); + } + + @Test + public void translateFromReadResponse_fullCustomPlugin_success() { + assertThat(translator.translateFromReadResponse(TestData.FULL_DESCRIBE_CUSTOM_PLUGIN_RESPONSE)) + .isEqualTo(TestData.FULL_RESOURCE_DESCRIBE_MODEL); + } + + @Test + public void translateToListRequest_success() { + assertThat(translator.translateToListRequest(TestData.NEXT_TOKEN)) + .isEqualTo(TestData.LIST_CUSTOM_PLUGINS_REQUEST); + } + + @Test + public void translateFromListResponse_success() { + assertThat(translator.translateFromListResponse(TestData.LIST_CUSTOM_PLUGINS_RESPONSE)) + .isEqualTo(TestData.LIST_CUSTOM_PLUGIN_MODELS); + } + + @Test + public void translateToTagResourceRequest_success() { + final ResourceModel model = TestData.buildBaseModel(TestData.CUSTOM_PLUGIN_ARN); + assertThat(Translator.translateToTagRequest(model, TAGS)) + .isEqualTo(TestData.TAG_RESOURCE_REQUEST); + } + + @Test + public void translateToUntagResourceRequest_success() { + final Set tagsToUntag = new HashSet<>(); + tagsToUntag.add(TestData.TAG_KEY_TO_REMOVE); + final ResourceModel model = TestData.buildBaseModel(TestData.CUSTOM_PLUGIN_ARN); + assertThat(Translator.translateToUntagRequest(model, tagsToUntag)) + .isEqualTo(TestData.UNTAG_RESOURCE_REQUEST); + } + + @Test + public void translateToDeleteRequest_success() { + assertThat(translator.translateToDeleteRequest(TestData.DELETE_REQUEST_RESOURCE_MODEL)) + .isEqualTo(TestData.DELETE_CUSTOM_PLUGIN_REQUEST); + } + + @Test + public void sdkCustomPluginFileDescriptionToResourceCustomPluginFileDescription_success() { + assertThat( + Translator.sdkCustomPluginFileDescriptionToResourceCustomPluginFileDescription( + TestData.FULL_DESCRIBE_CUSTOM_PLUGIN_RESPONSE.latestRevision().fileDescription())) + .isEqualTo(TestData.FULL_RESOURCE_DESCRIBE_MODEL.getFileDescription()); + assertThat( + Translator.sdkCustomPluginFileDescriptionToResourceCustomPluginFileDescription(null)) + .isEqualTo(null); + } + + @Test + public void sdkS3LocationDescriptionToResourceS3Location_success() { + assertThat( + Translator.sdkS3LocationDescriptionToResourceS3Location( + TestData.FULL_DESCRIBE_CUSTOM_PLUGIN_RESPONSE.latestRevision().location().s3Location())) + .isEqualTo(TestData.FULL_RESOURCE_DESCRIBE_MODEL.getLocation().getS3Location()); + assertThat( + Translator.sdkS3LocationDescriptionToResourceS3Location(null)) + .isEqualTo(null); + } + + @Test + public void sdkCustomPluginLocationDescriptionToResourceCustomPluginLocation_success() { + assertThat( + Translator.sdkCustomPluginLocationDescriptionToResourceCustomPluginLocation( + TestData.FULL_DESCRIBE_CUSTOM_PLUGIN_RESPONSE.latestRevision().location())) + .isEqualTo(TestData.FULL_RESOURCE_DESCRIBE_MODEL.getLocation()); + assertThat( + Translator.sdkCustomPluginLocationDescriptionToResourceCustomPluginLocation(null)) + .isEqualTo(null); + } + + @Test + public void resourceS3LocationToSdkS3Location_success() { + assertThat( + Translator.resourceS3LocationToSdkS3Location( + TestData.CREATE_REQUEST_RESOURCE_MODEL.getLocation().getS3Location())) + .isEqualTo(TestData.CREATE_CUSTOM_PLUGIN_REQUEST.location().s3Location()); + assertThat( + Translator.resourceS3LocationToSdkS3Location(null)) + .isEqualTo(null); + } + + @Test + public void resourceCustomPluginLocationToSdkCustomPluginLocation_success() { + assertThat( + Translator.resourceCustomPluginLocationToSdkCustomPluginLocation( + TestData.CREATE_REQUEST_RESOURCE_MODEL.getLocation())) + .isEqualTo(TestData.CREATE_CUSTOM_PLUGIN_REQUEST.location()); + assertThat( + Translator.resourceCustomPluginLocationToSdkCustomPluginLocation(null)) + .isEqualTo(null); + } + + private static class TestData { + private static final String CUSTOM_PLUGIN_ARN = + "arn:aws:kafkaconnect:us-east-1:123456789:custom-plugin/unit-test-custom-plugin"; + private static final String CUSTOM_PLUGIN_NAME = "unit-test-custom-plugin"; + private static final String CUSTOM_PLUGIN_ARN_2 = + "arn:aws:kafkaconnect:us-east-1:123456789:custom-plugin/unit-test-custom-plugin-2"; + private static final String CUSTOM_PLUGIN_NAME_2 = "unit-test-custom-plugin-2"; + private static final String CUSTOM_PLUGIN_DESCRIPTION = + "Unit testing custom plugin description"; + private static final long CUSTOM_PLUGIN_REVISION = 1L; + private static final Instant CUSTOM_PLUGIN_CREATION_TIME = + OffsetDateTime.parse("2021-03-04T14:03:40.818Z").toInstant(); + private static final String CUSTOM_PLUGIN_STATE_CODE = "custom-plugin-state-code"; + private static final String CUSTOM_PLUGIN_STATE_DESCRIPTION = "custom-plugin-state-description"; + private static final String CUSTOM_PLUGIN_FILE_MD5 = "abcd1234"; + private static final long CUSTOM_PLUGIN_FILE_SIZE = 123456L; + private static final String CUSTOM_PLUGIN_S3_BUCKET_ARN = "bucket-arn"; + private static final String CUSTOM_PLUGIN_S3_FILE_KEY = "file-key"; + private static final String CUSTOM_PLUGIN_S3_OBJECT_VERSION = "object-version"; + private static final String NEXT_TOKEN = "next-token"; + private static final String TAG_KEY_TO_REMOVE = "tag-key-to-remove"; + + private static final ResourceModel READ_REQUEST_RESOURCE_MODEL = + ResourceModel.builder().customPluginArn(CUSTOM_PLUGIN_ARN).build(); + + private static final DescribeCustomPluginRequest DESCRIBE_CUSTOM_PLUGIN_REQUEST = + DescribeCustomPluginRequest.builder().customPluginArn(CUSTOM_PLUGIN_ARN).build(); + + private static final DescribeCustomPluginResponse FULL_DESCRIBE_CUSTOM_PLUGIN_RESPONSE = + DescribeCustomPluginResponse.builder() + .creationTime(CUSTOM_PLUGIN_CREATION_TIME) + .customPluginArn(CUSTOM_PLUGIN_ARN) + .customPluginState(CustomPluginState.ACTIVE) + .name(CUSTOM_PLUGIN_NAME) + .description(CUSTOM_PLUGIN_DESCRIPTION) + .stateDescription( + StateDescription.builder() + .code(CUSTOM_PLUGIN_STATE_CODE) + .message(CUSTOM_PLUGIN_STATE_DESCRIPTION) + .build()) + .latestRevision( + CustomPluginRevisionSummary.builder() + .contentType(CustomPluginContentType.ZIP) + .creationTime(CUSTOM_PLUGIN_CREATION_TIME) + .description(CUSTOM_PLUGIN_DESCRIPTION) + .fileDescription( + software.amazon.awssdk.services.kafkaconnect.model.CustomPluginFileDescription.builder() + .fileMd5(CUSTOM_PLUGIN_FILE_MD5) + .fileSize(CUSTOM_PLUGIN_FILE_SIZE) + .build()) + .location( + CustomPluginLocationDescription.builder() + .s3Location( + S3LocationDescription.builder() + .bucketArn(CUSTOM_PLUGIN_S3_BUCKET_ARN) + .fileKey(CUSTOM_PLUGIN_S3_FILE_KEY) + .objectVersion(CUSTOM_PLUGIN_S3_OBJECT_VERSION) + .build()) + .build()) + .revision(CUSTOM_PLUGIN_REVISION) + .build()) + .build(); + + private static final ResourceModel FULL_RESOURCE_DESCRIBE_MODEL = + ResourceModel.builder() + .customPluginArn(CUSTOM_PLUGIN_ARN) + .description(CUSTOM_PLUGIN_DESCRIPTION) + .name(CUSTOM_PLUGIN_NAME) + .fileDescription( + CustomPluginFileDescription.builder() + .fileMd5(CUSTOM_PLUGIN_FILE_MD5) + .fileSize(CUSTOM_PLUGIN_FILE_SIZE) + .build()) + .location( + CustomPluginLocation.builder() + .s3Location( + S3Location.builder() + .bucketArn(CUSTOM_PLUGIN_S3_BUCKET_ARN) + .fileKey(CUSTOM_PLUGIN_S3_FILE_KEY) + .objectVersion(CUSTOM_PLUGIN_S3_OBJECT_VERSION) + .build()) + .build()) + .contentType(CustomPluginContentType.ZIP.toString()) + .revision(CUSTOM_PLUGIN_REVISION) + .build(); + + private static final ResourceModel buildBaseModel(final String customPluginArn) { + return ResourceModel.builder().customPluginArn(customPluginArn).build(); + } + + private static final CustomPluginSummary buildCustomPluginSummary( + final String customPluginName, final String customPluginArn) { + return CustomPluginSummary.builder() + .creationTime(CUSTOM_PLUGIN_CREATION_TIME) + .customPluginArn(customPluginArn) + .customPluginState(CustomPluginState.ACTIVE) + .description(CUSTOM_PLUGIN_DESCRIPTION) + .name(customPluginName) + .latestRevision( + CustomPluginRevisionSummary.builder() + .contentType(CustomPluginContentType.ZIP) + .creationTime(CUSTOM_PLUGIN_CREATION_TIME) + .description(CUSTOM_PLUGIN_DESCRIPTION) + .location( + CustomPluginLocationDescription.builder() + .s3Location( + S3LocationDescription.builder() + .bucketArn(CUSTOM_PLUGIN_S3_BUCKET_ARN) + .fileKey(CUSTOM_PLUGIN_S3_FILE_KEY) + .objectVersion(CUSTOM_PLUGIN_S3_OBJECT_VERSION) + .build()) + .build()) + .fileDescription( + software.amazon.awssdk.services.kafkaconnect.model.CustomPluginFileDescription + .builder() + .fileMd5(CUSTOM_PLUGIN_FILE_MD5) + .fileSize(CUSTOM_PLUGIN_FILE_SIZE) + .build()) + .revision(CUSTOM_PLUGIN_REVISION) + .build()) + .build(); + } + + private static final List LIST_CUSTOM_PLUGIN_MODELS = + asList(buildBaseModel(CUSTOM_PLUGIN_ARN), buildBaseModel(CUSTOM_PLUGIN_ARN_2)); + + private static final ListCustomPluginsRequest LIST_CUSTOM_PLUGINS_REQUEST = + ListCustomPluginsRequest.builder().nextToken(NEXT_TOKEN).build(); + + private static final ListCustomPluginsResponse LIST_CUSTOM_PLUGINS_RESPONSE = + ListCustomPluginsResponse.builder() + .customPlugins( + asList( + buildCustomPluginSummary(CUSTOM_PLUGIN_NAME, CUSTOM_PLUGIN_ARN), + buildCustomPluginSummary(CUSTOM_PLUGIN_NAME_2, CUSTOM_PLUGIN_ARN_2))) + .build(); + + private static final UntagResourceRequest UNTAG_RESOURCE_REQUEST = + UntagResourceRequest.builder() + .resourceArn(CUSTOM_PLUGIN_ARN) + .tagKeys(TAG_KEY_TO_REMOVE) + .build(); + + private static final TagResourceRequest TAG_RESOURCE_REQUEST = + TagResourceRequest.builder().resourceArn(CUSTOM_PLUGIN_ARN).tags(TAGS).build(); + + private static final ResourceModel DELETE_REQUEST_RESOURCE_MODEL = + ResourceModel.builder().customPluginArn(CUSTOM_PLUGIN_ARN).build(); + + private static final DeleteCustomPluginRequest DELETE_CUSTOM_PLUGIN_REQUEST = + DeleteCustomPluginRequest.builder().customPluginArn(CUSTOM_PLUGIN_ARN).build(); + + private static final ResourceModel CREATE_REQUEST_RESOURCE_MODEL = + ResourceModel.builder() + .name(CUSTOM_PLUGIN_NAME) + .description(CUSTOM_PLUGIN_DESCRIPTION) + .contentType(CustomPluginContentType.ZIP.toString()) + .location( + CustomPluginLocation.builder() + .s3Location( + S3Location.builder() + .bucketArn(CUSTOM_PLUGIN_S3_BUCKET_ARN) + .fileKey(CUSTOM_PLUGIN_S3_FILE_KEY) + .objectVersion(CUSTOM_PLUGIN_S3_OBJECT_VERSION) + .build()) + .build()) + .tags(TagHelper.convertToList(TAGS)) + .build(); + + private static final CreateCustomPluginRequest CREATE_CUSTOM_PLUGIN_REQUEST = + CreateCustomPluginRequest.builder() + .contentType(CustomPluginContentType.ZIP) + .description(CUSTOM_PLUGIN_DESCRIPTION) + .location( + software.amazon.awssdk.services.kafkaconnect.model.CustomPluginLocation.builder() + .s3Location( + software.amazon.awssdk.services.kafkaconnect.model.S3Location.builder() + .bucketArn(CUSTOM_PLUGIN_S3_BUCKET_ARN) + .fileKey(CUSTOM_PLUGIN_S3_FILE_KEY) + .objectVersion(CUSTOM_PLUGIN_S3_OBJECT_VERSION) + .build()) + .build()) + .name(CUSTOM_PLUGIN_NAME) + .tags(TAGS) + .build(); + } +} diff --git a/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/UpdateHandlerTest.java b/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/UpdateHandlerTest.java new file mode 100644 index 0000000..e328218 --- /dev/null +++ b/aws-kafkaconnect-customplugin/src/test/java/software/amazon/kafkaconnect/customplugin/UpdateHandlerTest.java @@ -0,0 +1,594 @@ +package software.amazon.kafkaconnect.customplugin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.CustomPluginLocationDescription; +import software.amazon.awssdk.services.kafkaconnect.model.CustomPluginRevisionSummary; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeCustomPluginResponse; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceResponse; +import software.amazon.awssdk.services.kafkaconnect.model.S3LocationDescription; +import software.amazon.awssdk.services.kafkaconnect.model.TagResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.TagResourceResponse; +import software.amazon.awssdk.services.kafkaconnect.model.UntagResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.UntagResourceResponse; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnNotUpdatableException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest.ResourceHandlerRequestBuilder; + +@ExtendWith(MockitoExtension.class) +public class UpdateHandlerTest extends AbstractTestBase { + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + KafkaConnectClient kafkaConnectClient; + + @Mock + private ExceptionTranslator exceptionTranslator; + + @Mock + private Translator translator; + + private ReadHandler readHandler; + + private UpdateHandler handler; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy( + logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis()); + proxyClient = proxyStub(proxy, kafkaConnectClient); + readHandler = new ReadHandler(exceptionTranslator, translator); + handler = new UpdateHandler(exceptionTranslator, translator, readHandler); + } + + @AfterEach + public void tear_down() { + verify(kafkaConnectClient, atLeastOnce()).serviceName(); + verifyNoMoreInteractions(kafkaConnectClient, exceptionTranslator, translator); + } + + @Test + public void test_handleRequest_updateTags_success_onAddingAndRemovingTags() { + final ResourceModel previousModel = TestData.RESOURCE_MODEL_1.toBuilder().build(); + final ResourceModel desiredModel = TestData.RESOURCE_MODEL_2.toBuilder().build(); + final DescribeCustomPluginRequest describeCustomPluginRequest = TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST_1; + final DescribeCustomPluginResponse describeCustomPluginResponse = TestData.DESCRIBE_CUSTOM_PLUGIN_RESPONSE_1 + .toBuilder().build(); + when(translator.translateToReadRequest(desiredModel)).thenReturn(describeCustomPluginRequest); + when(proxyClient.injectCredentialsAndInvokeV2( + describeCustomPluginRequest, kafkaConnectClient::describeCustomPlugin)) + .thenReturn(describeCustomPluginResponse); + when(proxyClient.client().untagResource(any(UntagResourceRequest.class))) + .thenReturn(UntagResourceResponse.builder().build()); + when(proxyClient.client().tagResource(any(TagResourceRequest.class))) + .thenReturn(TagResourceResponse.builder().build()); + when(translator.translateFromReadResponse(describeCustomPluginResponse)) + .thenReturn(desiredModel); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.LIST_TAGS_FOR_RESOURCE_REQUEST_1, kafkaConnectClient::listTagsForResource)) + .thenReturn(TestData.LIST_TAGS_FOR_RESOURCE_RESPONSE_2); + + final ResourceHandlerRequest request = + TestData.createResourceHandlerRequest(desiredModel, previousModel); + final ProgressEvent response = handler.handleRequest(proxy, request, + new CallbackContext(), proxyClient, logger); + + final ProgressEvent expected = ProgressEvent + .builder() + .resourceModel(desiredModel) + .status(OperationStatus.SUCCESS) + .build(); + + assertThat(response).isEqualTo(expected); + + verify(proxyClient.client(), times(2)) + .describeCustomPlugin(any(DescribeCustomPluginRequest.class)); + verify(proxyClient.client(), times(1)) + .listTagsForResource(any(ListTagsForResourceRequest.class)); + verify(proxyClient.client(), times(1)) + .untagResource(any(UntagResourceRequest.class)); + verify(proxyClient.client(), times(1)) + .tagResource(any(TagResourceRequest.class)); + } + + @Test + public void test_handleRequest_updateTags_success_onAddingTags() { + final ResourceModel previousModel = TestData.RESOURCE_MODEL_1.toBuilder().build(); + final ResourceModel desiredModel = TestData.RESOURCE_MODEL_3.toBuilder().build(); + final DescribeCustomPluginRequest describeCustomPluginRequest = TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST_1; + final DescribeCustomPluginResponse describeCustomPluginResponse = TestData.DESCRIBE_CUSTOM_PLUGIN_RESPONSE_1 + .toBuilder().build(); + when(translator.translateToReadRequest(desiredModel)).thenReturn(describeCustomPluginRequest); + when(proxyClient.injectCredentialsAndInvokeV2( + describeCustomPluginRequest, kafkaConnectClient::describeCustomPlugin)) + .thenReturn(describeCustomPluginResponse); + when(proxyClient.client().tagResource(any(TagResourceRequest.class))) + .thenReturn(TagResourceResponse.builder().build()); + when(translator.translateFromReadResponse(describeCustomPluginResponse)) + .thenReturn(desiredModel); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.LIST_TAGS_FOR_RESOURCE_REQUEST_1, kafkaConnectClient::listTagsForResource)) + .thenReturn(TestData.LIST_TAGS_FOR_RESOURCE_RESPONSE_3); + + final ResourceHandlerRequest request = + TestData.createResourceHandlerRequest(desiredModel, previousModel); + final ProgressEvent response = handler.handleRequest(proxy, request, + new CallbackContext(), proxyClient, logger); + + final ProgressEvent expected = ProgressEvent + .builder() + .resourceModel(desiredModel) + .status(OperationStatus.SUCCESS) + .build(); + + assertThat(response).isEqualTo(expected); + + verify(proxyClient.client(), times(2)) + .describeCustomPlugin(any(DescribeCustomPluginRequest.class)); + verify(proxyClient.client(), times(1)) + .listTagsForResource(any(ListTagsForResourceRequest.class)); + verify(proxyClient.client(), times(0)) + .untagResource(any(UntagResourceRequest.class)); + verify(proxyClient.client(), times(1)) + .tagResource(any(TagResourceRequest.class)); + } + + @Test + public void test_handleRequest_updateTags_success_onRemovingTags() { + final ResourceModel previousModel = TestData.RESOURCE_MODEL_1.toBuilder().build(); + final ResourceModel desiredModel = TestData.RESOURCE_MODEL_4.toBuilder().build(); + final DescribeCustomPluginRequest describeCustomPluginRequest = TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST_1; + final DescribeCustomPluginResponse describeCustomPluginResponse = TestData.DESCRIBE_CUSTOM_PLUGIN_RESPONSE_1 + .toBuilder().build(); + when(translator.translateToReadRequest(desiredModel)).thenReturn(describeCustomPluginRequest); + when(proxyClient.injectCredentialsAndInvokeV2( + describeCustomPluginRequest, kafkaConnectClient::describeCustomPlugin)) + .thenReturn(describeCustomPluginResponse); + when(proxyClient.client().untagResource(any(UntagResourceRequest.class))) + .thenReturn(UntagResourceResponse.builder().build()); + when(translator.translateFromReadResponse(describeCustomPluginResponse)) + .thenReturn(desiredModel); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.LIST_TAGS_FOR_RESOURCE_REQUEST_1, kafkaConnectClient::listTagsForResource)) + .thenReturn(TestData.LIST_TAGS_FOR_RESOURCE_RESPONSE_4); + + final ResourceHandlerRequest request = + TestData.createResourceHandlerRequest(desiredModel, previousModel); + final ProgressEvent response = handler.handleRequest(proxy, request, + new CallbackContext(), proxyClient, logger); + + final ProgressEvent expected = ProgressEvent + .builder() + .resourceModel(desiredModel) + .status(OperationStatus.SUCCESS) + .build(); + + assertThat(response).isEqualTo(expected); + + verify(proxyClient.client(), times(2)) + .describeCustomPlugin(any(DescribeCustomPluginRequest.class)); + verify(proxyClient.client(), times(1)) + .listTagsForResource(any(ListTagsForResourceRequest.class)); + verify(proxyClient.client(), times(1)) + .untagResource(any(UntagResourceRequest.class)); + verify(proxyClient.client(), times(0)) + .tagResource(any(TagResourceRequest.class)); + } + + @Test + public void test_handleRequest_updateTags_success_onNoChangeToTags() { + final ResourceModel previousModel = TestData.RESOURCE_MODEL_1.toBuilder().build(); + final ResourceModel desiredModel = TestData.RESOURCE_MODEL_1.toBuilder().build(); + final DescribeCustomPluginRequest describeCustomPluginRequest = TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST_1; + final DescribeCustomPluginResponse describeCustomPluginResponse = TestData.DESCRIBE_CUSTOM_PLUGIN_RESPONSE_1 + .toBuilder().build(); + when(translator.translateToReadRequest(desiredModel)).thenReturn(describeCustomPluginRequest); + when(proxyClient.injectCredentialsAndInvokeV2( + describeCustomPluginRequest, kafkaConnectClient::describeCustomPlugin)) + .thenReturn(describeCustomPluginResponse); + when(translator.translateFromReadResponse(describeCustomPluginResponse)) + .thenReturn(desiredModel); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.LIST_TAGS_FOR_RESOURCE_REQUEST_1, kafkaConnectClient::listTagsForResource)) + .thenReturn(TestData.LIST_TAGS_FOR_RESOURCE_RESPONSE_2); + + final ResourceHandlerRequest request = + TestData.createResourceHandlerRequest(desiredModel, previousModel); + final ProgressEvent response = handler.handleRequest(proxy, request, + new CallbackContext(), proxyClient, logger); + + final ProgressEvent expected = ProgressEvent + .builder() + .resourceModel(desiredModel) + .status(OperationStatus.SUCCESS) + .build(); + + assertThat(response).isEqualTo(expected); + + verify(proxyClient.client(), times(2)) + .describeCustomPlugin(any(DescribeCustomPluginRequest.class)); + verify(proxyClient.client(), times(1)) + .listTagsForResource(any(ListTagsForResourceRequest.class)); + verify(proxyClient.client(), times(0)) + .untagResource(any(UntagResourceRequest.class)); + verify(proxyClient.client(), times(0)) + .tagResource(any(TagResourceRequest.class)); + } + + @Test + public void test_handleRequest_failure_throwsException_onNameChange() { + final ResourceModel desiredModel = TestData.RESOURCE_MODEL_1.toBuilder().name(TestData.CUSTOM_PLUGIN_NAME_2) + .build(); + helper_test_handleRequest_failure_throwsException_onNonUpdatableFieldChange(desiredModel); + } + + @Test + public void test_handleRequest_failure_throwsException_onDescriptionChange() { + final ResourceModel desiredModel = TestData.RESOURCE_MODEL_1 + .toBuilder() + .description(TestData.CUSTOM_PLUGIN_DESCRIPTION_2) + .build(); + helper_test_handleRequest_failure_throwsException_onNonUpdatableFieldChange(desiredModel); + } + + @Test + public void test_handleRequest_failure_throwsException_onContentTypeChange() { + final ResourceModel desiredModel = TestData.RESOURCE_MODEL_1 + .toBuilder() + .contentType(TestData.CUSTOM_PLUGIN_CONTENT_TYPE_2) + .build(); + helper_test_handleRequest_failure_throwsException_onNonUpdatableFieldChange(desiredModel); + } + + @Test + public void test_handleRequest_failure_throwsException_onLocationChange() { + final ResourceModel desiredModel = TestData.RESOURCE_MODEL_1.toBuilder() + .location(TestData.CUSTOM_PLUGIN_LOCATION_2).build(); + helper_test_handleRequest_failure_throwsException_onNonUpdatableFieldChange(desiredModel); + } + + @Test + public void test_handleRequest_failure_throwsException_onCustomPluginArnChange() { + final ResourceModel desiredModel = TestData.RESOURCE_MODEL_1.toBuilder() + .customPluginArn(TestData.CUSTOM_PLUGIN_ARN_2).build(); + helper_test_handleRequest_failure_throwsException_onNonUpdatableFieldChange(desiredModel); + } + + @Test + public void test_handleRequest_failure_throwsException_onRevisionChange() { + final ResourceModel desiredModel = TestData.RESOURCE_MODEL_1.toBuilder() + .revision(TestData.CUSTOM_PLUGIN_REVISION_2).build(); + helper_test_handleRequest_failure_throwsException_onNonUpdatableFieldChange(desiredModel); + } + + @Test + public void test_handleRequest_failure_throwsException_onFileDescriptionChange() { + final ResourceModel desiredModel = TestData.RESOURCE_MODEL_1 + .toBuilder() + .fileDescription(TestData.CUSTOM_PLUGIN_FILE_DESCRIPTION_2) + .build(); + helper_test_handleRequest_failure_throwsException_onNonUpdatableFieldChange(desiredModel); + } + + @Test + public void test_handleRequest_failure_throwsCfnException_ifCustomPluginArnIsNull() { + final ResourceModel desiredModel = TestData.RESOURCE_MODEL_1.toBuilder().build(); + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(desiredModel).build(); + final DescribeCustomPluginRequest describeCustomPluginRequest = TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST_NULL; + when(translator.translateToReadRequest(desiredModel)).thenReturn(describeCustomPluginRequest); + + assertThrows( + CfnNotFoundException.class, + () -> { + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + }); + } + + @Test + public void test_handleRequest_failure_throwsCfnException_ifDescribeCustomPluginApiFails() { + final ResourceModel desiredModel = TestData.RESOURCE_MODEL_1.toBuilder().build(); + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(desiredModel).build(); + final DescribeCustomPluginRequest describeCustomPluginRequest = TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST_1; + when(translator.translateToReadRequest(desiredModel)).thenReturn(describeCustomPluginRequest); + final AwsServiceException exception = AwsServiceException.builder() + .message(TestData.EXCEPTION_MESSAGE) + .build(); + when(proxyClient.injectCredentialsAndInvokeV2(describeCustomPluginRequest, + kafkaConnectClient::describeCustomPlugin)).thenThrow(exception); + final CfnGeneralServiceException cfnException = new CfnGeneralServiceException(exception); + when(exceptionTranslator.translateToCfnException(exception, describeCustomPluginRequest.customPluginArn())) + .thenReturn(cfnException); + assertThrows( + CfnGeneralServiceException.class, + () -> { + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + }); + } + + @Test + public void test_handleRequest_failure_throwsCfnException_ifUntagResourceApiFails() { + final ResourceModel previousModel = TestData.RESOURCE_MODEL_1.toBuilder().build(); + final ResourceModel desiredModel = TestData.RESOURCE_MODEL_2.toBuilder().build(); + final DescribeCustomPluginRequest describeCustomPluginRequest = TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST_1; + final DescribeCustomPluginResponse describeCustomPluginResponse = TestData.DESCRIBE_CUSTOM_PLUGIN_RESPONSE_1 + .toBuilder().build(); + when(translator.translateToReadRequest(desiredModel)).thenReturn(describeCustomPluginRequest); + when(proxyClient.injectCredentialsAndInvokeV2( + describeCustomPluginRequest, kafkaConnectClient::describeCustomPlugin)) + .thenReturn(describeCustomPluginResponse); + final AwsServiceException exception = AwsServiceException.builder() + .message(TestData.EXCEPTION_MESSAGE) + .build(); + when(proxyClient.client().untagResource(any(UntagResourceRequest.class))) + .thenThrow(exception); + final CfnGeneralServiceException cfnException = new CfnGeneralServiceException(exception); + when(exceptionTranslator.translateToCfnException(exception, describeCustomPluginRequest.customPluginArn())) + .thenReturn(cfnException); + + final ResourceHandlerRequest request = + TestData.createResourceHandlerRequest(desiredModel, previousModel); + assertThrows( + CfnGeneralServiceException.class, + () -> { + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + }); + } + + @Test + public void test_handleRequest_failure_throwsCfnException_ifTagResourceApiFails() { + final ResourceModel previousModel = TestData.RESOURCE_MODEL_1.toBuilder().build(); + final ResourceModel desiredModel = TestData.RESOURCE_MODEL_2.toBuilder().build(); + final DescribeCustomPluginRequest describeCustomPluginRequest = TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST_1; + final DescribeCustomPluginResponse describeCustomPluginResponse = TestData.DESCRIBE_CUSTOM_PLUGIN_RESPONSE_1 + .toBuilder().build(); + when(translator.translateToReadRequest(desiredModel)).thenReturn(describeCustomPluginRequest); + when(proxyClient.injectCredentialsAndInvokeV2( + describeCustomPluginRequest, kafkaConnectClient::describeCustomPlugin)) + .thenReturn(describeCustomPluginResponse); + final AwsServiceException exception = AwsServiceException.builder() + .message(TestData.EXCEPTION_MESSAGE) + .build(); + when(proxyClient.client().untagResource(any(UntagResourceRequest.class))) + .thenReturn(UntagResourceResponse.builder().build()); + when(proxyClient.client().tagResource(any(TagResourceRequest.class))) + .thenThrow(exception); + final CfnGeneralServiceException cfnException = new CfnGeneralServiceException(exception); + when(exceptionTranslator.translateToCfnException(exception, describeCustomPluginRequest.customPluginArn())) + .thenReturn(cfnException); + + final ResourceHandlerRequest request = + TestData.createResourceHandlerRequest(desiredModel, previousModel); + assertThrows( + CfnGeneralServiceException.class, + () -> { + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + }); + } + + // This is a helper method, please don't add @Test annotation. + private void helper_test_handleRequest_failure_throwsException_onNonUpdatableFieldChange( + ResourceModel desiredModel) { + final ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(desiredModel) + .desiredResourceTags(TagHelper.convertToMap(desiredModel.getTags())) + .previousResourceState(TestData.RESOURCE_MODEL_1.toBuilder().build()) + .build(); + final DescribeCustomPluginRequest describeCustomPluginRequest = TestData.DESCRIBE_CUSTOM_PLUGIN_REQUEST_1; + final DescribeCustomPluginResponse describeCustomPluginResponse = TestData.DESCRIBE_CUSTOM_PLUGIN_RESPONSE_1 + .toBuilder().build(); + when(translator.translateToReadRequest(desiredModel)).thenReturn(describeCustomPluginRequest); + when(proxyClient.injectCredentialsAndInvokeV2( + describeCustomPluginRequest, kafkaConnectClient::describeCustomPlugin)) + .thenReturn(describeCustomPluginResponse); + + assertThrows( + CfnNotUpdatableException.class, + () -> { + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + }); + } + + private static class TestData { + private static final String CUSTOM_PLUGIN_ARN_1 = + "arn:aws:kafkaconnect:us-east-1:123456789:custom-plugin/unit-test-custom-plugin-1"; + private static final String CUSTOM_PLUGIN_NAME_1 = "unit-test-custom-plugin-1"; + private static final String CUSTOM_PLUGIN_DESCRIPTION_1 = "unit-test-custom-plugin-description-1"; + private static final String CUSTOM_PLUGIN_CONTENT_TYPE_1 = "unit-test-custom-plugin-content-type-1"; + private static final String CUSTOM_PLUGIN_BUCKET_ARN_1 = "unit-test-custom-plugin-bucket-arn-1"; + private static final String CUSTOM_PLUGIN_FILE_KEY_1 = "unit-test-custom-plugin-file-key-1"; + private static final String CUSTOM_PLUGIN_FILE_MD5_1 = "unit-test-custom-plugin-file-md5-1"; + private static final Long CUSTOM_PLUGIN_FILE_SIZE_1 = 123L; + private static final Long CUSTOM_PLUGIN_REVISION_1 = 1L; + + private static final String CUSTOM_PLUGIN_ARN_2 = + "arn:aws:kafkaconnect:us-east-1:123456789:custom-plugin/unit-test-custom-plugin-2"; + private static final String CUSTOM_PLUGIN_NAME_2 = "unit-test-custom-plugin-2"; + private static final String CUSTOM_PLUGIN_DESCRIPTION_2 = "unit-test-custom-plugin-description-2"; + private static final String CUSTOM_PLUGIN_CONTENT_TYPE_2 = "unit-test-custom-plugin-content-type-2"; + private static final String CUSTOM_PLUGIN_BUCKET_ARN_2 = "unit-test-custom-plugin-bucket-arn-2"; + private static final String CUSTOM_PLUGIN_FILE_KEY_2 = "unit-test-custom-plugin-file-key-2"; + private static final String CUSTOM_PLUGIN_FILE_MD5_2 = "unit-test-custom-plugin-file-md5-2"; + private static final Long CUSTOM_PLUGIN_FILE_SIZE_2 = 456L; + private static final Long CUSTOM_PLUGIN_REVISION_2 = 2L; + private static final String EXCEPTION_MESSAGE = "exception-message"; + + private static final CustomPluginLocation CUSTOM_PLUGIN_LOCATION_1 = CustomPluginLocation.builder() + .s3Location( + S3Location.builder() + .bucketArn(CUSTOM_PLUGIN_BUCKET_ARN_1) + .fileKey(CUSTOM_PLUGIN_FILE_KEY_1) + .build()) + .build(); + private static final CustomPluginLocation CUSTOM_PLUGIN_LOCATION_2 = CustomPluginLocation.builder() + .s3Location( + S3Location.builder() + .bucketArn(CUSTOM_PLUGIN_BUCKET_ARN_2) + .fileKey(CUSTOM_PLUGIN_FILE_KEY_2) + .build()) + .build(); + + private static final CustomPluginFileDescription CUSTOM_PLUGIN_FILE_DESCRIPTION_1 = CustomPluginFileDescription + .builder() + .fileMd5(CUSTOM_PLUGIN_FILE_MD5_1) + .fileSize(CUSTOM_PLUGIN_FILE_SIZE_1) + .build(); + private static final CustomPluginFileDescription CUSTOM_PLUGIN_FILE_DESCRIPTION_2 = CustomPluginFileDescription + .builder() + .fileMd5(CUSTOM_PLUGIN_FILE_MD5_2) + .fileSize(CUSTOM_PLUGIN_FILE_SIZE_2) + .build(); + + private static final Map SYSTEM_TAGS = new HashMap() { + { + put("SYSTEM_TAG_TEST1", "SYSTEM_TAG_TEST_VALUE1"); + put("SYSTEM_TAG_TEST2", "SYSTEM_TAG_TEST_VALUE2"); + } + }; + + private static final Map TAGS_1 = new HashMap() { + { + put("TEST_TAG1", "TEST_TAG_VALUE1"); + put("TEST_TAG2", "TEST_TAG_VALUE2"); + } + }; + + private static final Map TAGS_2 = new HashMap() { + { + put("TEST_TAG1", "TEST_TAG_VALUE1"); + put("TEST_TAG3", "TEST_TAG_VALUE3"); + } + }; + + private static final Map TAGS_3 = new HashMap() { + { + put("TEST_TAG1", "TEST_TAG_VALUE1"); + put("TEST_TAG2", "TEST_TAG_VALUE2"); + put("TEST_TAG3", "TEST_TAG_VALUE3"); + } + }; + + private static final Map TAGS_4 = new HashMap() { + { + put("TEST_TAG1", "TEST_TAG_VALUE1"); + } + }; + + private static final ResourceModel RESOURCE_MODEL_1 = ResourceModel.builder() + .customPluginArn(CUSTOM_PLUGIN_ARN_1) + .name(CUSTOM_PLUGIN_NAME_1) + .description(CUSTOM_PLUGIN_DESCRIPTION_1) + .contentType(CUSTOM_PLUGIN_CONTENT_TYPE_1) + .location(CUSTOM_PLUGIN_LOCATION_1) + .revision(CUSTOM_PLUGIN_REVISION_1) + .fileDescription(CUSTOM_PLUGIN_FILE_DESCRIPTION_1) + .tags(TagHelper.convertToList(TAGS_1)) + .build(); + + private static final ResourceModel RESOURCE_MODEL_2 = RESOURCE_MODEL_1.toBuilder() + .tags(TagHelper.convertToList(TAGS_2)).build(); + + private static final ResourceModel RESOURCE_MODEL_3 = RESOURCE_MODEL_1.toBuilder() + .tags(TagHelper.convertToList(TAGS_3)).build(); + + private static final ResourceModel RESOURCE_MODEL_4 = RESOURCE_MODEL_1.toBuilder() + .tags(TagHelper.convertToList(TAGS_4)).build(); + + private static final DescribeCustomPluginRequest DESCRIBE_CUSTOM_PLUGIN_REQUEST_1 = DescribeCustomPluginRequest + .builder().customPluginArn(CUSTOM_PLUGIN_ARN_1).build(); + private static final DescribeCustomPluginResponse DESCRIBE_CUSTOM_PLUGIN_RESPONSE_1 = + DescribeCustomPluginResponse + .builder() + .customPluginArn(CUSTOM_PLUGIN_ARN_1) + .name(CUSTOM_PLUGIN_NAME_1) + .description(CUSTOM_PLUGIN_DESCRIPTION_1) + .latestRevision( + CustomPluginRevisionSummary.builder() + .revision(CUSTOM_PLUGIN_REVISION_1) + .contentType(CUSTOM_PLUGIN_CONTENT_TYPE_1) + .fileDescription( + software.amazon.awssdk.services.kafkaconnect.model.CustomPluginFileDescription + .builder() + .fileMd5(CUSTOM_PLUGIN_FILE_MD5_1) + .fileSize(CUSTOM_PLUGIN_FILE_SIZE_1) + .build()) + .location( + CustomPluginLocationDescription.builder() + .s3Location( + S3LocationDescription.builder() + .bucketArn(CUSTOM_PLUGIN_BUCKET_ARN_1) + .fileKey(CUSTOM_PLUGIN_FILE_KEY_1) + .build()) + .build()) + .build()) + .build(); + + private static final DescribeCustomPluginRequest DESCRIBE_CUSTOM_PLUGIN_REQUEST_NULL = + DescribeCustomPluginRequest + .builder().customPluginArn(null).build(); + + private static final ListTagsForResourceRequest LIST_TAGS_FOR_RESOURCE_REQUEST_1 = ListTagsForResourceRequest + .builder().resourceArn(CUSTOM_PLUGIN_ARN_1).build(); + + private static final ListTagsForResourceResponse LIST_TAGS_FOR_RESOURCE_RESPONSE_2 = ListTagsForResourceResponse + .builder().tags(TAGS_2).build(); + private static final ListTagsForResourceResponse LIST_TAGS_FOR_RESOURCE_RESPONSE_3 = ListTagsForResourceResponse + .builder().tags(TAGS_3).build(); + private static final ListTagsForResourceResponse LIST_TAGS_FOR_RESOURCE_RESPONSE_4 = ListTagsForResourceResponse + .builder().tags(TAGS_4).build(); + + private static ResourceHandlerRequest createResourceHandlerRequest( + @Nonnull ResourceModel desiredModel, + @Nullable ResourceModel previousModel) { + final ResourceHandlerRequestBuilder requestBuilder = + ResourceHandlerRequest.builder() + .desiredResourceState(desiredModel) + .desiredResourceTags(TagHelper.convertToMap(desiredModel.getTags())) + .systemTags(TestData.SYSTEM_TAGS); + + if (previousModel != null) { + requestBuilder.previousResourceState(previousModel) + .previousResourceTags(TagHelper.convertToMap(previousModel.getTags())) + .previousSystemTags(TestData.SYSTEM_TAGS); + } + + return requestBuilder.build(); + } + } +} diff --git a/aws-kafkaconnect-customplugin/template.yml b/aws-kafkaconnect-customplugin/template.yml new file mode 100644 index 0000000..f0a0600 --- /dev/null +++ b/aws-kafkaconnect-customplugin/template.yml @@ -0,0 +1,24 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: AWS SAM template for the AWS::KafkaConnect::CustomPlugin resource type + +Globals: + Function: + Timeout: 180 # docker start-up times can be long for SAM CLI + MemorySize: 256 + +Resources: + TypeFunction: + Type: AWS::Serverless::Function + Properties: + Handler: software.amazon.kafkaconnect.customplugin.HandlerWrapper::handleRequest + Runtime: java17 + CodeUri: ./target/aws-kafkaconnect-customplugin-1.0.jar + + TestEntrypoint: + Type: AWS::Serverless::Function + Properties: + Handler: software.amazon.kafkaconnect.customplugin.HandlerWrapper::testEntrypoint + Runtime: java17 + CodeUri: ./target/aws-kafkaconnect-customplugin-1.0.jar + diff --git a/aws-kafkaconnect-workerconfiguration/.gitignore b/aws-kafkaconnect-workerconfiguration/.gitignore new file mode 100644 index 0000000..4aba59d --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/.gitignore @@ -0,0 +1,24 @@ +# macOS +.DS_Store +._* + +# Maven outputs +.classpath + +# IntelliJ +*.iml +.idea +out.java +out/ +.settings +.project + +# auto-generated files +target/ +build/ + +# our logs +rpdk.log* + +# contains credentials +sam-tests/ diff --git a/aws-kafkaconnect-workerconfiguration/.rpdk-config b/aws-kafkaconnect-workerconfiguration/.rpdk-config new file mode 100644 index 0000000..f7bb0c9 --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/.rpdk-config @@ -0,0 +1,29 @@ +{ + "artifact_type": "RESOURCE", + "typeName": "AWS::KafkaConnect::WorkerConfiguration", + "language": "java", + "runtime": "java17", + "entrypoint": "software.amazon.kafkaconnect.workerconfiguration.HandlerWrapper::handleRequest", + "testEntrypoint": "software.amazon.kafkaconnect.workerconfiguration.HandlerWrapper::testEntrypoint", + "settings": { + "version": false, + "subparser_name": null, + "verbose": 0, + "force": false, + "type_name": null, + "artifact_type": null, + "endpoint_url": null, + "region": null, + "target_schemas": [], + "namespace": [ + "software", + "amazon", + "kafkaconnect", + "workerconfiguration" + ], + "codegen_template_path": "guided_aws", + "protocolVersion": "2.0.0" + }, + "logProcessorEnabled": "true", + "executableEntrypoint": "software.amazon.kafkaconnect.workerconfiguration.HandlerWrapperExecutable" +} diff --git a/aws-kafkaconnect-workerconfiguration/README.md b/aws-kafkaconnect-workerconfiguration/README.md new file mode 100644 index 0000000..9e75435 --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/README.md @@ -0,0 +1,12 @@ +# AWS::KafkaConnect::WorkerConfiguration + +Congratulations on starting development! Next steps: + +1. Write the JSON schema describing your resource, `aws-kafkaconnect-workerconfiguration.json` +1. Implement your resource handlers. + +The RPDK will automatically generate the correct resource model from the schema whenever the project is built via Maven. You can also do this manually with the following command: `cfn generate`. + +> Please don't modify files under `target/generated-sources/rpdk`, as they will be automatically overwritten. + +The code uses [Lombok](https://projectlombok.org/), and [you may have to install IDE integrations](https://projectlombok.org/setup/overview) to enable auto-complete for Lombok-annotated classes. diff --git a/aws-kafkaconnect-workerconfiguration/aws-kafkaconnect-workerconfiguration.json b/aws-kafkaconnect-workerconfiguration/aws-kafkaconnect-workerconfiguration.json new file mode 100644 index 0000000..1fb08fa --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/aws-kafkaconnect-workerconfiguration.json @@ -0,0 +1,126 @@ +{ + "typeName": "AWS::KafkaConnect::WorkerConfiguration", + "description": "The configuration of the workers, which are the processes that run the connector logic.", + "additionalProperties": false, + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-kafkaconnect.git", + "properties": { + "Name": { + "description": "The name of the worker configuration.", + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "Description": { + "description": "A summary description of the worker configuration.", + "type": "string", + "maxLength": 1024 + }, + "WorkerConfigurationArn": { + "description": "The Amazon Resource Name (ARN) of the custom configuration.", + "type": "string", + "pattern": "arn:(aws|aws-us-gov|aws-cn):kafkaconnect:.*" + }, + "PropertiesFileContent": { + "description": "Base64 encoded contents of connect-distributed.properties file.", + "type": "string" + }, + "Revision": { + "description": "The description of a revision of the worker configuration.", + "type": "integer", + "format": "int64" + }, + "Tags": { + "description": "A collection of tags associated with a resource", + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + } + }, + "definitions": { + "Tag": { + "type": "object", + "properties": { + "Key": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "type": "string", + "maxLength": 256 + } + }, + "required": [ + "Value", + "Key" + ], + "additionalProperties": false + } + }, + "required": [ + "Name", + "PropertiesFileContent" + ], + "primaryIdentifier": [ + "/properties/WorkerConfigurationArn" + ], + "additionalIdentifiers": [ + [ + "/properties/Name" + ] + ], + "readOnlyProperties": [ + "/properties/WorkerConfigurationArn", + "/properties/Revision" + ], + "createOnlyProperties": [ + "/properties/Name", + "/properties/Description", + "/properties/PropertiesFileContent" + ], + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, + "handlers": { + "create": { + "permissions": [ + "kafkaconnect:DescribeWorkerConfiguration", + "kafkaconnect:CreateWorkerConfiguration", + "kafkaconnect:TagResource", + "kafkaconnect:ListTagsForResource" + ] + }, + "read": { + "permissions": [ + "kafkaconnect:DescribeWorkerConfiguration", + "kafkaconnect:ListTagsForResource" + ] + }, + "update": { + "permissions": [ + "kafkaconnect:DescribeWorkerConfiguration", + "kafkaconnect:ListTagsForResource", + "kafkaconnect:TagResource", + "kafkaconnect:UntagResource" + ] + }, + "delete": { + "permissions": [ + "kafkaconnect:DescribeWorkerConfiguration", + "kafkaconnect:DeleteWorkerConfiguration" + ] + }, + "list": { + "permissions": [ + "kafkaconnect:ListWorkerConfigurations" + ] + } + } +} diff --git a/aws-kafkaconnect-workerconfiguration/checkstyle.xml b/aws-kafkaconnect-workerconfiguration/checkstyle.xml new file mode 100644 index 0000000..631567a --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/checkstyle.xml @@ -0,0 +1,360 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/aws-kafkaconnect-workerconfiguration/docs/README.md b/aws-kafkaconnect-workerconfiguration/docs/README.md new file mode 100644 index 0000000..b0819c2 --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/docs/README.md @@ -0,0 +1,102 @@ +# AWS::KafkaConnect::WorkerConfiguration + +The configuration of the workers, which are the processes that run the connector logic. + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +

+{
+    "Type" : "AWS::KafkaConnect::WorkerConfiguration",
+    "Properties" : {
+        "Name" : String,
+        "Description" : String,
+        "PropertiesFileContent" : String,
+        "Tags" : [ Tag, ... ]
+    }
+}
+
+ +### YAML + +
+Type: AWS::KafkaConnect::WorkerConfiguration
+Properties:
+    Name: String
+    Description: String
+    PropertiesFileContent: String
+    Tags: 
+      - Tag
+
+ +## Properties + +#### Name + +The name of the worker configuration. + +_Required_: Yes + +_Type_: String + +_Minimum Length_: 1 + +_Maximum Length_: 128 + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### Description + +A summary description of the worker configuration. + +_Required_: No + +_Type_: String + +_Maximum Length_: 1024 + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### PropertiesFileContent + +Base64 encoded contents of connect-distributed.properties file. + +_Required_: Yes + +_Type_: String + +_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement) + +#### Tags + +A collection of tags associated with a resource + +_Required_: No + +_Type_: List of Tag + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +## Return Values + +### Ref + +When you pass the logical ID of this resource to the intrinsic `Ref` function, Ref returns the WorkerConfigurationArn. + +### Fn::GetAtt + +The `Fn::GetAtt` intrinsic function returns a value for a specified attribute of this type. The following are the available attributes and sample return values. + +For more information about using the `Fn::GetAtt` intrinsic function, see [Fn::GetAtt](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html). + +#### WorkerConfigurationArn + +The Amazon Resource Name (ARN) of the custom configuration. + +#### Revision + +The description of a revision of the worker configuration. + diff --git a/aws-kafkaconnect-workerconfiguration/docs/tag.md b/aws-kafkaconnect-workerconfiguration/docs/tag.md new file mode 100644 index 0000000..c252065 --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/docs/tag.md @@ -0,0 +1,46 @@ +# AWS::KafkaConnect::WorkerConfiguration Tag + +## Syntax + +To declare this entity in your AWS CloudFormation template, use the following syntax: + +### JSON + +
+{
+    "Key" : String,
+    "Value" : String
+}
+
+ +### YAML + +
+Key: String
+Value: String
+
+ +## Properties + +#### Key + +_Required_: Yes + +_Type_: String + +_Minimum Length_: 1 + +_Maximum Length_: 128 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + +#### Value + +_Required_: Yes + +_Type_: String + +_Maximum Length_: 256 + +_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt) + diff --git a/aws-kafkaconnect-workerconfiguration/lombok.config b/aws-kafkaconnect-workerconfiguration/lombok.config new file mode 100644 index 0000000..7a21e88 --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true diff --git a/aws-kafkaconnect-workerconfiguration/pom.xml b/aws-kafkaconnect-workerconfiguration/pom.xml new file mode 100644 index 0000000..ef2031e --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/pom.xml @@ -0,0 +1,259 @@ + + + 4.0.0 + + software.amazon.kafkaconnect.workerconfiguration + aws-kafkaconnect-workerconfiguration-handler + aws-kafkaconnect-workerconfiguration-handler + 1.0-SNAPSHOT + jar + + + 17 + ${java.version} + ${java.version} + UTF-8 + UTF-8 + + + + + + + + + software.amazon.awssdk + bom + 2.25.17 + pom + import + + + + + + + + software.amazon.cloudformation + aws-cloudformation-rpdk-java-plugin + [2.0.0,3.0.0) + + + + org.projectlombok + lombok + 1.18.32 + provided + + + + org.apache.logging.log4j + log4j-api + 2.17.1 + + + + org.apache.logging.log4j + log4j-core + 2.17.1 + + + + org.apache.logging.log4j + log4j-slf4j-impl + 2.13.3 + + + + + software.amazon.awssdk + kafkaconnect + + + + + org.assertj + assertj-core + 3.12.2 + test + + + + org.junit.jupiter + junit-jupiter + 5.5.0-M1 + test + + + + org.mockito + mockito-core + 3.6.0 + test + + + + org.mockito + mockito-junit-jupiter + 3.6.0 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.12.1 + + + -Xlint:all,-options,-processing + -Werror + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.3 + + false + + + *:* + + **/Log4j2Plugins.dat + + + + + + + package + + shade + + + + + + org.codehaus.mojo + exec-maven-plugin + 1.6.0 + + + generate + generate-sources + + exec + + + cfn + generate ${cfn.generate.args} + ${project.basedir} + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.0.0 + + + add-source + generate-sources + + add-source + + + + ${project.basedir}/target/generated-sources/rpdk + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 2.4 + + + maven-surefire-plugin + 3.0.0-M3 + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + **/BaseConfiguration* + **/BaseHandler* + **/HandlerWrapper* + **/ResourceModel* + + + + + + prepare-agent + + + + report + test + + report + + + + jacoco-check + + check + + + + + PACKAGE + + + BRANCH + COVEREDRATIO + 0.8 + + + INSTRUCTION + COVEREDRATIO + 0.8 + + + + + + + + + + + + ${project.basedir} + + aws-kafkaconnect-workerconfiguration.json + + + + ${project.basedir}/target/loaded-target-schemas + + **/*.json + + + + + \ No newline at end of file diff --git a/aws-kafkaconnect-workerconfiguration/resource-role.yaml b/aws-kafkaconnect-workerconfiguration/resource-role.yaml new file mode 100644 index 0000000..550af49 --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/resource-role.yaml @@ -0,0 +1,44 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: > + This CloudFormation template creates a role assumed by CloudFormation + during CRUDL operations to mutate resources on behalf of the customer. + +Resources: + ExecutionRole: + Type: AWS::IAM::Role + Properties: + MaxSessionDuration: 8400 + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: resources.cloudformation.amazonaws.com + Action: sts:AssumeRole + Condition: + StringEquals: + aws:SourceAccount: + Ref: AWS::AccountId + StringLike: + aws:SourceArn: + Fn::Sub: arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:type/resource/AWS-KafkaConnect-WorkerConfiguration/* + Path: "/" + Policies: + - PolicyName: ResourceTypePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - "kafkaconnect:CreateWorkerConfiguration" + - "kafkaconnect:DeleteWorkerConfiguration" + - "kafkaconnect:DescribeWorkerConfiguration" + - "kafkaconnect:ListTagsForResource" + - "kafkaconnect:ListWorkerConfigurations" + - "kafkaconnect:TagResource" + - "kafkaconnect:UntagResource" + Resource: "*" +Outputs: + ExecutionRoleArn: + Value: + Fn::GetAtt: ExecutionRole.Arn diff --git a/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/BaseHandlerStd.java b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/BaseHandlerStd.java new file mode 100644 index 0000000..cf811d7 --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/BaseHandlerStd.java @@ -0,0 +1,80 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationResponse; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationRequest; +import software.amazon.awssdk.services.kafkaconnect.model.NotFoundException; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +// Placeholder for the functionality that could be shared across Create/Read/Update/Delete/List Handlers + +public abstract class BaseHandlerStd extends BaseHandler { + @Override + public final ProgressEvent handleRequest( + + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final Logger logger) { + + return handleRequest( + proxy, + request, + callbackContext != null ? callbackContext : new CallbackContext(), + proxy.newProxy(() -> ClientBuilder.getClient(request.getAwsPartition(), request.getRegion())), + logger); + } + + protected abstract ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger); + + protected DescribeWorkerConfigurationResponse runDescribeWorkerConfiguration( + final DescribeWorkerConfigurationRequest describeWorkerConfigurationRequest, + final ProxyClient proxyClient, + final String failureMessagePattern) { + + final KafkaConnectClient kafkaConnectClient = proxyClient.client(); + + try { + return proxyClient + .injectCredentialsAndInvokeV2(describeWorkerConfigurationRequest, + kafkaConnectClient::describeWorkerConfiguration); + } catch (final AwsServiceException e) { + throw new CfnGeneralServiceException( + String.format( + failureMessagePattern, ResourceModel.TYPE_NAME, e.getMessage()), + e); + } + } + + protected DescribeWorkerConfigurationResponse runDescribeWorkerConfigurationWithNotFoundCatch( + final DescribeWorkerConfigurationRequest describeWorkerConfigurationRequest, + final ProxyClient proxyClient, + final String failureMessagePattern, + final ExceptionTranslator exceptionTranslator) { + + final KafkaConnectClient kafkaConnectClient = proxyClient.client(); + + try { + return proxyClient + .injectCredentialsAndInvokeV2(describeWorkerConfigurationRequest, + kafkaConnectClient::describeWorkerConfiguration); + } catch (final NotFoundException e) { + throw exceptionTranslator.translateToCfnException(e, + describeWorkerConfigurationRequest.workerConfigurationArn()); + } catch (final AwsServiceException e) { + throw new CfnGeneralServiceException( + String.format(failureMessagePattern, ResourceModel.TYPE_NAME, e.getMessage()), e); + } + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/CallbackContext.java b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/CallbackContext.java new file mode 100644 index 0000000..ffb9a9e --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/CallbackContext.java @@ -0,0 +1,10 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import software.amazon.cloudformation.proxy.StdCallbackContext; + +@lombok.Getter +@lombok.Setter +@lombok.ToString +@lombok.EqualsAndHashCode(callSuper = true) +public class CallbackContext extends StdCallbackContext { +} diff --git a/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/ClientBuilder.java b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/ClientBuilder.java new file mode 100644 index 0000000..e636ca9 --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/ClientBuilder.java @@ -0,0 +1,50 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.core.retry.backoff.BackoffStrategy; +import software.amazon.awssdk.core.retry.backoff.EqualJitterBackoffStrategy; +import software.amazon.awssdk.core.retry.conditions.RetryCondition; +import software.amazon.awssdk.core.retry.RetryPolicy; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.cloudformation.LambdaWrapper; + +import java.net.URI; +import java.time.Duration; + +public class ClientBuilder { + private static final String CN_PARTITION = "aws-cn"; + private static final String CN_SUFFIX = ".cn"; + private static final String SERVICE_ENDPOINT_TEMPLATE = "https://kafkaconnect.%s.amazonaws.com"; + + private static final BackoffStrategy BACKOFF_THROTTLING_STRATEGY = EqualJitterBackoffStrategy.builder() + .baseDelay(Duration.ofMillis(1200)) + .maxBackoffTime(Duration.ofSeconds(45)) + .build(); + + private static final RetryPolicy RETRY_POLICY = RetryPolicy.builder() + .numRetries(10) + .retryCondition(RetryCondition.defaultRetryCondition()) + .backoffStrategy(BACKOFF_THROTTLING_STRATEGY) + .throttlingBackoffStrategy(BACKOFF_THROTTLING_STRATEGY) + .build(); + + private ClientBuilder() { + } + + public static KafkaConnectClient getClient(final String awsPartition, final String awsRegion) { + return KafkaConnectClient + .builder() + .httpClient(LambdaWrapper.HTTP_CLIENT) + .endpointOverride(getServiceEndpoint(awsPartition, awsRegion)) + .overrideConfiguration(ClientOverrideConfiguration.builder() + .retryPolicy(RETRY_POLICY) + .build()) + .build(); + } + + private static URI getServiceEndpoint(final String partition, final String region) { + final String serviceEndpoint = String.format(SERVICE_ENDPOINT_TEMPLATE, region); + return URI.create( + partition.equals(CN_PARTITION) ? serviceEndpoint + CN_SUFFIX : serviceEndpoint); + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/Configuration.java b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/Configuration.java new file mode 100644 index 0000000..a973eb0 --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/Configuration.java @@ -0,0 +1,22 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import java.util.Map; + +class Configuration extends BaseConfiguration { + + public Configuration() { + super("aws-kafkaconnect-workerconfiguration.json"); + } + + /** + * Providers should implement this method if their resource has a 'Tags' property to define resource-level tags + * @return + */ + public Map resourceDefinedTags(final ResourceModel resourceModel) { + if (resourceModel.getTags() == null) { + return null; + } else { + return TagHelper.convertToMap(resourceModel.getTags()); + } + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/CreateHandler.java b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/CreateHandler.java new file mode 100644 index 0000000..9ebd6ea --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/CreateHandler.java @@ -0,0 +1,104 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; + +import software.amazon.awssdk.services.kafkaconnect.model.CreateWorkerConfigurationRequest; +import software.amazon.awssdk.services.kafkaconnect.model.CreateWorkerConfigurationResponse; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; + +public class CreateHandler extends BaseHandlerStd { + private Logger logger; + private final ExceptionTranslator exceptionTranslator; + private final Translator translator; + private final ReadHandler readHandler; + + public CreateHandler() { + this(new ExceptionTranslator(), new Translator(), new ReadHandler()); + } + + /** + * Constructor used for unit testing + * + * @param exceptionTranslator + * @param translator + * @param readHandler + */ + CreateHandler( + final ExceptionTranslator exceptionTranslator, + final Translator translator, + final ReadHandler readHandler) { + + this.exceptionTranslator = exceptionTranslator; + this.translator = translator; + this.readHandler = readHandler; + } + + @Override + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + + final ResourceModel model = request.getDesiredResourceState(); + + return ProgressEvent.progress(model, callbackContext) + .then(progress -> initiateCreateWorkerConfiguration(proxy, proxyClient, progress, + "AWS-KafkaConnect-WorkerConfiguration::Create", request)) + .then(progress -> readHandler.handleRequest(proxy, request, callbackContext, proxyClient, logger)); + } + + private ProgressEvent initiateCreateWorkerConfiguration( + final AmazonWebServicesClientProxy proxy, + final ProxyClient proxyClient, + final ProgressEvent progress, + final String callGraph, + final ResourceHandlerRequest request) { + + return proxy + .initiate(callGraph, proxyClient, progress.getResourceModel(), progress.getCallbackContext()) + .translateToServiceRequest(_resourceModel -> translator.translateToCreateRequest(_resourceModel, + TagHelper.generateTagsForCreate(request))) + .makeServiceCall(this::runCreateWorkerConfiguration) + .done(this::setWorkerConfigurationArn); + } + + private ProgressEvent setWorkerConfigurationArn( + final CreateWorkerConfigurationRequest createWorkerConfigurationRequest, + final CreateWorkerConfigurationResponse createWorkerConfigurationResponse, + final ProxyClient kafkaConnectClientProxyClient, + final ResourceModel resourceModel, + final CallbackContext callbackContext) { + + resourceModel.setWorkerConfigurationArn(createWorkerConfigurationResponse.workerConfigurationArn()); + return ProgressEvent.progress(resourceModel, callbackContext); + } + + private CreateWorkerConfigurationResponse runCreateWorkerConfiguration( + final CreateWorkerConfigurationRequest createWorkerConfigurationRequest, + final ProxyClient proxyClient) { + + final KafkaConnectClient kafkaConnectClient = proxyClient.client(); + final String identifier = createWorkerConfigurationRequest.name(); + CreateWorkerConfigurationResponse createWorkerConfigurationResponse; + + try { + createWorkerConfigurationResponse = proxyClient.injectCredentialsAndInvokeV2( + createWorkerConfigurationRequest, + kafkaConnectClient::createWorkerConfiguration); + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + + logger.log(String.format("%s [%s] created successfully.", ResourceModel.TYPE_NAME, identifier)); + return createWorkerConfigurationResponse; + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/DeleteHandler.java b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/DeleteHandler.java new file mode 100644 index 0000000..a3561b9 --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/DeleteHandler.java @@ -0,0 +1,146 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.model.DeleteWorkerConfigurationRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DeleteWorkerConfigurationResponse; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationRequest; +import software.amazon.awssdk.services.kafkaconnect.model.WorkerConfigurationState; +import software.amazon.awssdk.services.kafkaconnect.model.NotFoundException; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationResponse; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnNotStabilizedException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; + +public class DeleteHandler extends BaseHandlerStd { + private Logger logger; + private final Translator translator; + + private final ExceptionTranslator exceptionTranslator; + + public DeleteHandler() { + this(new ExceptionTranslator(), new Translator()); + } + + /** + * Constructor used for unit testing + * + * @param translator + */ + DeleteHandler(final ExceptionTranslator exceptionTranslator, final Translator translator) { + + this.translator = translator; + this.exceptionTranslator = exceptionTranslator; + } + + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + final ResourceModel model = request.getDesiredResourceState(); + + return ProgressEvent.progress(model, callbackContext) + .then(progress -> proxy + .initiate("AWS-KafkaConnect-WorkerConfiguration::ValidateResourceExists", proxyClient, model, + callbackContext) + .translateToServiceRequest(translator::translateToReadRequest) + .makeServiceCall(this::validateResourceExists) + .progress()) + .then(progress -> proxy + .initiate("AWS-KafkaConnect-WorkerConfiguration::Delete", proxyClient, model, callbackContext) + .translateToServiceRequest(translator::translateToDeleteRequest) + .makeServiceCall(this::deleteWorkerConfiguration) + .stabilize( + (awsRequest, awsResponse, client, awsModel, context) -> isStabilized(awsRequest, client, awsModel)) + .done( + (awsRequest, awsResponse, client, awsModel, context) -> ProgressEvent.defaultSuccessHandler(null))); + } + + private DescribeWorkerConfigurationResponse validateResourceExists( + DescribeWorkerConfigurationRequest describeWorkerConfigurationRequest, + ProxyClient proxyClient) { + DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse; + if (describeWorkerConfigurationRequest.workerConfigurationArn() == null) { + throw new CfnNotFoundException(ResourceModel.TYPE_NAME, null); + } + final String identifier = describeWorkerConfigurationRequest.workerConfigurationArn(); + + try { + describeWorkerConfigurationResponse = proxyClient.injectCredentialsAndInvokeV2( + describeWorkerConfigurationRequest, proxyClient.client()::describeWorkerConfiguration); + } catch (final NotFoundException e) { + logger.log(String.format("Worker configuration %s does not exist", identifier)); + throw exceptionTranslator.translateToCfnException(e, identifier); + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + logger.log(String.format("Validated Worker Configuration exists; Name %s", + describeWorkerConfigurationResponse.name())); + return describeWorkerConfigurationResponse; + } + + private DeleteWorkerConfigurationResponse deleteWorkerConfiguration( + final DeleteWorkerConfigurationRequest deleteWorkerConfigurationRequest, + final ProxyClient proxyClient) { + DeleteWorkerConfigurationResponse deleteWorkerConfigurationResponse; + final String identifier = deleteWorkerConfigurationRequest.workerConfigurationArn(); + + try { + deleteWorkerConfigurationResponse = + proxyClient.injectCredentialsAndInvokeV2(deleteWorkerConfigurationRequest, + proxyClient.client()::deleteWorkerConfiguration); + logger.log(String.format("Deleted Worker Configuration; ARN %s", identifier)); + + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + return deleteWorkerConfigurationResponse; + } + + /** + * If deletion of your resource requires some form of stabilization (e.g. propagation delay) + * for more information -> https://docs.aws.amazon.com/cloudformation-cli/latest/userguide/resource-type-test-contract.html + * @param deleteWorkerConfigurationRequest the aws service request to delete a resource + * @param proxyClient the aws service client to make the call + * @param model resource model + * @return boolean state of stabilized or not + */ + private boolean isStabilized( + final DeleteWorkerConfigurationRequest deleteWorkerConfigurationRequest, + final ProxyClient proxyClient, + final ResourceModel model) { + + final String identifier = deleteWorkerConfigurationRequest.workerConfigurationArn(); + + try { + final WorkerConfigurationState currentWorkerConfigurationState = + proxyClient.injectCredentialsAndInvokeV2(translator.translateToReadRequest(model), + proxyClient.client()::describeWorkerConfiguration).workerConfigurationState(); + + switch (currentWorkerConfigurationState) { + case DELETING: + logger.log(String.format("Worker configuration %s is deleting, current state is %s", identifier, + currentWorkerConfigurationState)); + return false; + default: + logger.log(String.format("Worker configuration %s reached unexpected state %s", identifier, + currentWorkerConfigurationState)); + throw new CfnNotStabilizedException( + ResourceModel.TYPE_NAME, identifier); + } + } catch (final NotFoundException e) { + logger.log(String.format("Worker configuration %s is deleted", identifier)); + return true; + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/ExceptionTranslator.java b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/ExceptionTranslator.java new file mode 100644 index 0000000..689ac36 --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/ExceptionTranslator.java @@ -0,0 +1,51 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.model.NotFoundException; +import software.amazon.awssdk.services.kafkaconnect.model.ConflictException; +import software.amazon.awssdk.services.kafkaconnect.model.InternalServerErrorException; +import software.amazon.awssdk.services.kafkaconnect.model.BadRequestException; +import software.amazon.awssdk.services.kafkaconnect.model.UnauthorizedException; + +import software.amazon.cloudformation.exceptions.*; + +public class ExceptionTranslator { + + public ExceptionTranslator() { + } + + /** + * Translation for exceptions coming from SDK having no additional messaging or clarification needs + * to Cfn exceptions. + * + * @param exception SDK exception to translate + * @param identifier Resource identifying field + * @return Cfn equivalent exception + */ + public BaseHandlerException translateToCfnException( + final AwsServiceException exception, + final String identifier) { + + if (exception instanceof NotFoundException) { + return new CfnNotFoundException(ResourceModel.TYPE_NAME, identifier, exception); + } + + if (exception instanceof BadRequestException) { + return new CfnInvalidRequestException(exception.getMessage(), exception); + } + + if (exception instanceof ConflictException) { + return new CfnAlreadyExistsException(ResourceModel.TYPE_NAME, identifier, exception); + } + + if (exception instanceof InternalServerErrorException) { + return new CfnInternalFailureException(exception); + } + + if (exception instanceof UnauthorizedException) { + return new CfnAccessDeniedException(ResourceModel.TYPE_NAME, exception); + } + + return new CfnGeneralServiceException(exception); + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/ListHandler.java b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/ListHandler.java new file mode 100644 index 0000000..33d3a62 --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/ListHandler.java @@ -0,0 +1,68 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.ListWorkerConfigurationsRequest; +import software.amazon.awssdk.services.kafkaconnect.model.ListWorkerConfigurationsResponse; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.util.List; + +public class ListHandler extends BaseHandlerStd { + + private final ExceptionTranslator exceptionTranslator; + private final Translator translator; + + public ListHandler() { + this(new ExceptionTranslator(), new Translator()); + } + + /** + * Constructor used for unit testing + * + * @param exceptionTranslator + * @param translator + */ + ListHandler(final ExceptionTranslator exceptionTranslator, final Translator translator) { + this.exceptionTranslator = exceptionTranslator; + this.translator = translator; + } + + @Override + public ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + final ListWorkerConfigurationsRequest listWorkerConfigurationsRequest = + translator.translateToListRequest(request.getNextToken()); + + final KafkaConnectClient kafkaConnectClient = proxyClient.client(); + + ListWorkerConfigurationsResponse listWorkerConfigurationsResponse; + + try { + listWorkerConfigurationsResponse = proxyClient.injectCredentialsAndInvokeV2(listWorkerConfigurationsRequest, + kafkaConnectClient::listWorkerConfigurations); + } catch (final AwsServiceException e) { + final String identifier = request.getAwsAccountId(); + throw exceptionTranslator.translateToCfnException(e, identifier); + } + + final List models = translator.translateFromListResponse(listWorkerConfigurationsResponse); + final String nextToken = listWorkerConfigurationsResponse.nextToken(); + + return ProgressEvent.builder() + .resourceModels(models) + .nextToken(nextToken) + .status(OperationStatus.SUCCESS) + .build(); + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/ReadHandler.java b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/ReadHandler.java new file mode 100644 index 0000000..1fa6ecd --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/ReadHandler.java @@ -0,0 +1,93 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationResponse; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceResponse; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.util.Map; + +public class ReadHandler extends BaseHandlerStd { + private Logger logger; + private final ExceptionTranslator exceptionTranslator; + private final Translator translator; + + public ReadHandler() { + this(new ExceptionTranslator(), new Translator()); + } + + /** + * Constructor used for unit testing + * + * @param exceptionTranslator + * @param translator + */ + ReadHandler(final ExceptionTranslator exceptionTranslator, final Translator translator) { + this.exceptionTranslator = exceptionTranslator; + this.translator = translator; + } + + @Override + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + + return proxy.initiate( + "AWS-KafkaConnect-WorkerConfiguration::Read", + proxyClient, + request.getDesiredResourceState(), + callbackContext) + .translateToServiceRequest(translator::translateToReadRequest) + .makeServiceCall(this::describeWorkerConfigurationWithTags) + .done(responseModel -> ProgressEvent.defaultSuccessHandler(responseModel)); + } + + private ResourceModel describeWorkerConfigurationWithTags( + final DescribeWorkerConfigurationRequest describeWorkerConfigurationRequest, + final ProxyClient proxyClient) { + + DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse; + Map workerConfigurationTags; + final String identifier = describeWorkerConfigurationRequest.workerConfigurationArn(); + final KafkaConnectClient kafkaConnectClient = proxyClient.client(); + + try { + describeWorkerConfigurationResponse = + proxyClient.injectCredentialsAndInvokeV2(describeWorkerConfigurationRequest, + kafkaConnectClient::describeWorkerConfiguration); + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + + try { + final ListTagsForResourceResponse listTagsForResourceResponse = TagHelper + .listTags(describeWorkerConfigurationRequest.workerConfigurationArn(), kafkaConnectClient, proxyClient); + workerConfigurationTags = listTagsForResourceResponse.tags(); + + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + + } + + logger.log( + String.format( + "%s [%s] has successfully been read.", + ResourceModel.TYPE_NAME, + identifier)); + ResourceModel readResponse = translator.translateFromReadResponse(describeWorkerConfigurationResponse); + readResponse.setTags(TagHelper.convertToSet(workerConfigurationTags)); + + return readResponse; + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/TagHelper.java b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/TagHelper.java new file mode 100644 index 0000000..224fd15 --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/TagHelper.java @@ -0,0 +1,199 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.ObjectUtils; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceResponse; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.Objects; +import java.util.stream.Collectors; + +public class TagHelper { + /** + * convertToMap + * + * Converts a collection of Tag objects to a tag-name -> tag-value map. + * + * Note: Tag objects with null tag values will not be included in the output + * map. + * + * @param tags Collection of tags to convert + * @return Converted Map of tags + */ + public static Map convertToMap(final Collection tags) { + if (CollectionUtils.isEmpty(tags)) { + return Collections.emptyMap(); + } + return tags.stream() + .filter(tag -> tag.getValue() != null) + .collect(Collectors.toMap( + Tag::getKey, + Tag::getValue, + (oldValue, newValue) -> newValue)); + } + + /** + * convertToSet + * + * Converts a tag map to a set of Tag objects. + * + * Note: Like convertToMap, convertToSet filters out value-less tag entries. + * + * @param tagMap Map of tags to convert + * @return Set of Tag objects + */ + public static Set convertToSet(final Map tagMap) { + if (MapUtils.isEmpty(tagMap)) { + return Collections.emptySet(); + } + return tagMap.entrySet().stream() + .filter(tag -> tag.getValue() != null) + .map(tag -> Tag.builder() + .key(tag.getKey()) + .value(tag.getValue()) + .build()) + .collect(Collectors.toSet()); + } + + public static ListTagsForResourceResponse listTags(final String arn, final KafkaConnectClient kafkaConnectClient, + final ProxyClient proxyClient) { + final ListTagsForResourceRequest listTagsForResourceRequest = ListTagsForResourceRequest.builder() + .resourceArn(arn) + .build(); + + return proxyClient.injectCredentialsAndInvokeV2(listTagsForResourceRequest, + kafkaConnectClient::listTagsForResource); + } + + /** + * generateTagsForCreate + * + * Generate tags to put into resource creation request. + * This includes user defined tags and system tags as well. + */ + public static Map generateTagsForCreate( + final ResourceHandlerRequest handlerRequest) { + final Map tagMap = new HashMap<>(); + + // merge system tags with desired resource tags if your service supports CloudFormation system tags + if (handlerRequest.getSystemTags() != null) { + tagMap.putAll(handlerRequest.getSystemTags()); + } + + // get desired stack level tags from handlerRequest + if (handlerRequest.getDesiredResourceTags() != null) { + tagMap.putAll(handlerRequest.getDesiredResourceTags()); + } + + // get resource level tags from resource model based on your tag property name + if (handlerRequest.getDesiredResourceState() != null + && handlerRequest.getDesiredResourceState().getTags() != null) { + tagMap.putAll(convertToMap(handlerRequest.getDesiredResourceState().getTags())); + } + + return Collections.unmodifiableMap(tagMap); + } + + /** + * shouldUpdateTags + * + * Determines whether user defined tags have been changed during update. + */ + public static boolean shouldUpdateTags(final ResourceHandlerRequest handlerRequest) { + final Map previousTags = getPreviouslyAttachedTags(handlerRequest); + final Map desiredTags = getNewDesiredTags(handlerRequest); + return ObjectUtils.notEqual(previousTags, desiredTags); + } + + /** + * getPreviouslyAttachedTags + * + * If stack tags and resource tags are not merged together in Configuration class, + * we will get previous attached user defined tags from both handlerRequest.getPreviousResourceTags (stack tags) + * and handlerRequest.getPreviousResourceState (resource tags). + */ + public static Map getPreviouslyAttachedTags( + final ResourceHandlerRequest handlerRequest) { + final Map previousTags = new HashMap<>(); + + // get previous system tags if your service supports CloudFormation system tags + if (handlerRequest.getPreviousSystemTags() != null) { + previousTags.putAll(handlerRequest.getPreviousSystemTags()); + } + + // get previous stack level tags from handlerRequest + if (handlerRequest.getPreviousResourceTags() != null) { + previousTags.putAll(handlerRequest.getPreviousResourceTags()); + } + + // get resource level tags from previous resource state based on your tag property name + if (handlerRequest.getPreviousResourceState() != null + && handlerRequest.getPreviousResourceState().getTags() != null) { + previousTags.putAll(convertToMap(handlerRequest.getPreviousResourceState().getTags())); + } + + return previousTags; + } + + /** + * getNewDesiredTags + * + * If stack tags and resource tags are not merged together in Configuration class, + * we will get new user defined tags from both resource model and previous stack tags. + */ + public static Map getNewDesiredTags(final ResourceHandlerRequest handlerRequest) { + final Map desiredTags = new HashMap<>(); + + // merge system tags with desired resource tags if your service supports CloudFormation system tags + if (handlerRequest.getSystemTags() != null) { + desiredTags.putAll(handlerRequest.getSystemTags()); + } + + // get desired stack level tags from handlerRequest + if (handlerRequest.getDesiredResourceTags() != null) { + desiredTags.putAll(handlerRequest.getDesiredResourceTags()); + } + + // get resource level tags from resource model based on your tag property name + desiredTags.putAll(convertToMap(handlerRequest.getDesiredResourceState().getTags())); + return desiredTags; + } + + /** + * generateTagsToAdd + * + * Determines the tags the customer desired to define or redefine. + */ + public static Map generateTagsToAdd(final Map previousTags, + final Map desiredTags) { + return desiredTags.entrySet().stream() + .filter(e -> !previousTags.containsKey(e.getKey()) + || !Objects.equals(previousTags.get(e.getKey()), e.getValue())) + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue)); + } + + /** + * getTagsToRemove + * + * Determines the tags the customer desired to remove from the function. + */ + public static Set generateTagsToRemove(final Map previousTags, + final Map desiredTags) { + final Set desiredTagNames = desiredTags.keySet(); + + return previousTags.keySet().stream() + .filter(tagName -> !desiredTagNames.contains(tagName)) + .collect(Collectors.toSet()); + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/Translator.java b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/Translator.java new file mode 100644 index 0000000..3079d4c --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/Translator.java @@ -0,0 +1,149 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import software.amazon.awssdk.services.kafkaconnect.model.CreateWorkerConfigurationRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationResponse; +import software.amazon.awssdk.services.kafkaconnect.model.ListWorkerConfigurationsRequest; +import software.amazon.awssdk.services.kafkaconnect.model.ListWorkerConfigurationsResponse; +import software.amazon.awssdk.services.kafkaconnect.model.DeleteWorkerConfigurationRequest; +import software.amazon.awssdk.services.kafkaconnect.model.TagResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.UntagResourceRequest; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * This class is a centralized placeholder for + * - api request construction + * - object translation to/from aws sdk + * - resource model construction for read/list handlers + */ + +public class Translator { + + public Translator() { + } + + /** + * Request to create a resource + * + * @param model resource model + * @return createWorkerConfigurationRequest the kafkaconnect request to create a resource + */ + public CreateWorkerConfigurationRequest translateToCreateRequest(final ResourceModel model, + final Map tagsForCreate) { + return CreateWorkerConfigurationRequest.builder() + .name(model.getName()) + .description(model.getDescription()) + .propertiesFileContent(model.getPropertiesFileContent()) + .tags(tagsForCreate) + .build(); + } + + /** + * Request to read a resource + * + * @param model resource model + * @return describeWorkerConfigurationRequest the kafkaconnect request to describe a resource + */ + public DescribeWorkerConfigurationRequest translateToReadRequest(final ResourceModel model) { + return DescribeWorkerConfigurationRequest.builder() + .workerConfigurationArn(model.getWorkerConfigurationArn()) + .build(); + } + + /** + * Translates resource object from sdk into a resource model + * + * @param describeWorkerConfigurationResponse the kafkaconnect describe resource response + * @return model resource model + */ + public ResourceModel translateFromReadResponse( + final DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse) { + return ResourceModel + .builder() + .name(describeWorkerConfigurationResponse.name()) + .description(describeWorkerConfigurationResponse.description()) + .workerConfigurationArn(describeWorkerConfigurationResponse.workerConfigurationArn()) + .revision(describeWorkerConfigurationResponse.latestRevision().revision()) + .propertiesFileContent(describeWorkerConfigurationResponse.latestRevision().propertiesFileContent()) + .build(); + } + + /** + * Request to delete a resource + * + * @param model resource model + * @return awsRequest the aws service request to delete a resource + */ + public DeleteWorkerConfigurationRequest translateToDeleteRequest(final ResourceModel model) { + return DeleteWorkerConfigurationRequest.builder() + .workerConfigurationArn(model.getWorkerConfigurationArn()) + .build(); + } + + /** + * Request to list resources + * + * @param nextToken token passed to the aws service list resources request + * @return awsRequest the aws service request to list resources within aws account + */ + public ListWorkerConfigurationsRequest translateToListRequest(final String nextToken) { + return ListWorkerConfigurationsRequest.builder() + .nextToken(nextToken) + .build(); + } + + /** + * Translates resource objects from sdk into a resource model (primary identifier only) + * + * @param listWorkerConfigurationsResponse the aws service list resources response + * @return list of resource models + */ + public List translateFromListResponse( + final ListWorkerConfigurationsResponse listWorkerConfigurationsResponse) { + return streamOfOrEmpty(listWorkerConfigurationsResponse.workerConfigurations()) + .map(workerConfiguration -> ResourceModel.builder() + .workerConfigurationArn(workerConfiguration.workerConfigurationArn()) + .build()) + .collect(Collectors.toList()); + } + + private static Stream streamOfOrEmpty(final Collection collection) { + return Optional.ofNullable(collection) + .map(Collection::stream) + .orElseGet(Stream::empty); + } + + /** + * Request to add tags to a resource + * + * @param model resource model + * @return awsRequest the aws service request to create a resource + */ + static TagResourceRequest tagResourceRequest(final ResourceModel model, final Map addedTags) { + return TagResourceRequest.builder() + .resourceArn(model.getWorkerConfigurationArn()) + .tags(addedTags) + .build(); + } + + /** + * Request to add tags to a resource + * + * @param model resource model + * @return awsRequest the aws service request to create a resource + */ + static UntagResourceRequest untagResourceRequest(final ResourceModel model, final Set removedTags) { + return UntagResourceRequest.builder() + .resourceArn(model.getWorkerConfigurationArn()) + .tagKeys(removedTags) + .build(); + + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/UpdateHandler.java b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/UpdateHandler.java new file mode 100644 index 0000000..87fc387 --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/main/java/software/amazon/kafkaconnect/workerconfiguration/UpdateHandler.java @@ -0,0 +1,174 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationResponse; +import software.amazon.awssdk.services.kafkaconnect.model.TagResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.UntagResourceRequest; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnNotUpdatableException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +public class UpdateHandler extends BaseHandlerStd { + private Logger logger; + private final Translator translator; + + private final ExceptionTranslator exceptionTranslator; + + private final ReadHandler readHandler; + + public UpdateHandler() { + this(new ExceptionTranslator(), new Translator(), new ReadHandler()); + } + + /** + * Constructor used for unit testing + * + * @param translator + * @param readHandler + */ + UpdateHandler(final ExceptionTranslator exceptionTranslator, final Translator translator, + final ReadHandler readHandler) { + + this.translator = translator; + this.exceptionTranslator = exceptionTranslator; + this.readHandler = readHandler; + } + + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + this.logger = logger; + + final ResourceModel model = request.getDesiredResourceState(); + + return ProgressEvent.progress(model, callbackContext) + .then(progress -> proxy + .initiate("AWS-KafkaConnect-WorkerConfiguration::ValidateResourceExists", proxyClient, model, + callbackContext) + .translateToServiceRequest(translator::translateToReadRequest) + .makeServiceCall(this::validateResourceExists) + .progress()) + .then(progress -> verifyNonUpdatableFields(model, request.getPreviousResourceState(), progress)) + .then(progress -> updateTags(proxyClient, progress, request)) + .then(progress -> readHandler.handleRequest(proxy, request, callbackContext, proxyClient, logger)); + } + + private DescribeWorkerConfigurationResponse validateResourceExists( + DescribeWorkerConfigurationRequest describeWorkerConfigurationRequest, + ProxyClient proxyClient) { + DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse; + if (describeWorkerConfigurationRequest.workerConfigurationArn() == null) { + throw new CfnNotFoundException(ResourceModel.TYPE_NAME, null); + } + final String identifier = describeWorkerConfigurationRequest.workerConfigurationArn(); + + try { + describeWorkerConfigurationResponse = proxyClient.injectCredentialsAndInvokeV2( + describeWorkerConfigurationRequest, proxyClient.client()::describeWorkerConfiguration); + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + logger.log(String.format("Validated Worker Configuration exists; Name %s", + describeWorkerConfigurationResponse.name())); + return describeWorkerConfigurationResponse; + } + + private ProgressEvent updateTags(final ProxyClient proxyClient, + final ProgressEvent progress, ResourceHandlerRequest request) { + + final ResourceModel desiredModel = request.getDesiredResourceState(); + final String identifier = desiredModel.getName(); + final CallbackContext callbackContext = progress.getCallbackContext(); + + if (TagHelper.shouldUpdateTags(request)) { + final Map previousTags = TagHelper.getPreviouslyAttachedTags(request); + final Map desiredTags = TagHelper.getNewDesiredTags(request); + final Map addedTags = TagHelper.generateTagsToAdd(previousTags, desiredTags); + final Set removedTags = TagHelper.generateTagsToRemove(previousTags, desiredTags); + + // calculate tags to remove based on key only + if (!removedTags.isEmpty()) { + final UntagResourceRequest untagResourceRequest = + Translator.untagResourceRequest(desiredModel, removedTags); + try { + proxyClient.injectCredentialsAndInvokeV2(untagResourceRequest, proxyClient.client()::untagResource); + logger.log(String.format("Removed %d tags", removedTags.size())); + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + } + + // calculate tags to update based on Tags (key + value) + if (!addedTags.isEmpty()) { + final TagResourceRequest tagResourceRequest = Translator.tagResourceRequest(desiredModel, addedTags); + try { + proxyClient.injectCredentialsAndInvokeV2(tagResourceRequest, proxyClient.client()::tagResource); + logger.log(String.format("Added %d tags", addedTags.size())); + } catch (final AwsServiceException e) { + throw exceptionTranslator.translateToCfnException(e, identifier); + } + } + } + return ProgressEvent.progress(desiredModel, callbackContext); + + } + + /** + * Checks the if the create only fields have been updated and throws an exception if it is the case + * @param currModel the current resource model + * @param prevModel the previous resource model + */ + private ProgressEvent verifyNonUpdatableFields(ResourceModel currModel, + ResourceModel prevModel, + ProgressEvent progress) { + + if (prevModel != null) { + final String identifier = prevModel.getWorkerConfigurationArn(); + if (!Optional.ofNullable(currModel.getName()).equals(Optional.ofNullable(prevModel.getName()))) { + logger.log("Name change not allowed"); + throw new CfnNotUpdatableException(ResourceModel.TYPE_NAME, identifier); + } + if (!Optional.ofNullable(currModel.getDescription()) + .equals(Optional.ofNullable(prevModel.getDescription()))) { + logger.log(String.format("Description change not allowed; Previous: %s; Current: %s", + prevModel.getDescription(), currModel.getDescription())); + throw new CfnNotUpdatableException(ResourceModel.TYPE_NAME, identifier); + } + if (!Optional.ofNullable(currModel.getPropertiesFileContent()) + .equals(Optional.ofNullable(prevModel.getPropertiesFileContent()))) { + logger.log(String.format("PropertiesFileContent change not allowed; Previous: %s; Current: %s", + prevModel.getPropertiesFileContent(), currModel.getPropertiesFileContent())); + throw new CfnNotUpdatableException(ResourceModel.TYPE_NAME, identifier); + + } + if (!Optional.ofNullable(currModel.getRevision()).equals(Optional.ofNullable(prevModel.getRevision()))) { + logger.log(String.format("Revision change not allowed; Previous: %s; Current: %s", + prevModel.getRevision(), currModel.getRevision())); + throw new CfnNotUpdatableException(ResourceModel.TYPE_NAME, identifier); + } + if (!Optional.ofNullable(currModel.getWorkerConfigurationArn()) + .equals(Optional.ofNullable(prevModel.getWorkerConfigurationArn()))) { + logger.log(String.format("WorkerConfigurationArn change not allowed; Previous: %s; Current: %s", + prevModel.getWorkerConfigurationArn(), currModel.getWorkerConfigurationArn())); + throw new CfnNotUpdatableException(ResourceModel.TYPE_NAME, identifier); + } + } + logger.log("Verified non-updatable fields"); + + return progress; + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/resources/log4j2.xml b/aws-kafkaconnect-workerconfiguration/src/resources/log4j2.xml new file mode 100644 index 0000000..5657daf --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/resources/log4j2.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/AbstractTestBase.java b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/AbstractTestBase.java new file mode 100644 index 0000000..3a77a49 --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/AbstractTestBase.java @@ -0,0 +1,72 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.HashMap; +import java.util.Map; + +import software.amazon.awssdk.awscore.AwsRequest; +import software.amazon.awssdk.awscore.AwsResponse; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.pagination.sync.SdkIterable; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Credentials; +import software.amazon.cloudformation.proxy.LoggerProxy; +import software.amazon.cloudformation.proxy.ProxyClient; + +public class AbstractTestBase { + protected static final Credentials MOCK_CREDENTIALS = new Credentials("accessKey", "secretKey", "token"); + protected static final LoggerProxy logger = new LoggerProxy(); + + protected static final Map TAGS = new HashMap() { + { + put("TEST_TAG1", "TEST_TAG_VALUE1"); + put("TEST_TAG2", "TEST_TAG_VALUE2"); + } + }; + + static ProxyClient proxyStub( + final AmazonWebServicesClientProxy proxy, + final KafkaConnectClient kafkaConnectClient) { + return new ProxyClient() { + @Override + public ResponseT injectCredentialsAndInvokeV2( + RequestT request, Function requestFunction) { + return proxy.injectCredentialsAndInvokeV2(request, requestFunction); + } + + @Override + public CompletableFuture injectCredentialsAndInvokeV2Async( + RequestT request, Function> requestFunction) { + throw new UnsupportedOperationException(); + } + + @Override + public > IterableT injectCredentialsAndInvokeIterableV2( + RequestT request, Function requestFunction) { + return proxy.injectCredentialsAndInvokeIterableV2(request, requestFunction); + } + + @Override + public ResponseInputStream injectCredentialsAndInvokeV2InputStream( + RequestT requestT, Function> function) { + + throw new UnsupportedOperationException(); + } + + @Override + public ResponseBytes injectCredentialsAndInvokeV2Bytes( + RequestT requestT, Function> function) { + + throw new UnsupportedOperationException(); + } + + @Override + public KafkaConnectClient client() { + return kafkaConnectClient; + } + }; + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/BaseHandlerStdTest.java b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/BaseHandlerStdTest.java new file mode 100644 index 0000000..c80bffc --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/BaseHandlerStdTest.java @@ -0,0 +1,160 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationResponse; +import software.amazon.awssdk.services.kafkaconnect.model.NotFoundException; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.proxy.*; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class BaseHandlerStdTest extends AbstractTestBase { + + @Mock + private KafkaConnectClient kafkaConnectClient; + + @Mock + private ExceptionTranslator exceptionTranslator; + + private ProxyClient proxyClient; + + private StubHandler stubHandler; + + @BeforeEach + public void setup() { + final AmazonWebServicesClientProxy proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, + () -> Duration.ofSeconds(600).toMillis()); + proxyClient = proxyStub(proxy, kafkaConnectClient); + stubHandler = new StubHandler(); + } + + @AfterEach + public void tear_down() { + verifyNoMoreInteractions(kafkaConnectClient); + } + + @Test + public void runDescribeWorkerConfiguration_success() { + when(proxyClient.injectCredentialsAndInvokeV2(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, + kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_RESPONSE); + + final DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse = + stubHandler.runDescribeWorkerConfiguration( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, proxyClient, TestData.FAILURE_MESSAGE_PATTERN); + + assertThat(describeWorkerConfigurationResponse).isEqualTo(TestData.DESCRIBE_WORKER_CONFIGURATION_RESPONSE); + } + + @Test + public void runDescribeWorkerConfiguration_throwsCfnGeneralServiceException_whenDescribeFails() { + when(proxyClient.injectCredentialsAndInvokeV2(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, + kafkaConnectClient::describeWorkerConfiguration)).thenThrow(AwsServiceException.builder() + .message(TestData.EXCEPTION_MESSAGE).build()); + + final CfnGeneralServiceException serviceException = assertThrows(CfnGeneralServiceException.class, + () -> stubHandler.runDescribeWorkerConfiguration(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, + proxyClient, + TestData.FAILURE_MESSAGE_PATTERN)); + + assertThat(serviceException.getMessage()).isEqualTo(String.format("Error occurred during operation '" + + TestData.FAILURE_MESSAGE_PATTERN + "'.", ResourceModel.TYPE_NAME, TestData.EXCEPTION_MESSAGE)); + } + + @Test + public void runDescribeWorkerConfigurationWithNotFoundCatch_success() { + when(proxyClient.injectCredentialsAndInvokeV2(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, + kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_RESPONSE); + + final DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse = + stubHandler.runDescribeWorkerConfigurationWithNotFoundCatch( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, proxyClient, TestData.FAILURE_MESSAGE_PATTERN, + exceptionTranslator); + + assertThat(describeWorkerConfigurationResponse).isEqualTo(TestData.DESCRIBE_WORKER_CONFIGURATION_RESPONSE); + } + + @Test + public void runDescribeWorkerConfigurationWithNotFoundCatch_throwsCfnNotFoundException_whenWorkerConfigurationDoesNotExist() { + final NotFoundException exception = NotFoundException.builder() + .message(TestData.EXCEPTION_MESSAGE).build(); + final CfnNotFoundException cfnException = new CfnNotFoundException(ResourceModel.TYPE_NAME, + TestData.WORKER_CONFIGURATION_ARN, exception); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, + kafkaConnectClient::describeWorkerConfiguration)).thenThrow(exception); + when(exceptionTranslator.translateToCfnException(exception, TestData.WORKER_CONFIGURATION_ARN)) + .thenReturn(cfnException); + + final CfnNotFoundException responseException = assertThrows(CfnNotFoundException.class, + () -> stubHandler.runDescribeWorkerConfigurationWithNotFoundCatch( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, proxyClient, + TestData.FAILURE_MESSAGE_PATTERN, exceptionTranslator)); + + assertThat(responseException).isEqualTo(cfnException); + } + + @Test + public void runDescribeWorkerConfigurationWithNotFoundCatch_throwsCfnGeneralServiceException_whenDescribeFails() { + when(proxyClient.injectCredentialsAndInvokeV2(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, + kafkaConnectClient::describeWorkerConfiguration)).thenThrow(AwsServiceException.builder() + .message(TestData.EXCEPTION_MESSAGE).build()); + + final CfnGeneralServiceException serviceException = assertThrows(CfnGeneralServiceException.class, + () -> stubHandler.runDescribeWorkerConfigurationWithNotFoundCatch( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, proxyClient, + TestData.FAILURE_MESSAGE_PATTERN, exceptionTranslator)); + + assertThat(serviceException.getMessage()).isEqualTo(String.format("Error occurred during operation '" + + TestData.FAILURE_MESSAGE_PATTERN + "'.", ResourceModel.TYPE_NAME, TestData.EXCEPTION_MESSAGE)); + } + + private class StubHandler extends BaseHandlerStd { + @Override + protected ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final ResourceHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger) { + + return super.handleRequest(proxy, request, callbackContext, logger); + } + } + + private static class TestData { + + private static final String WORKER_CONFIGURATION_ARN = + "arn:aws:kafkaconnect:us-east-1:1111111111:worker-configuration/unit-test-worker-configuration"; + + private static final String FAILURE_MESSAGE_PATTERN = "%s FAILED because of %s"; + private static final String EXCEPTION_MESSAGE = "Exception Message"; + + private static final DescribeWorkerConfigurationRequest DESCRIBE_WORKER_CONFIGURATION_REQUEST = + DescribeWorkerConfigurationRequest + .builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN) + .build(); + + private static final DescribeWorkerConfigurationResponse DESCRIBE_WORKER_CONFIGURATION_RESPONSE = + DescribeWorkerConfigurationResponse + .builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN) + .build(); + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/ConfigurationTest.java b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/ConfigurationTest.java new file mode 100644 index 0000000..0804ecd --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/ConfigurationTest.java @@ -0,0 +1,37 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ConfigurationTest extends AbstractTestBase { + + private Configuration configuration; + + @BeforeEach + public void setup() { + configuration = new Configuration(); + } + + @Test + public void test_resourceDefinedTags_whenTagsAreNull() { + final ResourceModel model = ResourceModel.builder().tags(null).build(); + + final Map response = configuration.resourceDefinedTags(model); + + assertThat(response).isNull(); + } + + @Test + public void test_resourceDefinedTags_whenTagsAreNotNull() { + final ResourceModel model = ResourceModel.builder().tags(TagHelper.convertToSet(TAGS)).build(); + + final Map response = configuration.resourceDefinedTags(model); + + assertThat(response).isNotNull(); + assertThat(response).isEqualTo(TagHelper.convertToMap(model.getTags())); + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/CreateHandlerTest.java b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/CreateHandlerTest.java new file mode 100644 index 0000000..ee6af4c --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/CreateHandlerTest.java @@ -0,0 +1,225 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; + +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.cloudformation.exceptions.CfnAlreadyExistsException; +import software.amazon.awssdk.services.kafkaconnect.model.CreateWorkerConfigurationRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationResponse; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.CreateWorkerConfigurationResponse; +import software.amazon.awssdk.services.kafkaconnect.model.ConflictException; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceResponse; +import software.amazon.awssdk.services.kafkaconnect.model.WorkerConfigurationRevisionSummary; +import software.amazon.awssdk.services.kafkaconnect.model.WorkerConfigurationRevisionDescription; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class CreateHandlerTest extends AbstractTestBase { + + @Mock + private KafkaConnectClient kafkaConnectClient; + + @Mock + private ExceptionTranslator exceptionTranslator; + + @Mock + private Translator translator; + + private ReadHandler readHandler; + + private AmazonWebServicesClientProxy proxy; + + private ProxyClient proxyClient; + + private CreateHandler handler; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, + () -> Duration.ofSeconds(600).toMillis()); + proxyClient = proxyStub(proxy, kafkaConnectClient); + readHandler = new ReadHandler(exceptionTranslator, translator); + handler = new CreateHandler(exceptionTranslator, translator, readHandler); + } + + @AfterEach + public void tear_down() { + verify(kafkaConnectClient, atLeastOnce()).serviceName(); + verifyNoMoreInteractions(kafkaConnectClient, exceptionTranslator, translator); + } + + @Test + public void handleRequest_success() { + final ResourceModel resourceModel = TestData.getResourceModel(); + when(translator.translateToCreateRequest(resourceModel, TagHelper.convertToMap(resourceModel.getTags()))) + .thenReturn(TestData.CREATE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.CREATE_WORKER_CONFIGURATION_REQUEST, kafkaConnectClient::createWorkerConfiguration)) + .thenReturn(TestData.CREATE_WORKER_CONFIGURATION_RESPONSE); + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL_WITH_ARN)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + final DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse = + TestData.describeResponse(); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(describeWorkerConfigurationResponse); + when(translator.translateFromReadResponse(describeWorkerConfigurationResponse)) + .thenReturn(TestData.RESOURCE_MODEL_WITH_ARN); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, + kafkaConnectClient::listTagsForResource)).thenReturn(TestData.LIST_TAGS_FOR_RESOURCE_RESPONSE); + + final ResourceHandlerRequest request = TestData.getResourceHandlerRequest(resourceModel); + + final ProgressEvent response = handler.handleRequest( + proxy, request, new CallbackContext(), proxyClient, logger); + + assertThat(response).isEqualTo(TestData.DESCRIBE_RESPONSE); + assertThat(response.getResourceModel().getTags()) + .isEqualTo(request.getDesiredResourceState().getTags()); + } + + @Test + public void handleRequest_throwsAlreadyExistsException_whenWorkerConfigurationExists() { + final ResourceModel resourceModel = TestData.getResourceModel(); + final ConflictException cException = ConflictException.builder().build(); + final CfnAlreadyExistsException cfnException = new CfnAlreadyExistsException(cException); + when(translator.translateToCreateRequest(resourceModel, TagHelper.convertToMap(resourceModel.getTags()))) + .thenReturn(TestData.CREATE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.CREATE_WORKER_CONFIGURATION_REQUEST, + kafkaConnectClient::createWorkerConfiguration)).thenThrow(cException); + when(exceptionTranslator.translateToCfnException(cException, TestData.WORKER_CONFIGURATION_NAME)) + .thenReturn(cfnException); + + final CfnAlreadyExistsException exception = assertThrows(CfnAlreadyExistsException.class, + () -> handler.handleRequest(proxy, TestData.getResourceHandlerRequest(resourceModel), + new CallbackContext(), proxyClient, logger)); + + assertThat(exception).isEqualTo(cfnException); + } + + private static class TestData { + private static final String WORKER_CONFIGURATION_NAME = "unit-test-worker-configuration"; + private static final String WORKER_CONFIGURATION_DESCRIPTION = "Unit testing worker configuration description"; + + private static final long WORKER_CONFIGURATION_REVISION = 1L; + + private static final String WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT = "propertiesFileContent"; + + private static final String WORKER_CONFIGURATION_ARN = + "arn:aws:kafkaconnect:us-east-1:1111111111:worker-configuration/unit-test-worker-configuration"; + private static final Instant WORKER_CONFIGURATION_CREATION_TIME = + OffsetDateTime.parse("2021-03-04T14:03:40.818Z").toInstant(); + private static final Instant WORKER_CONFIGURATION_REVISION_CREATION_TIME = + OffsetDateTime.parse("2021-03-04T14:03:40.818Z").toInstant(); + private static final CreateWorkerConfigurationRequest CREATE_WORKER_CONFIGURATION_REQUEST = + CreateWorkerConfigurationRequest.builder() + .name(WORKER_CONFIGURATION_NAME) + .description(WORKER_CONFIGURATION_DESCRIPTION) + .propertiesFileContent(WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT) + .tags(TAGS) + .build(); + + private static final CreateWorkerConfigurationResponse CREATE_WORKER_CONFIGURATION_RESPONSE = + CreateWorkerConfigurationResponse + .builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN) + .creationTime(WORKER_CONFIGURATION_CREATION_TIME) + .name(WORKER_CONFIGURATION_NAME) + .latestRevision(workerConfigurationRevisionSummary()) + .build(); + + private static final DescribeWorkerConfigurationRequest DESCRIBE_WORKER_CONFIGURATION_REQUEST = + DescribeWorkerConfigurationRequest.builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN) + .build(); + + private static final ResourceModel RESOURCE_MODEL_WITH_ARN = ResourceModel.builder() + .name(WORKER_CONFIGURATION_NAME) + .workerConfigurationArn(WORKER_CONFIGURATION_ARN) + .tags(TagHelper.convertToSet(TAGS)) + .build(); + + private static final ProgressEvent DESCRIBE_RESPONSE = + ProgressEvent.builder() + .resourceModel(RESOURCE_MODEL_WITH_ARN) + .status(OperationStatus.SUCCESS) + .build(); + + private static ResourceHandlerRequest getResourceHandlerRequest( + final ResourceModel resourceModel) { + + return ResourceHandlerRequest.builder() + .desiredResourceState(resourceModel) + .desiredResourceTags(TAGS) + .build(); + } + + private static ResourceModel getResourceModel() { + return ResourceModel + .builder() + .name(WORKER_CONFIGURATION_NAME) + .tags(TagHelper.convertToSet(TAGS)) + .build(); + } + + private static DescribeWorkerConfigurationResponse describeResponse() { + return DescribeWorkerConfigurationResponse + .builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN) + .creationTime(WORKER_CONFIGURATION_CREATION_TIME) + .name(WORKER_CONFIGURATION_NAME) + .description(WORKER_CONFIGURATION_DESCRIPTION) + .latestRevision(workerConfigurationRevisionDescription()) + .build(); + } + + private static WorkerConfigurationRevisionSummary workerConfigurationRevisionSummary() { + return WorkerConfigurationRevisionSummary.builder() + .revision(WORKER_CONFIGURATION_REVISION) + .description(WORKER_CONFIGURATION_DESCRIPTION) + .creationTime(WORKER_CONFIGURATION_REVISION_CREATION_TIME) + .build(); + } + + private static WorkerConfigurationRevisionDescription workerConfigurationRevisionDescription() { + return WorkerConfigurationRevisionDescription.builder() + .revision(WORKER_CONFIGURATION_REVISION) + .description(WORKER_CONFIGURATION_DESCRIPTION) + .propertiesFileContent(WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT) + .build(); + } + + private static final ListTagsForResourceRequest LIST_TAGS_FOR_RESOURCE_REQUEST = + ListTagsForResourceRequest.builder() + .resourceArn(WORKER_CONFIGURATION_ARN) + .build(); + + private static final ListTagsForResourceResponse LIST_TAGS_FOR_RESOURCE_RESPONSE = + ListTagsForResourceResponse + .builder() + .tags(TAGS) + .build(); + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/DeleteHandlerTest.java b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/DeleteHandlerTest.java new file mode 100644 index 0000000..162b21a --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/DeleteHandlerTest.java @@ -0,0 +1,279 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.stream.Stream; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationResponse; +import software.amazon.awssdk.services.kafkaconnect.model.WorkerConfigurationState; +import software.amazon.awssdk.services.kafkaconnect.model.DeleteWorkerConfigurationRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DeleteWorkerConfigurationResponse; +import software.amazon.awssdk.services.kafkaconnect.model.NotFoundException; +import software.amazon.awssdk.services.kafkaconnect.model.BadRequestException; +import software.amazon.awssdk.services.kafkaconnect.model.InternalServerErrorException; +import software.amazon.awssdk.services.kafkaconnect.model.WorkerConfigurationRevisionDescription; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.cloudformation.exceptions.CfnNotStabilizedException; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +public class DeleteHandlerTest extends AbstractTestBase { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + private KafkaConnectClient kafkaConnectClient; + + @Mock + private ExceptionTranslator exceptionTranslator; + + @Mock + private Translator translator; + + private DeleteHandler handler; + + private static Stream stabilizeKafkaConnectErrorToCfnError() { + return Stream.of( + arguments(BadRequestException.builder().build()), + arguments(InternalServerErrorException.builder().build()), + arguments(AwsServiceException.builder().build())); + } + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, + () -> Duration.ofSeconds(600).toMillis()); + proxyClient = proxyStub(proxy, kafkaConnectClient); + handler = new DeleteHandler(exceptionTranslator, translator); + } + + @AfterEach + public void tear_down() { + verify(kafkaConnectClient, atLeastOnce()).serviceName(); + verifyNoMoreInteractions(kafkaConnectClient, exceptionTranslator, translator); + } + + @Test + public void handleRequest_SimpleSuccess() { + final DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse = TestData.describeResponse(); + + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(describeWorkerConfigurationResponse).thenReturn(describeWorkerConfigurationResponse) + .thenThrow(NotFoundException.class); + when(proxyClient.client().deleteWorkerConfiguration(any(DeleteWorkerConfigurationRequest.class))) + .thenReturn(TestData.DELETE_WORKER_CONFIGURATION_RESPONSE); + when(translator.translateToDeleteRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DELETE_WORKER_CONFIGURATION_REQUEST); + final ProgressEvent response = + handler.handleRequest(proxy, TestData.RESOURCE_HANDLER_REQUEST, new CallbackContext(), proxyClient, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isNull(); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + verify(proxyClient.client(), times(1)).deleteWorkerConfiguration(any(DeleteWorkerConfigurationRequest.class)); + verify(proxyClient.client(), times(3)) + .describeWorkerConfiguration(any(DescribeWorkerConfigurationRequest.class)); + } + + @Test + public void handleStabilize_UnexpectedStatus() { + final DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse = TestData.describeResponse(); + + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(describeWorkerConfigurationResponse).thenReturn(describeWorkerConfigurationResponse + .toBuilder().workerConfigurationState(WorkerConfigurationState.UNKNOWN_TO_SDK_VERSION).build()); + when(proxyClient.client().deleteWorkerConfiguration(any(DeleteWorkerConfigurationRequest.class))) + .thenReturn(TestData.DELETE_WORKER_CONFIGURATION_RESPONSE); + when(translator.translateToDeleteRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DELETE_WORKER_CONFIGURATION_REQUEST); + + assertThrows(CfnNotStabilizedException.class, + () -> handler.handleRequest(proxy, TestData.RESOURCE_HANDLER_REQUEST, new CallbackContext(), proxyClient, + logger)); + verify(proxyClient.client(), times(1)).deleteWorkerConfiguration(any(DeleteWorkerConfigurationRequest.class)); + verify(proxyClient.client(), times(2)) + .describeWorkerConfiguration(any(DescribeWorkerConfigurationRequest.class)); + } + + @Test + public void handleStabilize_BadRequest_InvalidParameter() { + final BadRequestException serviceException = BadRequestException.builder().build(); + final CfnInvalidRequestException cfnException = new CfnInvalidRequestException(serviceException); + + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, kafkaConnectClient::describeWorkerConfiguration)) + .thenThrow(serviceException); + when(exceptionTranslator.translateToCfnException(serviceException, TestData.WORKER_CONFIGURATION_ARN)) + .thenReturn(cfnException); + + assertThrows(CfnInvalidRequestException.class, + () -> handler.handleRequest(proxy, TestData.RESOURCE_HANDLER_REQUEST, new CallbackContext(), proxyClient, + logger)); + + verify(proxyClient.client(), times(0)).deleteWorkerConfiguration(any(DeleteWorkerConfigurationRequest.class)); + verify(proxyClient.client(), times(1)) + .describeWorkerConfiguration(any(DescribeWorkerConfigurationRequest.class)); + } + + @Test + public void handleDelete_ResourceNotFound_AlreadyDeletedFailure() { + final DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse = TestData.describeResponse(); + final NotFoundException serviceException = NotFoundException.builder().build(); + final CfnNotFoundException cfnException = new CfnNotFoundException(serviceException); + + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(describeWorkerConfigurationResponse); + when(proxyClient.client().deleteWorkerConfiguration(any(DeleteWorkerConfigurationRequest.class))) + .thenThrow(serviceException); + when(exceptionTranslator.translateToCfnException(serviceException, TestData.WORKER_CONFIGURATION_ARN)) + .thenReturn(cfnException); + when(translator.translateToDeleteRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DELETE_WORKER_CONFIGURATION_REQUEST); + + assertThrows(CfnNotFoundException.class, + () -> handler.handleRequest(proxy, TestData.RESOURCE_HANDLER_REQUEST, new CallbackContext(), proxyClient, + logger)); + + verify(proxyClient.client(), times(1)).deleteWorkerConfiguration(any(DeleteWorkerConfigurationRequest.class)); + verify(kafkaConnectClient, atLeastOnce()).serviceName(); + } + + @ParameterizedTest + @MethodSource("stabilizeKafkaConnectErrorToCfnError") + public void handleStabilize_Exception( + AwsServiceException kafkaConnectException) { + + final DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse = TestData.describeResponse(); + + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(describeWorkerConfigurationResponse).thenThrow(kafkaConnectException); + when(proxyClient.client().deleteWorkerConfiguration(any(DeleteWorkerConfigurationRequest.class))) + .thenReturn(TestData.DELETE_WORKER_CONFIGURATION_RESPONSE); + when(translator.translateToDeleteRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DELETE_WORKER_CONFIGURATION_REQUEST); + + final ProgressEvent response = + handler.handleRequest(proxy, TestData.RESOURCE_HANDLER_REQUEST, new CallbackContext(), proxyClient, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.FAILED); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModels()).isNull(); + + verify(proxyClient.client(), times(1)).deleteWorkerConfiguration(any(DeleteWorkerConfigurationRequest.class)); + verify(proxyClient.client(), times(2)) + .describeWorkerConfiguration(any(DescribeWorkerConfigurationRequest.class)); + verify(exceptionTranslator, times(1)).translateToCfnException(any(AwsServiceException.class), + eq(TestData.WORKER_CONFIGURATION_ARN)); + } + + private static class TestData { + private static final String WORKER_CONFIGURATION_ARN = + "arn:aws:kafkaconnect:us-east-1:1111111111:worker-configuration/unit-test-worker-configuration"; + + private static final String WORKER_CONFIGURATION_NAME = "unit-test-worker-configuration"; + + private static final String WORKER_CONFIGURATION_DESCRIPTION = "Unit testing worker configuration description"; + + private static final long WORKER_CONFIGURATION_REVISION = 1L; + + private static final String WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT = "propertiesFileContent"; + + private static final Instant WORKER_CONFIGURATION_CREATION_TIME = + OffsetDateTime.parse("2021-03-04T14:03:40.818Z").toInstant(); + private static final DescribeWorkerConfigurationRequest DESCRIBE_WORKER_CONFIGURATION_REQUEST = + DescribeWorkerConfigurationRequest.builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN) + .build(); + + private static final ResourceModel RESOURCE_MODEL = ResourceModel.builder() + .workerConfigurationArn(TestData.WORKER_CONFIGURATION_ARN) + .name(TestData.WORKER_CONFIGURATION_NAME) + .description(TestData.WORKER_CONFIGURATION_DESCRIPTION) + .revision(TestData.WORKER_CONFIGURATION_REVISION) + .propertiesFileContent(TestData.WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT) + .build(); + + private static WorkerConfigurationRevisionDescription workerConfigurationRevisionDescription() { + return WorkerConfigurationRevisionDescription.builder() + .revision(WORKER_CONFIGURATION_REVISION) + .description(WORKER_CONFIGURATION_DESCRIPTION) + .propertiesFileContent(WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT) + .build(); + } + + private static DescribeWorkerConfigurationResponse describeResponse() { + return DescribeWorkerConfigurationResponse + .builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN) + .creationTime(WORKER_CONFIGURATION_CREATION_TIME) + .name(WORKER_CONFIGURATION_NAME) + .description(WORKER_CONFIGURATION_DESCRIPTION) + .latestRevision(workerConfigurationRevisionDescription()) + .workerConfigurationState(WorkerConfigurationState.DELETING) + .build(); + } + + private static final ResourceHandlerRequest RESOURCE_HANDLER_REQUEST = + ResourceHandlerRequest.builder() + .desiredResourceState(RESOURCE_MODEL) + .build(); + + private static final DeleteWorkerConfigurationRequest DELETE_WORKER_CONFIGURATION_REQUEST = + DeleteWorkerConfigurationRequest.builder().workerConfigurationArn(WORKER_CONFIGURATION_ARN).build(); + + private static final DeleteWorkerConfigurationResponse DELETE_WORKER_CONFIGURATION_RESPONSE = + DeleteWorkerConfigurationResponse.builder().workerConfigurationArn(WORKER_CONFIGURATION_ARN).build(); + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/ExceptionTranslatorTest.java b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/ExceptionTranslatorTest.java new file mode 100644 index 0000000..b6ed7a3 --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/ExceptionTranslatorTest.java @@ -0,0 +1,92 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.model.BadRequestException; +import software.amazon.awssdk.services.kafkaconnect.model.ConflictException; +import software.amazon.awssdk.services.kafkaconnect.model.NotFoundException; +import software.amazon.awssdk.services.kafkaconnect.model.UnauthorizedException; +import software.amazon.awssdk.services.kafkaconnect.model.InternalServerErrorException; + +import software.amazon.cloudformation.exceptions.*; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +public class ExceptionTranslatorTest { + private static final String TEST_IDENTIFIER = "worker-configuration-test-name"; + private static final String TEST_MESSAGE = "test-message"; + private final ExceptionTranslator exceptionTranslator = new ExceptionTranslator(); + + @Test + public void translateToCfnException_NotFoundException_MapsToCfnNotFoundException() { + final NotFoundException exception = NotFoundException.builder() + .message(TEST_MESSAGE) + .build(); + + runTranslateToCfnExceptionAndVerifyOutput(exception, CfnNotFoundException.class, + "Resource of type 'AWS::KafkaConnect::WorkerConfiguration' with identifier 'worker-configuration-test-name' was not found."); + } + + @Test + public void translateToCfnException_BadRequestException_MapsToCfnInvalidRequestException() { + final BadRequestException exception = BadRequestException.builder() + .message(TEST_MESSAGE) + .build(); + + runTranslateToCfnExceptionAndVerifyOutput(exception, CfnInvalidRequestException.class, + "Invalid request provided: " + TEST_MESSAGE); + } + + @Test + public void translateToCfnException_ConflictException_MapsToCfnAlreadyExistsException() { + final ConflictException exception = ConflictException.builder() + .message(TEST_MESSAGE) + .build(); + + runTranslateToCfnExceptionAndVerifyOutput(exception, CfnAlreadyExistsException.class, + "Resource of type 'AWS::KafkaConnect::WorkerConfiguration' with identifier 'worker-configuration-test-name' " + + + "already exists."); + } + + @Test + public void translateToCfnException_InternalServerErrorException_MapsToCfnInternalFailureException() { + final InternalServerErrorException exception = InternalServerErrorException.builder() + .message(TEST_MESSAGE) + .build(); + + runTranslateToCfnExceptionAndVerifyOutput(exception, CfnInternalFailureException.class, + "Internal error occurred."); + } + + @Test + public void translateToCfnException_UnauthorizedException_MapsToCfnAccessDeniedException() { + final UnauthorizedException exception = UnauthorizedException.builder() + .message(TEST_MESSAGE) + .build(); + + runTranslateToCfnExceptionAndVerifyOutput(exception, CfnAccessDeniedException.class, + "Access denied for operation 'AWS::KafkaConnect::WorkerConfiguration'."); + } + + @Test + public void translateToCfnException_Other_MapsToCfnGeneralServiceException() { + final AwsServiceException exception = AwsServiceException.builder() + .message(TEST_MESSAGE) + .build(); + + runTranslateToCfnExceptionAndVerifyOutput(exception, CfnGeneralServiceException.class, TEST_MESSAGE); + } + + private void runTranslateToCfnExceptionAndVerifyOutput(final AwsServiceException exception, + final Class expectedExceptionClass, final String expectedMessage) { + + final BaseHandlerException result = exceptionTranslator.translateToCfnException(exception, TEST_IDENTIFIER); + + assertThat(result.getClass()).isEqualTo(expectedExceptionClass); + assertThat(result.getMessage()).isEqualTo(expectedMessage); + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/ListHandlerTest.java b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/ListHandlerTest.java new file mode 100644 index 0000000..3168e1d --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/ListHandlerTest.java @@ -0,0 +1,206 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import org.junit.jupiter.api.AfterEach; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.ListWorkerConfigurationsRequest; +import software.amazon.awssdk.services.kafkaconnect.model.ListWorkerConfigurationsResponse; +import software.amazon.awssdk.services.kafkaconnect.model.WorkerConfigurationSummary; +import software.amazon.cloudformation.exceptions.CfnGeneralServiceException; +import software.amazon.cloudformation.proxy.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class ListHandlerTest extends AbstractTestBase { + + @Mock + private KafkaConnectClient kafkaConnectClient; + + @Mock + private ExceptionTranslator exceptionTranslator; + + @Mock + private Translator translator; + + private AmazonWebServicesClientProxy proxy; + + private ProxyClient proxyClient; + + private ListHandler handler; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, + () -> Duration.ofSeconds(600).toMillis()); + proxyClient = proxyStub(proxy, kafkaConnectClient); + handler = new ListHandler(exceptionTranslator, translator); + } + + @AfterEach + public void tear_down() { + verifyNoMoreInteractions(kafkaConnectClient, exceptionTranslator, translator); + } + + @Test + public void handleRequest_success() { + when(translator.translateToListRequest(null)) + .thenReturn(TestData.LIST_WORKER_CONFIGURATIONS_REQUEST_NEXT_TOKEN_1); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.LIST_WORKER_CONFIGURATIONS_REQUEST_NEXT_TOKEN_1, + kafkaConnectClient::listWorkerConfigurations)).thenReturn(TestData.LIST_WORKER_CONFIGURATIONS_RESPONSE); + when(translator.translateFromListResponse(TestData.LIST_WORKER_CONFIGURATIONS_RESPONSE)) + .thenReturn(TestData.WORKER_CONFIGURATIONS_MODELS); + + final ProgressEvent response = handler + .handleRequest(proxy, TestData.RESOURCE_HANDLER_REQUEST, new CallbackContext(), proxyClient, logger); + + assertThat(response).isEqualTo(TestData.SUCCESS_RESPONSE); + } + + @Test + public void handleRequest_success_null_next_token() { + when(translator.translateToListRequest(TestData.NEXT_TOKEN)) + .thenReturn(TestData.LIST_WORKER_CONFIGURATIONS_REQUEST_NEXT_TOKEN_2); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.LIST_WORKER_CONFIGURATIONS_REQUEST_NEXT_TOKEN_2, + kafkaConnectClient::listWorkerConfigurations)) + .thenReturn(TestData.LIST_WORKER_CONFIGURATIONS_RESPONSE_NULL_NEXT_TOKEN); + when(translator.translateFromListResponse(TestData.LIST_WORKER_CONFIGURATIONS_RESPONSE_NULL_NEXT_TOKEN)) + .thenReturn(TestData.WORKER_CONFIGURATIONS_MODELS_2); + + final ProgressEvent response = handler + .handleRequest(proxy, TestData.RESOURCE_HANDLER_REQUEST_2, new CallbackContext(), proxyClient, logger); + + assertThat(response).isEqualTo(TestData.SUCCESS_RESPONSE_NULL_NEXT_TOKEN); + } + + @Test + public void handleRequest_throwsException_whenListWorkerConfigurationsFails() { + final AwsServiceException serviceException = AwsServiceException.builder().build(); + final CfnGeneralServiceException cfnException = new CfnGeneralServiceException(serviceException); + when(translator.translateToListRequest(null)) + .thenReturn(TestData.LIST_WORKER_CONFIGURATIONS_REQUEST_NEXT_TOKEN_1); + when(proxyClient + .injectCredentialsAndInvokeV2(TestData.LIST_WORKER_CONFIGURATIONS_REQUEST_NEXT_TOKEN_1, + kafkaConnectClient::listWorkerConfigurations)) + .thenThrow(serviceException); + when(exceptionTranslator.translateToCfnException(serviceException, TestData.AWS_ACCOUNT_ID)) + .thenReturn(cfnException); + + final CfnGeneralServiceException exception = + assertThrows( + CfnGeneralServiceException.class, () -> handler.handleRequest( + proxy, TestData.RESOURCE_HANDLER_REQUEST, new CallbackContext(), proxyClient, logger)); + + assertThat(exception).isEqualTo(cfnException); + } + + private static class TestData { + private static final String NEXT_TOKEN = "1234abcd"; + private static final String AWS_ACCOUNT_ID = "1111111111"; + private static final String WORKER_CONFIGURATION_ARN_1 = + "arn:aws:kafkaconnect:us-east-1:1111111111:worker-configuration/unit-test-worker-configuration-1"; + private static final String WORKER_CONFIGURATION_ARN_2 = + "arn:aws:kafkaconnect:us-east-1:1111111111:worker-configuration/unit-test-worker-configuration-2"; + private static final String WORKER_CONFIGURATION_ARN_3 = + "arn:aws:kafkaconnect:us-east-1:1111111111:worker-configuration/unit-test-worker-configuration-3"; + private static final String WORKER_CONFIGURATION_ARN_4 = + "arn:aws:kafkaconnect:us-east-1:1111111111:worker-configuration/unit-test-worker-configuration-4"; + private static final ListWorkerConfigurationsRequest LIST_WORKER_CONFIGURATIONS_REQUEST_NEXT_TOKEN_1 = + ListWorkerConfigurationsRequest + .builder() + .nextToken(null) + .build(); + + private static final ListWorkerConfigurationsRequest LIST_WORKER_CONFIGURATIONS_REQUEST_NEXT_TOKEN_2 = + ListWorkerConfigurationsRequest + .builder() + .nextToken(NEXT_TOKEN) + .build(); + private static final ResourceModel MODEL = ResourceModel.builder().build(); + private static final ResourceHandlerRequest RESOURCE_HANDLER_REQUEST = + ResourceHandlerRequest.builder() + .desiredResourceState(MODEL) + .nextToken(null) + .awsAccountId(AWS_ACCOUNT_ID) + .build(); + + private static final ResourceHandlerRequest RESOURCE_HANDLER_REQUEST_2 = + ResourceHandlerRequest.builder() + .desiredResourceState(MODEL) + .nextToken(NEXT_TOKEN) + .awsAccountId(AWS_ACCOUNT_ID) + .build(); + + public static final List WORKER_CONFIGURATIONS_MODELS = Arrays.asList( + testResourceModel(WORKER_CONFIGURATION_ARN_1), + testResourceModel(WORKER_CONFIGURATION_ARN_2)); + + private static final List WORKER_CONFIGURATION_SUMMARIES = Arrays.asList( + testWorkerConfigurationSummary(WORKER_CONFIGURATION_ARN_1), + testWorkerConfigurationSummary(WORKER_CONFIGURATION_ARN_2)); + + public static final List WORKER_CONFIGURATIONS_MODELS_2 = Arrays.asList( + testResourceModel(WORKER_CONFIGURATION_ARN_3), + testResourceModel(WORKER_CONFIGURATION_ARN_4)); + + private static final List WORKER_CONFIGURATION_SUMMARIES_2 = Arrays.asList( + testWorkerConfigurationSummary(WORKER_CONFIGURATION_ARN_3), + testWorkerConfigurationSummary(WORKER_CONFIGURATION_ARN_4)); + + private static final ProgressEvent SUCCESS_RESPONSE = + ProgressEvent.builder() + .resourceModels(WORKER_CONFIGURATIONS_MODELS) + .nextToken(NEXT_TOKEN) + .status(OperationStatus.SUCCESS) + .build(); + + private static final ProgressEvent SUCCESS_RESPONSE_NULL_NEXT_TOKEN = + ProgressEvent.builder() + .resourceModels(WORKER_CONFIGURATIONS_MODELS_2) + .nextToken(null) + .status(OperationStatus.SUCCESS) + .build(); + + private static final ListWorkerConfigurationsResponse LIST_WORKER_CONFIGURATIONS_RESPONSE = + ListWorkerConfigurationsResponse + .builder() + .nextToken(TestData.NEXT_TOKEN) + .workerConfigurations(TestData.WORKER_CONFIGURATION_SUMMARIES) + .build(); + + private static final ListWorkerConfigurationsResponse LIST_WORKER_CONFIGURATIONS_RESPONSE_NULL_NEXT_TOKEN = + ListWorkerConfigurationsResponse + .builder() + .nextToken(null) + .workerConfigurations(TestData.WORKER_CONFIGURATION_SUMMARIES_2) + .build(); + + private static WorkerConfigurationSummary testWorkerConfigurationSummary(final String arn) { + return WorkerConfigurationSummary + .builder() + .workerConfigurationArn(arn) + .build(); + } + + private static ResourceModel testResourceModel(final String arn) { + return ResourceModel + .builder() + .workerConfigurationArn(arn) + .build(); + } + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/ReadHandlerTest.java b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/ReadHandlerTest.java new file mode 100644 index 0000000..879e88b --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/ReadHandlerTest.java @@ -0,0 +1,204 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import java.time.Duration; +import java.util.HashMap; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.NotFoundException; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationResponse; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceResponse; +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class ReadHandlerTest extends AbstractTestBase { + + @Mock + private KafkaConnectClient kafkaConnectClient; + + @Mock + private ExceptionTranslator exceptionTranslator; + + @Mock + private Translator translator; + + private AmazonWebServicesClientProxy proxy; + + private ProxyClient proxyClient; + + private ReadHandler handler; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, + () -> Duration.ofSeconds(600).toMillis()); + proxyClient = proxyStub(proxy, kafkaConnectClient); + handler = new ReadHandler(exceptionTranslator, translator); + } + + @AfterEach + public void tear_down() { + verify(kafkaConnectClient, atLeastOnce()).serviceName(); + verifyNoMoreInteractions(kafkaConnectClient, exceptionTranslator, translator); + } + + @Test + public void handleRequest_returnsCWorkerConfigurationWhenResourceModelIsPassedAndNonEmptyTags_success() { + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, + kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_RESPONSE); + when(translator.translateFromReadResponse(TestData.DESCRIBE_WORKER_CONFIGURATION_RESPONSE)) + .thenReturn(TestData.RESPONSE_RESOURCE_MODEL); + ; + when(proxyClient.injectCredentialsAndInvokeV2(TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, + kafkaConnectClient::listTagsForResource)).thenReturn(TestData.LIST_TAGS_FOR_RESOURCE_RESPONSE); + + final ProgressEvent response = handler.handleRequest(proxy, + TestData.RESOURCE_HANDLER_REQUEST, new CallbackContext(), proxyClient, logger); + + assertThat(response).isEqualTo(TestData.EXPECTED_RESPONSE); + } + + @Test + + public void handleRequest_returnsCWorkerConfigurationWhenResourceModelIsPassedAndEmptyTags_success() { + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, + kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_RESPONSE); + when(translator.translateFromReadResponse(TestData.DESCRIBE_WORKER_CONFIGURATION_RESPONSE)) + .thenReturn(TestData.RESPONSE_RESOURCE_MODEL); + ; + when(proxyClient.injectCredentialsAndInvokeV2(TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, + kafkaConnectClient::listTagsForResource)).thenReturn(TestData.LIST_TAGS_FOR_RESOURCE_EMPTY_RESPONSE); + + final ProgressEvent response = handler.handleRequest(proxy, + TestData.RESOURCE_HANDLER_REQUEST, new CallbackContext(), proxyClient, logger); + + assertThat(response).isEqualTo(TestData.EXPECTED_RESPONSE_EMPTY_TAGS); + } + + @Test + public void handleRequest_throwsCfnNotFoundException_whenDescribeWorkerConfigurationFails() { + final NotFoundException serviceException = NotFoundException.builder().build(); + final CfnNotFoundException cfnException = new CfnNotFoundException(serviceException); + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, + kafkaConnectClient::describeWorkerConfiguration)).thenThrow(serviceException); + when(exceptionTranslator.translateToCfnException(serviceException, TestData.WORKER_CONFIGURATION_ARN)) + .thenReturn(cfnException); + + final CfnNotFoundException exception = assertThrows(CfnNotFoundException.class, () -> handler + .handleRequest(proxy, TestData.RESOURCE_HANDLER_REQUEST, new CallbackContext(), proxyClient, logger)); + + assertThat(exception).isEqualTo(cfnException); + } + + @Test + public void handleRequest_throwsCfnNotFoundException_whenListTagsForResourceFails() { + final NotFoundException serviceException = NotFoundException.builder().build(); + final CfnNotFoundException cfnException = new CfnNotFoundException(serviceException); + + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, + kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_RESPONSE); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, + kafkaConnectClient::listTagsForResource)).thenThrow(serviceException); + when(exceptionTranslator.translateToCfnException(serviceException, TestData.WORKER_CONFIGURATION_ARN)) + .thenReturn(cfnException); + + final CfnNotFoundException exception = assertThrows(CfnNotFoundException.class, () -> handler + .handleRequest(proxy, TestData.RESOURCE_HANDLER_REQUEST, new CallbackContext(), proxyClient, logger)); + + assertThat(exception).isEqualTo(cfnException); + } + + private static class TestData { + private static final String WORKER_CONFIGURATION_NAME = "unit-test-worker-configuration"; + private static final String WORKER_CONFIGURATION_ARN = + "arn:aws:kafkaconnect:us-east-1:1111111111:worker-configuration/unit-test-worker-configuration"; + private static final String EXCEPTION_MESSAGE = "Exception message"; + + private static final ResourceModel RESPONSE_RESOURCE_MODEL = ResourceModel + .builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN) + .name(WORKER_CONFIGURATION_NAME) + .build(); + + private static final ResourceModel RESOURCE_MODEL = ResourceModel + .builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN) + .build(); + + private static final ResourceHandlerRequest RESOURCE_HANDLER_REQUEST = + ResourceHandlerRequest.builder() + .desiredResourceState(RESOURCE_MODEL) + .build(); + + private static final DescribeWorkerConfigurationRequest DESCRIBE_WORKER_CONFIGURATION_REQUEST = + DescribeWorkerConfigurationRequest.builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN) + .build(); + + private static final ListTagsForResourceRequest LIST_TAGS_FOR_RESOURCE_REQUEST = + ListTagsForResourceRequest.builder() + .resourceArn(WORKER_CONFIGURATION_ARN) + .build(); + + private static final DescribeWorkerConfigurationResponse DESCRIBE_WORKER_CONFIGURATION_RESPONSE = + DescribeWorkerConfigurationResponse + .builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN) + .name(WORKER_CONFIGURATION_NAME) + .build(); + + private static final ListTagsForResourceResponse LIST_TAGS_FOR_RESOURCE_RESPONSE = + ListTagsForResourceResponse + .builder() + .tags(TAGS) + .build(); + + private static final ListTagsForResourceResponse LIST_TAGS_FOR_RESOURCE_EMPTY_RESPONSE = + ListTagsForResourceResponse + .builder() + .tags(new HashMap<>()) + .build(); + private static final ProgressEvent EXPECTED_RESPONSE = + ProgressEvent.builder() + .status(OperationStatus.SUCCESS) + .resourceModel(RESPONSE_RESOURCE_MODEL.toBuilder().tags(TagHelper.convertToSet(TAGS)).build()) + .build(); + + private static final ProgressEvent EXPECTED_RESPONSE_EMPTY_TAGS = + ProgressEvent.builder() + .status(OperationStatus.SUCCESS) + .resourceModel(RESPONSE_RESOURCE_MODEL) + .build(); + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/TranslatorTest.java b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/TranslatorTest.java new file mode 100644 index 0000000..0d151f8 --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/TranslatorTest.java @@ -0,0 +1,232 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.kafkaconnect.model.CreateWorkerConfigurationRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationResponse; +import software.amazon.awssdk.services.kafkaconnect.model.ListWorkerConfigurationsRequest; +import software.amazon.awssdk.services.kafkaconnect.model.ListWorkerConfigurationsResponse; +import software.amazon.awssdk.services.kafkaconnect.model.WorkerConfigurationSummary; +import software.amazon.awssdk.services.kafkaconnect.model.WorkerConfigurationRevisionSummary; +import software.amazon.awssdk.services.kafkaconnect.model.WorkerConfigurationRevisionDescription; +import software.amazon.awssdk.services.kafkaconnect.model.TagResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.UntagResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DeleteWorkerConfigurationRequest; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.*; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +public class TranslatorTest extends AbstractTestBase { + + private final Translator translator = new Translator(); + + @Test + public void translateToCreateRequest_success() { + compareCreateRequest( + translator.translateToCreateRequest(TestData.RESOURCE_CREATE_MODEL, + TagHelper.convertToMap(TestData.RESOURCE_CREATE_MODEL.getTags())), + TestData.CREATE_WORKER_CONFIGURATION_REQUEST); + } + + @Test + public void translateToReadRequest_success() { + assertThat(translator.translateToReadRequest(TestData.READ_REQUEST_RESOURCE_MODEL)) + .isEqualTo(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + } + + @Test + public void translateFromReadResponse_success() { + assertThat(translator.translateFromReadResponse(TestData.DESCRIBE_WORKER_CONFIGURATION_RESPONSE)) + .isEqualTo(TestData.READ_RESPONSE_DESCRIBE_MODEL); + } + + @Test + public void translateToListRequest_success() { + assertThat(translator.translateToListRequest(TestData.NEXT_TOKEN)) + .isEqualTo(TestData.LIST_WORKER_CONFIGURATIONS_REQUEST); + } + + @Test + public void translateFromListResponse_success() { + assertThat(translator.translateFromListResponse(TestData.LIST_WORKER_CONFIGURATIONS_RESPONSE)) + .isEqualTo(TestData.LIST_WORKER_CONFIGURATION_MODELS); + } + + @Test + public void translateToTagResourceRequest_success() { + assertThat(Translator.tagResourceRequest(TestData.TAG_RESOURCE_REQUEST_RESOURCE_MODEL, TAGS)) + .isEqualTo(TestData.TAG_RESOURCE_REQUEST); + } + + @Test + public void translateToUntagResourceRequest_success() { + Set tagsToUntag = new HashSet<>(); + tagsToUntag.add(TestData.TAG_KEY); + assertThat(Translator.untagResourceRequest(TestData.UNTAG_RESOURCE_REQUEST_RESOURCE_MODEL, tagsToUntag)) + .isEqualTo(TestData.UNTAG_RESOURCE_REQUEST); + } + + @Test + public void translateToDeleteRequest_success() { + assertThat(translator.translateToDeleteRequest(TestData.DELETE_REQUEST_RESOURCE_MODEL)) + .isEqualTo(TestData.DELETE_WORKER_CONFIGURATION_REQUEST); + } + + private void compareCreateRequest(final CreateWorkerConfigurationRequest request1, + final CreateWorkerConfigurationRequest request2) { + // compare all fields without arrays + assertThat(request1.name()).isEqualTo(request2.name()); + assertThat(request1.description()).isEqualTo(request2.description()); + assertThat(request1.propertiesFileContent()).isEqualTo(request2.propertiesFileContent()); + } + + private static class TestData { + + private static final String WORKER_CONFIGURATION_NAME_1 = "unit-test-worker-configuration-1"; + + private static final String WORKER_CONFIGURATION_NAME_2 = "unit-test-worker-configuration-2"; + + private static final String WORKER_CONFIGURATION_DESCRIPTION = "Unit testing worker configuration description"; + + private static final long WORKER_CONFIGURATION_REVISION = 1L; + + private static final String WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT = "propertiesFileContent"; + + private static final String WORKER_CONFIGURATION_ARN_1 = + "arn:aws:kafkaconnect:us-east-1:1111111111:worker-configuration/unit-test-worker-configuration-1"; + + private static final String WORKER_CONFIGURATION_ARN_2 = + "arn:aws:kafkaconnect:us-east-1:1111111111:worker-configuration/unit-test-worker-configuration-2"; + + private static final Instant WORKER_CONFIGURATION_CREATION_TIME = + OffsetDateTime.parse("2021-03-04T14:03:40.818Z").toInstant(); + + private static final Instant WORKER_CONFIGURATION_REVISION_CREATION_TIME = + OffsetDateTime.parse("2021-03-04T14:03:40.818Z").toInstant(); + + private static final String NEXT_TOKEN = "1234abcd"; + + private static final String TAG_KEY = "key"; + + private static final ResourceModel READ_REQUEST_RESOURCE_MODEL = + ResourceModel.builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN_1) + .build(); + + private static final ResourceModel DELETE_REQUEST_RESOURCE_MODEL = + ResourceModel.builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN_1) + .build(); + + private static final ResourceModel TAG_RESOURCE_REQUEST_RESOURCE_MODEL = + ResourceModel.builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN_1) + .build(); + + private static final ResourceModel UNTAG_RESOURCE_REQUEST_RESOURCE_MODEL = + ResourceModel.builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN_1) + .build(); + + private static final ResourceModel READ_RESPONSE_DESCRIBE_MODEL = + ResourceModel.builder().name(WORKER_CONFIGURATION_NAME_1).workerConfigurationArn(WORKER_CONFIGURATION_ARN_1) + .description(WORKER_CONFIGURATION_DESCRIPTION) + .propertiesFileContent(workerConfigurationRevisionDescription().propertiesFileContent()) + .revision(workerConfigurationRevisionDescription().revision()).build(); + + private static final DescribeWorkerConfigurationRequest DESCRIBE_WORKER_CONFIGURATION_REQUEST = + DescribeWorkerConfigurationRequest.builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN_1) + .build(); + + private static final DescribeWorkerConfigurationResponse DESCRIBE_WORKER_CONFIGURATION_RESPONSE = + DescribeWorkerConfigurationResponse + .builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN_1) + .creationTime(WORKER_CONFIGURATION_CREATION_TIME) + .name(WORKER_CONFIGURATION_NAME_1) + .description(WORKER_CONFIGURATION_DESCRIPTION) + .latestRevision(workerConfigurationRevisionDescription()) + .build(); + + private static final ListWorkerConfigurationsRequest LIST_WORKER_CONFIGURATIONS_REQUEST = + ListWorkerConfigurationsRequest.builder() + .nextToken(NEXT_TOKEN) + .build(); + + private static final List LIST_WORKER_CONFIGURATION_MODELS = asList( + ResourceModel.builder().workerConfigurationArn(WORKER_CONFIGURATION_ARN_1).build(), + ResourceModel.builder().workerConfigurationArn(WORKER_CONFIGURATION_ARN_2).build()); + + private static final ListWorkerConfigurationsResponse LIST_WORKER_CONFIGURATIONS_RESPONSE = + ListWorkerConfigurationsResponse.builder() + .workerConfigurations( + asList(fullWorkerConfigurationSummary(WORKER_CONFIGURATION_NAME_1, WORKER_CONFIGURATION_ARN_1), + fullWorkerConfigurationSummary(WORKER_CONFIGURATION_NAME_2, WORKER_CONFIGURATION_ARN_2))) + .build(); + + private static WorkerConfigurationSummary fullWorkerConfigurationSummary(final String name, final String arn) { + return WorkerConfigurationSummary.builder() + .workerConfigurationArn(arn) + .name(name) + .creationTime(WORKER_CONFIGURATION_CREATION_TIME) + .description(WORKER_CONFIGURATION_DESCRIPTION) + .latestRevision(workerConfigurationRevisionSummary()) + .build(); + } + + private static WorkerConfigurationRevisionSummary workerConfigurationRevisionSummary() { + return WorkerConfigurationRevisionSummary.builder() + .revision(WORKER_CONFIGURATION_REVISION) + .description(WORKER_CONFIGURATION_DESCRIPTION) + .creationTime(WORKER_CONFIGURATION_REVISION_CREATION_TIME) + .build(); + } + + private static WorkerConfigurationRevisionDescription workerConfigurationRevisionDescription() { + return WorkerConfigurationRevisionDescription.builder() + .revision(WORKER_CONFIGURATION_REVISION) + .description(WORKER_CONFIGURATION_DESCRIPTION) + .propertiesFileContent(WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT) + .build(); + } + + private static final ResourceModel RESOURCE_CREATE_MODEL = + ResourceModel.builder() + .name(WORKER_CONFIGURATION_NAME_1) + .description(WORKER_CONFIGURATION_DESCRIPTION) + .propertiesFileContent(WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT) + .build(); + + private static final CreateWorkerConfigurationRequest CREATE_WORKER_CONFIGURATION_REQUEST = + CreateWorkerConfigurationRequest.builder() + .name(WORKER_CONFIGURATION_NAME_1) + .description(WORKER_CONFIGURATION_DESCRIPTION) + .propertiesFileContent(WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT) + .build(); + + private static final TagResourceRequest TAG_RESOURCE_REQUEST = + TagResourceRequest.builder() + .tags(TAGS) + .resourceArn(WORKER_CONFIGURATION_ARN_1) + .build(); + + private static final UntagResourceRequest UNTAG_RESOURCE_REQUEST = + UntagResourceRequest.builder() + .tagKeys(TAG_KEY) + .resourceArn(WORKER_CONFIGURATION_ARN_1) + .build(); + + private static final DeleteWorkerConfigurationRequest DELETE_WORKER_CONFIGURATION_REQUEST = + DeleteWorkerConfigurationRequest.builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN_1) + .build(); + } +} diff --git a/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/UpdateHandlerTest.java b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/UpdateHandlerTest.java new file mode 100644 index 0000000..e266ae8 --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/src/test/java/software/amazon/kafkaconnect/workerconfiguration/UpdateHandlerTest.java @@ -0,0 +1,638 @@ +package software.amazon.kafkaconnect.workerconfiguration; + +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.*; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.junit.jupiter.api.AfterEach; +import software.amazon.awssdk.services.kafkaconnect.KafkaConnectClient; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationRequest; +import software.amazon.awssdk.services.kafkaconnect.model.DescribeWorkerConfigurationResponse; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceResponse; +import software.amazon.awssdk.services.kafkaconnect.model.ListTagsForResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.NotFoundException; +import software.amazon.awssdk.services.kafkaconnect.model.TagResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.TagResourceResponse; +import software.amazon.awssdk.services.kafkaconnect.model.UntagResourceRequest; +import software.amazon.awssdk.services.kafkaconnect.model.UntagResourceResponse; +import software.amazon.awssdk.services.kafkaconnect.model.WorkerConfigurationRevisionDescription; + +import software.amazon.cloudformation.exceptions.CfnNotFoundException; +import software.amazon.cloudformation.exceptions.CfnNotUpdatableException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest.ResourceHandlerRequestBuilder; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +public class UpdateHandlerTest extends AbstractTestBase { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private ProxyClient proxyClient; + + @Mock + private KafkaConnectClient kafkaConnectClient; + + @Mock + private ExceptionTranslator exceptionTranslator; + + @Mock + private Translator translator; + + private ReadHandler readHandler; + + private UpdateHandler handler; + + @BeforeEach + public void setup() { + proxy = new AmazonWebServicesClientProxy(logger, MOCK_CREDENTIALS, + () -> Duration.ofSeconds(600).toMillis()); + proxyClient = proxyStub(proxy, kafkaConnectClient); + readHandler = new ReadHandler(exceptionTranslator, translator); + handler = new UpdateHandler(exceptionTranslator, translator, readHandler); + } + + @AfterEach + public void tear_down() { + verify(kafkaConnectClient, atLeastOnce()).serviceName(); + verifyNoMoreInteractions(kafkaConnectClient, exceptionTranslator, translator); + } + + @Test + public void handleRequest_SimpleSuccess() { + final ResourceModel model = TestData.RESOURCE_MODEL.toBuilder().build(); + + final ResourceHandlerRequest request = + TestData.createResourceHandlerRequestWithoutSystemTags(Objects.requireNonNull(model), + Objects.requireNonNull(model)); + final DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse = + TestData.describeResponse(); + + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(describeWorkerConfigurationResponse); + when(translator.translateFromReadResponse(describeWorkerConfigurationResponse)) + .thenReturn(model); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.LIST_TAGS_FOR_RESOURCE_REQUEST, + kafkaConnectClient::listTagsForResource)).thenReturn(TestData.LIST_TAGS_FOR_RESOURCE_RESPONSE); + + final ProgressEvent response = + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel().getWorkerConfigurationArn()) + .isEqualTo(TestData.WORKER_CONFIGURATION_ARN); + assertThat(response.getResourceModel().getName()).isEqualTo(TestData.WORKER_CONFIGURATION_NAME); + assertThat(response.getResourceModel().getDescription()).isEqualTo(TestData.WORKER_CONFIGURATION_DESCRIPTION); + assertThat(response.getResourceModel().getRevision()).isEqualTo(TestData.WORKER_CONFIGURATION_REVISION); + assertThat(response.getResourceModel().getPropertiesFileContent()) + .isEqualTo(TestData.WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + verify(proxyClient.client(), times(2)) + .describeWorkerConfiguration(any(DescribeWorkerConfigurationRequest.class)); + verify(proxyClient.client(), times(1)).listTagsForResource(any(ListTagsForResourceRequest.class)); + } + + @Test + public void handleRequest_FailsWith_CfnNotUpdatableException_NameChange() { + final ResourceModel model = ResourceModel.builder() + .workerConfigurationArn(TestData.WORKER_CONFIGURATION_ARN) + .name(TestData.WORKER_CONFIGURATION_NAME_1) + .description(TestData.WORKER_CONFIGURATION_DESCRIPTION) + .revision(TestData.WORKER_CONFIGURATION_REVISION) + .propertiesFileContent(TestData.WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT) + .build(); + + final ResourceHandlerRequest request = + TestData.createResourceHandlerRequest(Objects.requireNonNull(TestData.RESOURCE_MODEL), model); + + final DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse = + TestData.describeResponse(); + + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(describeWorkerConfigurationResponse); + + assertThrows(CfnNotUpdatableException.class, () -> { + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + }); + } + + @Test + public void handleRequest_FailsWith_CfnNotUpdatableException_DescriptionChange() { + final ResourceModel model = ResourceModel.builder() + .workerConfigurationArn(TestData.WORKER_CONFIGURATION_ARN) + .name(TestData.WORKER_CONFIGURATION_NAME) + .description(TestData.WORKER_CONFIGURATION_DESCRIPTION_1) + .revision(TestData.WORKER_CONFIGURATION_REVISION) + .propertiesFileContent(TestData.WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT) + .build(); + + final ResourceHandlerRequest request = + TestData.createResourceHandlerRequest(Objects.requireNonNull(TestData.RESOURCE_MODEL), model); + + final DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse = + TestData.describeResponse(); + + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(describeWorkerConfigurationResponse); + + assertThrows(CfnNotUpdatableException.class, () -> { + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + }); + } + + @Test + public void handleRequest_FailsWith_CfnNotUpdatableException_PropertiesFileContentChange() { + final ResourceModel model = ResourceModel.builder() + .workerConfigurationArn(TestData.WORKER_CONFIGURATION_ARN) + .name(TestData.WORKER_CONFIGURATION_NAME) + .description(TestData.WORKER_CONFIGURATION_DESCRIPTION) + .revision(TestData.WORKER_CONFIGURATION_REVISION) + .propertiesFileContent(TestData.WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT_1) + .build(); + + final ResourceHandlerRequest request = + TestData.createResourceHandlerRequest(Objects.requireNonNull(TestData.RESOURCE_MODEL), model); + + final DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse = + TestData.describeResponse(); + + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(describeWorkerConfigurationResponse); + + assertThrows(CfnNotUpdatableException.class, () -> { + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + }); + } + + @Test + public void handleRequest_FailsWith_CfnNotUpdatableException_RevisionChange() { + final ResourceModel model = ResourceModel.builder() + .workerConfigurationArn(TestData.WORKER_CONFIGURATION_ARN) + .name(TestData.WORKER_CONFIGURATION_NAME) + .description(TestData.WORKER_CONFIGURATION_DESCRIPTION) + .revision(TestData.WORKER_CONFIGURATION_REVISION_1) + .propertiesFileContent(TestData.WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT) + .build(); + + final ResourceHandlerRequest request = + TestData.createResourceHandlerRequest(Objects.requireNonNull(TestData.RESOURCE_MODEL), model); + + final DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse = + TestData.describeResponse(); + + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(describeWorkerConfigurationResponse); + + assertThrows(CfnNotUpdatableException.class, () -> { + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + }); + } + + @Test + public void handleRequest_FailsWith_CfnNotUpdatableException_WorkerConfigurationArnChange() { + final ResourceModel model = ResourceModel.builder() + .workerConfigurationArn(TestData.WORKER_CONFIGURATION_ARN_1) + .name(TestData.WORKER_CONFIGURATION_NAME) + .description(TestData.WORKER_CONFIGURATION_DESCRIPTION) + .revision(TestData.WORKER_CONFIGURATION_REVISION) + .propertiesFileContent(TestData.WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT) + .build(); + + final ResourceHandlerRequest request = + TestData.createResourceHandlerRequest(Objects.requireNonNull(TestData.RESOURCE_MODEL), model); + + final DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse = + TestData.describeResponse(); + + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(describeWorkerConfigurationResponse); + + assertThrows(CfnNotUpdatableException.class, () -> { + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + }); + } + + @Test + public void handleRequest_FailsWith_CfnNotFoundException() { + final NotFoundException serviceException = NotFoundException.builder().build(); + final CfnNotFoundException cfnException = new CfnNotFoundException(serviceException); + + when(translator.translateToReadRequest(TestData.RESOURCE_MODEL)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, + kafkaConnectClient::describeWorkerConfiguration)).thenThrow(serviceException); + when(exceptionTranslator.translateToCfnException(serviceException, TestData.WORKER_CONFIGURATION_ARN)) + .thenReturn(cfnException); + + final ResourceHandlerRequest request = + TestData.createResourceHandlerRequest(Objects.requireNonNull(TestData.RESOURCE_MODEL), null); + + final CfnNotFoundException exception = assertThrows(CfnNotFoundException.class, () -> handler + .handleRequest(proxy, request, new CallbackContext(), proxyClient, logger)); + + assertThat(exception).isEqualTo(cfnException); + } + + @Test + public void handleRequest_addNewTags() { + final Set tagsSet = new HashSet<>(); + tagsSet.add(Tag.builder().key(TestData.WORKER_CONFIGURATION_TAG_KEY) + .value(TestData.WORKER_CONFIGURATION_TAG_VALUE).build()); + + final Map tagsMap = new HashMap<>(); + tagsMap.put(TestData.WORKER_CONFIGURATION_TAG_KEY, TestData.WORKER_CONFIGURATION_TAG_VALUE); + + final ResourceModel model = TestData.RESOURCE_MODEL.toBuilder() + .tags(tagsSet) + .build(); + + final ResourceHandlerRequest request = + TestData.createResourceHandlerRequest(Objects.requireNonNull(model), TestData.RESOURCE_MODEL); + + final DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse = + TestData.describeResponse(); + + when(translator.translateToReadRequest(model)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(describeWorkerConfigurationResponse); + when(translator.translateFromReadResponse(describeWorkerConfigurationResponse)) + .thenReturn(model); + ; + when(proxyClient.client().tagResource(any(TagResourceRequest.class))) + .thenReturn(TagResourceResponse.builder().build()); + when(proxyClient.client().listTagsForResource(any(ListTagsForResourceRequest.class))) + .thenReturn(ListTagsForResourceResponse + .builder() + .tags(tagsMap) + .build()); + + final ProgressEvent response = + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(request.getDesiredResourceState()); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + verify(proxyClient.client(), times(1)).tagResource(any(TagResourceRequest.class)); + verify(proxyClient.client(), never()).untagResource(any(UntagResourceRequest.class)); + verify(proxyClient.client(), times(2)) + .describeWorkerConfiguration(any(DescribeWorkerConfigurationRequest.class)); + verify(proxyClient.client(), times(1)).listTagsForResource(any(ListTagsForResourceRequest.class)); + } + + @Test + public void handleRequest_updateTags() { + final Set tagsSet = new HashSet<>(); + tagsSet.add(Tag.builder().key(TestData.WORKER_CONFIGURATION_TAG_KEY) + .value(TestData.WORKER_CONFIGURATION_TAG_VALUE).build()); + + final Set prevTagsSet = new HashSet<>(); + prevTagsSet.add(Tag.builder().key(TestData.WORKER_CONFIGURATION_TAG_KEY).value("OLD_VALUE").build()); + + final Map tagsMap = new HashMap<>(); + tagsMap.put(TestData.WORKER_CONFIGURATION_TAG_KEY, TestData.WORKER_CONFIGURATION_TAG_VALUE); + + final ResourceModel model = TestData.RESOURCE_MODEL.toBuilder() + .tags(tagsSet) + .build(); + + final ResourceModel prevModel = TestData.RESOURCE_MODEL.toBuilder() + .tags(prevTagsSet) + .build(); + + final ResourceHandlerRequest request = + TestData.createResourceHandlerRequest(Objects.requireNonNull(model), prevModel); + + final DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse = + TestData.describeResponse(); + + when(translator.translateToReadRequest(model)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(describeWorkerConfigurationResponse); + when(translator.translateFromReadResponse(describeWorkerConfigurationResponse)) + .thenReturn(model); + ; + when(proxyClient.client().tagResource(any(TagResourceRequest.class))) + .thenReturn(TagResourceResponse.builder().build()); + when(proxyClient.client().listTagsForResource(any(ListTagsForResourceRequest.class))) + .thenReturn(ListTagsForResourceResponse + .builder() + .tags(tagsMap) + .build()); + + final ProgressEvent response = + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel().getName()).isEqualTo(TestData.WORKER_CONFIGURATION_NAME); + assertThat(response.getResourceModel().getDescription()).isEqualTo(TestData.WORKER_CONFIGURATION_DESCRIPTION); + assertThat(response.getResourceModel().getPropertiesFileContent()) + .isEqualTo(TestData.WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT); + assertThat(response.getResourceModel()).isEqualTo(request.getDesiredResourceState()); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + verify(proxyClient.client(), times(2)) + .describeWorkerConfiguration(any(DescribeWorkerConfigurationRequest.class)); + verify(proxyClient.client(), times(1)).listTagsForResource(any(ListTagsForResourceRequest.class)); + verify(proxyClient.client(), never()).untagResource(any(UntagResourceRequest.class)); + verify(proxyClient.client(), times(1)).tagResource(any(TagResourceRequest.class)); + } + + @Test + public void handleRequest_RemoveTags() { + final Set tagsSet = new HashSet<>(); + tagsSet.add(Tag.builder().key(TestData.WORKER_CONFIGURATION_TAG_KEY) + .value(TestData.WORKER_CONFIGURATION_TAG_VALUE).build()); + + final ResourceModel model = TestData.RESOURCE_MODEL.toBuilder() + .build(); + + final ResourceModel prevModel = TestData.RESOURCE_MODEL.toBuilder() + .tags(tagsSet) + .build(); + + final ResourceHandlerRequest request = + TestData.createResourceHandlerRequest(Objects.requireNonNull(model), prevModel); + + final DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse = + TestData.describeResponse(); + + when(translator.translateToReadRequest(model)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(describeWorkerConfigurationResponse); + when(translator.translateFromReadResponse(describeWorkerConfigurationResponse)) + .thenReturn(model); + ; + when(proxyClient.client().untagResource(any(UntagResourceRequest.class))) + .thenReturn(UntagResourceResponse.builder().build()); + when(proxyClient.client().listTagsForResource(any(ListTagsForResourceRequest.class))) + .thenReturn(ListTagsForResourceResponse + .builder() + .build()); + + final ProgressEvent response = + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel().getName()).isEqualTo(TestData.WORKER_CONFIGURATION_NAME); + assertThat(response.getResourceModel().getDescription()).isEqualTo(TestData.WORKER_CONFIGURATION_DESCRIPTION); + assertThat(response.getResourceModel().getPropertiesFileContent()) + .isEqualTo(TestData.WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT); + assertThat(response.getResourceModel()).isEqualTo(request.getDesiredResourceState()); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + verify(proxyClient.client(), times(2)) + .describeWorkerConfiguration(any(DescribeWorkerConfigurationRequest.class)); + verify(proxyClient.client(), times(1)).listTagsForResource(any(ListTagsForResourceRequest.class)); + verify(proxyClient.client(), times(1)).untagResource(any(UntagResourceRequest.class)); + verify(proxyClient.client(), never()).tagResource(any(TagResourceRequest.class)); + } + + @Test + public void handleRequest_AddRemoveTags() { + final String tagKeyRemove = "TEST_KEY_REMOVE"; + final String tagValueRemove = "TEST_VALUE_REMOVE"; + + final Set tagsSet = new HashSet<>(); + tagsSet.add(Tag.builder().key(TestData.WORKER_CONFIGURATION_TAG_KEY) + .value(TestData.WORKER_CONFIGURATION_TAG_VALUE).build()); + + final Set prevTagsSet = new HashSet<>(); + prevTagsSet.add(Tag.builder().key(tagKeyRemove).value(tagValueRemove).build()); + + final Map tagsMap = new HashMap<>(); + tagsMap.put(TestData.WORKER_CONFIGURATION_TAG_KEY, TestData.WORKER_CONFIGURATION_TAG_VALUE); + + final ResourceModel model = TestData.RESOURCE_MODEL.toBuilder() + .tags(tagsSet) + .build(); + + final ResourceModel prevModel = TestData.RESOURCE_MODEL.toBuilder() + .tags(prevTagsSet) + .build(); + + final ResourceHandlerRequest request = + TestData.createResourceHandlerRequest(Objects.requireNonNull(model), prevModel); + + final DescribeWorkerConfigurationResponse describeWorkerConfigurationResponse = + TestData.describeResponse(); + + when(translator.translateToReadRequest(model)) + .thenReturn(TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST); + when(proxyClient.injectCredentialsAndInvokeV2( + TestData.DESCRIBE_WORKER_CONFIGURATION_REQUEST, kafkaConnectClient::describeWorkerConfiguration)) + .thenReturn(describeWorkerConfigurationResponse); + when(translator.translateFromReadResponse(describeWorkerConfigurationResponse)) + .thenReturn(model); + ; + when(proxyClient.client().tagResource(any(TagResourceRequest.class))) + .thenReturn(TagResourceResponse.builder().build()); + when(proxyClient.client().listTagsForResource(any(ListTagsForResourceRequest.class))) + .thenReturn(ListTagsForResourceResponse + .builder() + .tags(tagsMap) + .build()); + when(proxyClient.client().untagResource(any(UntagResourceRequest.class))) + .thenReturn(UntagResourceResponse.builder().build()); + + final ProgressEvent response = + handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel().getName()).isEqualTo(TestData.WORKER_CONFIGURATION_NAME); + assertThat(response.getResourceModel().getDescription()).isEqualTo(TestData.WORKER_CONFIGURATION_DESCRIPTION); + assertThat(response.getResourceModel().getPropertiesFileContent()) + .isEqualTo(TestData.WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT); + assertThat(response.getResourceModel()).isEqualTo(request.getDesiredResourceState()); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + verify(proxyClient.client(), times(2)) + .describeWorkerConfiguration(any(DescribeWorkerConfigurationRequest.class)); + verify(proxyClient.client(), times(1)).listTagsForResource(any(ListTagsForResourceRequest.class)); + verify(proxyClient.client(), times(1)).untagResource(any(UntagResourceRequest.class)); + verify(proxyClient.client(), times(1)).tagResource(any(TagResourceRequest.class)); + } + + private static class TestData { + private static final String WORKER_CONFIGURATION_NAME = "unit-test-worker-configuration"; + + private static final String WORKER_CONFIGURATION_NAME_1 = "unit-test-worker-configuration-1"; + + private static final String WORKER_CONFIGURATION_DESCRIPTION = "Unit testing worker configuration description"; + + private static final String WORKER_CONFIGURATION_DESCRIPTION_1 = + "Unit testing worker configuration description 1"; + + private static final long WORKER_CONFIGURATION_REVISION = 1L; + + private static final long WORKER_CONFIGURATION_REVISION_1 = 2L; + + private static final String WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT = "propertiesFileContent"; + + private static final String WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT_1 = "propertiesFileContent1"; + + private static final String WORKER_CONFIGURATION_ARN = + "arn:aws:kafkaconnect:us-east-1:1111111111:worker-configuration/unit-test-worker-configuration"; + + private static final String WORKER_CONFIGURATION_ARN_1 = + "arn:aws:kafkaconnect:us-east-1:1111111111:worker-configuration/unit-test-worker-configuration-1"; + private static final Instant WORKER_CONFIGURATION_CREATION_TIME = + OffsetDateTime.parse("2021-03-04T14:03:40.818Z").toInstant(); + private static final String WORKER_CONFIGURATION_TAG_KEY = "unit-test-key"; + private static final String WORKER_CONFIGURATION_TAG_VALUE = "unit-test-value"; + + private static final Map SYSTEM_TAGS = new HashMap() { + { + put("SYSTEM_TAG_TEST1", "SYSTEM_TAG_TEST_VALUE1"); + put("SYSTEM_TAG_TEST2", "SYSTEM_TAG_TEST_VALUE2"); + } + }; + + private static final DescribeWorkerConfigurationRequest DESCRIBE_WORKER_CONFIGURATION_REQUEST = + DescribeWorkerConfigurationRequest.builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN) + .build(); + + private static final ResourceModel RESOURCE_MODEL = ResourceModel.builder() + .workerConfigurationArn(TestData.WORKER_CONFIGURATION_ARN) + .name(TestData.WORKER_CONFIGURATION_NAME) + .description(TestData.WORKER_CONFIGURATION_DESCRIPTION) + .revision(TestData.WORKER_CONFIGURATION_REVISION) + .propertiesFileContent(TestData.WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT) + .build(); + + private static final ListTagsForResourceRequest LIST_TAGS_FOR_RESOURCE_REQUEST = + ListTagsForResourceRequest.builder() + .resourceArn(WORKER_CONFIGURATION_ARN) + .build(); + + private static final ListTagsForResourceResponse LIST_TAGS_FOR_RESOURCE_RESPONSE = + ListTagsForResourceResponse + .builder() + .tags(TAGS) + .build(); + + private static WorkerConfigurationRevisionDescription workerConfigurationRevisionDescription() { + return WorkerConfigurationRevisionDescription.builder() + .revision(WORKER_CONFIGURATION_REVISION) + .description(WORKER_CONFIGURATION_DESCRIPTION) + .propertiesFileContent(WORKER_CONFIGURATION_PROPERTIES_FILE_CONTENT) + .build(); + } + + private static DescribeWorkerConfigurationResponse describeResponse() { + return DescribeWorkerConfigurationResponse + .builder() + .workerConfigurationArn(WORKER_CONFIGURATION_ARN) + .creationTime(WORKER_CONFIGURATION_CREATION_TIME) + .name(WORKER_CONFIGURATION_NAME) + .description(WORKER_CONFIGURATION_DESCRIPTION) + .latestRevision(workerConfigurationRevisionDescription()) + .build(); + } + + private static ResourceHandlerRequest createResourceHandlerRequestWithoutSystemTags( + @Nonnull ResourceModel desiredModel, @Nullable ResourceModel previousModel) { + final ResourceHandlerRequestBuilder requestBuilder = + ResourceHandlerRequest.builder() + .desiredResourceState(desiredModel) + .desiredResourceTags(TagHelper.convertToMap(desiredModel.getTags())); + + if (previousModel != null) { + requestBuilder.previousResourceState(previousModel) + .previousResourceTags(TagHelper.convertToMap(previousModel.getTags())); + } + + return requestBuilder.build(); + } + + private static ResourceHandlerRequest createResourceHandlerRequest( + @Nonnull ResourceModel desiredModel, @Nullable ResourceModel previousModel) { + final ResourceHandlerRequestBuilder requestBuilder = + ResourceHandlerRequest.builder() + .desiredResourceState(desiredModel) + .desiredResourceTags(TagHelper.convertToMap(desiredModel.getTags())) + .systemTags(TestData.SYSTEM_TAGS); + + if (previousModel != null) { + requestBuilder.previousResourceState(previousModel) + .previousResourceTags(TagHelper.convertToMap(previousModel.getTags())) + .previousSystemTags(TestData.SYSTEM_TAGS); + } + + return requestBuilder.build(); + } + } +} diff --git a/aws-kafkaconnect-workerconfiguration/template.yml b/aws-kafkaconnect-workerconfiguration/template.yml new file mode 100644 index 0000000..9ed0feb --- /dev/null +++ b/aws-kafkaconnect-workerconfiguration/template.yml @@ -0,0 +1,23 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: AWS SAM template for the AWS::KafkaConnect::WorkerConfiguration resource type + +Globals: + Function: + Timeout: 180 # docker start-up times can be long for SAM CLI + MemorySize: 256 + +Resources: + TypeFunction: + Type: AWS::Serverless::Function + Properties: + Handler: software.amazon.kafkaconnect.workerconfiguration.HandlerWrapper::handleRequest + Runtime: java17 + CodeUri: ./target/aws-kafkaconnect-workerconfiguration-1.0.jar + + TestEntrypoint: + Type: AWS::Serverless::Function + Properties: + Handler: software.amazon.kafkaconnect.workerconfiguration.HandlerWrapper::testEntrypoint + Runtime: java17 + CodeUri: ./target/aws-kafkaconnect-workerconfiguration-1.0.jar