Repository: getmoto/moto
Pull request #6901 (2023-10-11 10:53:45)

EC2: Add default DHCP options set for VPCs
AWS provides the `AssociateDhcpOptions` operation to associate a DHCP Options Set with a VPC.

There is no `DisassociateDhcpOptions` operation to remove a DHCP Options Set from a VPC. Instead, `DhcpOptionsId` must be set to `default` when calling `AssociateDhcpOptions` to achieve this.

This PR implements this capability.

See: <https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_AssociateDhcpOptions.html#API_AssociateDhcpOptions_RequestParameters>

Closes: https://github.com/localstack/localstack/issues/9222

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/6901?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) Report
> Merging [#6901](https://app.codecov.io/gh/getmoto/moto/pull/6901?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) (571b205) into [master](https://app.codecov.io/gh/getmoto/moto/commit/f39bb45f2c7dc07fc05f26a8520ef36212497ddd?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) (f39bb45) will **decrease** coverage by `0.01%`.
> Report is 1 commits behind head on master.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff             @@
##           master    #6901      +/-   ##
==========================================
- Coverage   95.82%   95.82%   -0.01%     
==========================================
  Files         826      826              
  Lines       80961    80976      +15     
==========================================
+ Hits        77579    77593      +14     
- Misses       3382     3383       +1     
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/6901/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [servertests](https://app.codecov.io/gh/getmoto/moto/pull/6901/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `36.76% <12.50%> (-0.01%)` | :arrow_down: |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/6901/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `95.76% <100.00%> (-0.01%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Files](https://app.codecov.io/gh/getmoto/moto/pull/6901?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [moto/ec2/models/dhcp\_options.py](https://app.codecov.io/gh/getmoto/moto/pull/6901?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#diff-bW90by9lYzIvbW9kZWxzL2RoY3Bfb3B0aW9ucy5weQ==) | `100.00% <100.00%> (ø)` | |
| [moto/ec2/responses/dhcp\_options.py](https://app.codecov.io/gh/getmoto/moto/pull/6901?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#diff-bW90by9lYzIvcmVzcG9uc2VzL2RoY3Bfb3B0aW9ucy5weQ==) | `100.00% <100.00%> (ø)` | |
| [moto/ec2/responses/vpcs.py](https://app.codecov.io/gh/getmoto/moto/pull/6901?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#diff-bW90by9lYzIvcmVzcG9uc2VzL3ZwY3MucHk=) | `98.67% <ø> (ø)` | |

... and [2 files with indirect coverage changes](https://app.codecov.io/gh/getmoto/moto/pull/6901/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto)



Patch:
diff --git a/moto/ec2/models/dhcp_options.py b/moto/ec2/models/dhcp_options.py
index 8f8e2ebe7..397407147 100644
--- a/moto/ec2/models/dhcp_options.py
+++ b/moto/ec2/models/dhcp_options.py
@@ -69,6 +69,11 @@ class DHCPOptionsSetBackend:
         dhcp_options.vpc = vpc
         vpc.dhcp_options = dhcp_options
 
+    def disassociate_dhcp_options(self, vpc: Any) -> None:
+        if vpc.dhcp_options:
+            vpc.dhcp_options.vpc = None
+        vpc.dhcp_options = None
+
     def create_dhcp_options(
         self,
         domain_name_servers: Optional[List[str]] = None,
diff --git a/moto/ec2/responses/dhcp_options.py b/moto/ec2/responses/dhcp_options.py
index 6c3160004..153943eed 100644
--- a/moto/ec2/responses/dhcp_options.py
+++ b/moto/ec2/responses/dhcp_options.py
@@ -6,10 +6,13 @@ class DHCPOptions(EC2BaseResponse):
         dhcp_opt_id = self._get_param("DhcpOptionsId")
         vpc_id = self._get_param("VpcId")
 
-        dhcp_opt = self.ec2_backend.describe_dhcp_options([dhcp_opt_id])[0]
         vpc = self.ec2_backend.get_vpc(vpc_id)
 
-        self.ec2_backend.associate_dhcp_options(dhcp_opt, vpc)
+        if dhcp_opt_id == "default":
+            self.ec2_backend.disassociate_dhcp_options(vpc)
+        else:
+            dhcp_opt = self.ec2_backend.describe_dhcp_options([dhcp_opt_id])[0]
+            self.ec2_backend.associate_dhcp_options(dhcp_opt, vpc)
 
         template = self.response_template(ASSOCIATE_DHCP_OPTIONS_RESPONSE)
         return template.render()
diff --git a/moto/ec2/responses/vpcs.py b/moto/ec2/responses/vpcs.py
index 2f0f2e91f..93a80f90c 100644
--- a/moto/ec2/responses/vpcs.py
+++ b/moto/ec2/responses/vpcs.py
@@ -374,7 +374,7 @@ CREATE_VPC_RESPONSE = """
               {% endfor %}
           </ipv6CidrBlockAssociationSet>
         {% endif %}
-      <dhcpOptionsId>{% if vpc.dhcp_options %}{{ vpc.dhcp_options.id }}{% else %}dopt-1a2b3c4d2{% endif %}</dhcpOptionsId>
+      <dhcpOptionsId>{% if vpc.dhcp_options %}{{ vpc.dhcp_options.id }}{% else %}default{% endif %}</dhcpOptionsId>
       <instanceTenancy>{{ vpc.instance_tenancy }}</instanceTenancy>
       <ownerId> {{ vpc.owner_id }}</ownerId>
       <tagSet>
@@ -475,7 +475,7 @@ DESCRIBE_VPCS_RESPONSE = """
               {% endfor %}
             </ipv6CidrBlockAssociationSet>
         {% endif %}
-        <dhcpOptionsId>{% if vpc.dhcp_options %}{{ vpc.dhcp_options.id }}{% else %}dopt-7a8b9c2d{% endif %}</dhcpOptionsId>
+        <dhcpOptionsId>{% if vpc.dhcp_options %}{{ vpc.dhcp_options.id }}{% else %}default{% endif %}</dhcpOptionsId>
         <instanceTenancy>{{ vpc.instance_tenancy }}</instanceTenancy>
         <isDefault>{{ vpc.is_default }}</isDefault>
         <ownerId> {{ vpc.owner_id }}</ownerId>
diff --git a/moto/elbv2/exceptions.py b/moto/elbv2/exceptions.py
index dd2fa6ee9..469758e7b 100644
--- a/moto/elbv2/exceptions.py
+++ b/moto/elbv2/exceptions.py
@@ -187,3 +187,8 @@ class InvalidLoadBalancerActionException(ELBClientError):
 class ValidationError(ELBClientError):
     def __init__(self, msg: str):
         super().__init__("ValidationError", msg)
+
+
+class InvalidConfigurationRequest(ELBClientError):
+    def __init__(self, msg: str):
+        super().__init__("InvalidConfigurationRequest", msg)
diff --git a/moto/elbv2/models.py b/moto/elbv2/models.py
index 70c83fd7f..11c31ce39 100644
--- a/moto/elbv2/models.py
+++ b/moto/elbv2/models.py
@@ -36,6 +36,7 @@ from .exceptions import (
     InvalidStatusCodeActionTypeError,
     InvalidLoadBalancerActionException,
     ValidationError,
+    InvalidConfigurationRequest,
 )
 
 ALLOWED_ACTIONS = [
@@ -1257,6 +1258,72 @@ Member must satisfy regular expression pattern: {expression}"
         if not target_group:
             raise TargetGroupNotFoundError()
 
+        deregistration_delay_timeout_seconds = attributes.get(
+            "deregistration_delay.timeout_seconds"
+        )
+        if deregistration_delay_timeout_seconds:
+            if int(deregistration_delay_timeout_seconds) not in range(0, 3600):
+                raise ValidationError(
+                    f"'deregistration_delay.timeout_seconds' value '{deregistration_delay_timeout_seconds}' must be between '0-3600' inclusive"
+                )
+            if target_group.target_type == "lambda":
+                raise InvalidConfigurationRequest(
+                    "A target group with target type 'lambda' does not support the attribute deregistration_delay.timeout_seconds"
+                )
+
+        stickiness_type = attributes.get("stickiness.type")
+        # TODO: strict type checking for app_cookie
+        stickiness_cookie_name = attributes.get("stickiness.app_cookie.cookie_name")
+        if stickiness_type:
+            if target_group.protocol == "GENEVE":
+                if stickiness_cookie_name:
+                    # TODO: generalise error message
+                    raise ValidationError(
+                        "Target group attribute key 'stickiness.app_cookie.cookie_name' is not recognized"
+                    )
+                elif stickiness_type not in [
+                    "source_ip_dest_ip_proto",
+                    "source_ip_dest_ip",
+                ]:
+                    raise ValidationError(
+                        f"'{stickiness_type}' must be one of [source_ip_dest_ip_proto, source_ip_dest_ip]"
+                    )
+            if stickiness_type == "source_ip":
+                if target_group.protocol in ["HTTP", "HTTPS"]:
+                    raise InvalidConfigurationRequest(
+                        f"Stickiness type 'source_ip' is not supported for target groups with the {target_group.protocol} protocol"
+                    )
+                elif target_group.protocol == "TLS":
+                    raise InvalidConfigurationRequest(
+                        "You cannot enable stickiness on target groups with the TLS protocol"
+                    )
+            elif stickiness_type == "lb_cookie":
+                if target_group.protocol in ["TCP", "TLS", "UDP", "TCP_UDP"]:
+                    raise InvalidConfigurationRequest(
+                        f"Stickiness type 'lb_cookie' is not supported for target groups with the {target_group.protocol} protocol"
+                    )
+            elif stickiness_type == "app_cookie":
+                if not stickiness_cookie_name:
+                    raise InvalidConfigurationRequest(
+                        "You must set an application cookie name to enable stickiness of type 'app_cookie'"
+                    )
+                if target_group.protocol in ["TCP", "TLS", "UDP", "TCP_UDP", "GENEVE"]:
+                    raise InvalidConfigurationRequest(
+                        f"Stickiness type 'app_cookie' is not supported for target groups with the {target_group.protocol} protocol"
+                    )
+            elif stickiness_type in ["source_ip_dest_ip", "source_ip_dest_ip_proto"]:
+                if target_group.protocol in [
+                    "HTTP",
+                    "HTTPS",
+                    "TCP",
+                    "TLS",
+                    "UDP",
+                    "TCP_UDP",
+                ]:
+                    raise ValidationError(
+                        "'Stickiness type' must be one of [app_cookie, lb_cookie, source_ip]"
+                    )
+
         target_group.attributes.update(attributes)
 
     def convert_and_validate_certificates(
diff --git a/moto/elbv2/responses.py b/moto/elbv2/responses.py
index c65754e0c..2cfc49c7b 100644
--- a/moto/elbv2/responses.py
+++ b/moto/elbv2/responses.py
@@ -844,7 +844,8 @@ CREATE_TARGET_GROUP_TEMPLATE = """<CreateTargetGroupResponse xmlns="http://elast
         <HealthCheckEnabled>{{ target_group.healthcheck_enabled and 'true' or 'false' }}</HealthCheckEnabled>
         {% if target_group.matcher %}
         <Matcher>
-          <HttpCode>{{ target_group.matcher['HttpCode'] }}</HttpCode>
+          {% if target_group.matcher.get("HttpCode") %}<HttpCode>{{ target_group.matcher['HttpCode'] }}</HttpCode>{% endif %}
+          {% if target_group.matcher.get("GrpcCode") %}<GrpcCode>{{ target_group.matcher['GrpcCode'] }}</GrpcCode>{% endif %}
         </Matcher>
         {% endif %}
         {% if target_group.target_type %}
@@ -1098,7 +1099,8 @@ DESCRIBE_TARGET_GROUPS_TEMPLATE = """<DescribeTargetGroupsResponse xmlns="http:/
         <UnhealthyThresholdCount>{{ target_group.unhealthy_threshold_count }}</UnhealthyThresholdCount>
         {% if target_group.matcher %}
         <Matcher>
-          <HttpCode>{{ target_group.matcher['HttpCode'] }}</HttpCode>
+            {% if target_group.matcher.get("HttpCode") %}<HttpCode>{{ target_group.matcher['HttpCode'] }}</HttpCode>{% endif %}
+            {% if target_group.matcher.get("GrpcCode") %}<GrpcCode>{{ target_group.matcher['GrpcCode'] }}</GrpcCode>{% endif %}
         </Matcher>
         {% endif %}
         {% if target_group.target_type %}


Test patch:
diff --git a/tests/terraformtests/bin/run_go_test b/tests/terraformtests/bin/run_go_test
index 19727426b..6593ecb21 100755
--- a/tests/terraformtests/bin/run_go_test
+++ b/tests/terraformtests/bin/run_go_test
@@ -19,5 +19,9 @@ PATCH="etc/0001-Patch-Hardcode-endpoints-to-local-server.patch"
 (
 cd terraform-provider-aws || exit
 echo "Running tests $2 for service $1..."
-AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test TF_ACC=true go test ./internal/service/$1/ -v -timeout 60m -run $2
+TF_ACC=1 \
+  AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test \
+  AWS_ALTERNATE_ACCESS_KEY_ID=test AWS_ALTERNATE_SECRET_ACCESS_KEY=test \
+  AWS_THIRD_SECRET_ACCESS_KEY=test AWS_THIRD_ACCESS_KEY_ID=test \
+  go test ./internal/service/$1/ -v -timeout 60m -run $2
 )
diff --git a/tests/terraformtests/terraform-tests.failures.txt b/tests/terraformtests/terraform-tests.failures.txt
index accba96d1..9bd6b2873 100644
--- a/tests/terraformtests/terraform-tests.failures.txt
+++ b/tests/terraformtests/terraform-tests.failures.txt
@@ -57,5 +57,8 @@ TestAccLambdaFunctionURL_Cors
 TestAccVPCNATGateway_privateIP
 TestAccVPCSecurityGroupRule_multiDescription
 
+TestAccELBV2TargetGroup_ProtocolVersion_basic
+TestAccELBV2TargetGroup_ForceNew_port
+TestAccELBV2TargetGroup_ForceNew_protocol
 # TF expects a wrong error message, which is not in-line with what AWS returns
 TestAccEFSFileSystemDataSource_nonExistent_fileSystemID
diff --git a/tests/terraformtests/terraform-tests.success.txt b/tests/terraformtests/terraform-tests.success.txt
index 6a2020df2..b94d6afa9 100644
--- a/tests/terraformtests/terraform-tests.success.txt
+++ b/tests/terraformtests/terraform-tests.success.txt
@@ -267,14 +267,58 @@ elb:
   - TestAccELBSSLNegotiationPolicy
 elbv2:
   - TestAccELBV2ListenerCertificate
-  - TestAccELBV2TargetGroupAttachment
-  - TestAccELBV2TargetGroupDataSource
-  - TestAccELBV2TargetGroup_ALBAlias
-  - TestAccELBV2TargetGroup_networkLB
-  - TestAccELBV2TargetGroup_NetworkLB
+  - TestAccELBV2TargetGroup_backwardsCompatibility
+  - TestAccELBV2TargetGroup_ProtocolVersion_grpcHealthCheck
+  - TestAccELBV2TargetGroup_ProtocolVersion_grpcUpdate
+  - TestAccELBV2TargetGroup_ipAddressType
+  - TestAccELBV2TargetGroup_tls
+  - TestAccELBV2TargetGroup_HealthCheck_tcpHTTPS
+  - TestAccELBV2TargetGroup_attrsOnCreate
+  - TestAccELBV2TargetGroup_basic
+  - TestAccELBV2TargetGroup_udp
+  - TestAccELBV2TargetGroup_ForceNew_name
+  - TestAccELBV2TargetGroup_ForceNew_vpc
+  - TestAccELBV2TargetGroup_Defaults_application
+  - TestAccELBV2TargetGroup_Defaults_network
+  - TestAccELBV2TargetGroup_HealthCheck_enable
+  - TestAccELBV2TargetGroup_Name_generated
+  - TestAccELBV2TargetGroup_Name_prefix
+  - TestAccELBV2TargetGroup_NetworkLB_tcpHealthCheckUpdated
+  - TestAccELBV2TargetGroup_networkLB_TargetGroupWithConnectionTermination
+  - TestAccELBV2TargetGroup_NetworkLB_targetGroupWithProxy
+  - TestAccELBV2TargetGroup_preserveClientIPValid
+  - TestAccELBV2TargetGroup_Geneve_basic
+  - TestAccELBV2TargetGroup_Geneve_notSticky
+  - TestAccELBV2TargetGroup_Geneve_Sticky
+  - TestAccELBV2TargetGroup_Geneve_targetFailover
   - TestAccELBV2TargetGroup_Stickiness_defaultALB
-  - TestAccELBV2TargetGroup_Stickiness_valid
-  - TestAccELBV2TargetGroup_Stickiness_update
+  - TestAccELBV2TargetGroup_Stickiness_defaultNLB
+  - TestAccELBV2TargetGroup_Stickiness_invalidALB
+  - TestAccELBV2TargetGroup_Stickiness_invalidNLB
+  - TestAccELBV2TargetGroup_Stickiness_validALB
+  - TestAccELBV2TargetGroup_Stickiness_validNLB
+  - TestAccELBV2TargetGroup_tags
+  - TestAccELBV2TargetGroup_Stickiness_updateAppEnabled
+  - TestAccELBV2TargetGroup_HealthCheck_update
+  - TestAccELBV2TargetGroup_Stickiness_updateEnabled
+  - TestAccELBV2TargetGroup_HealthCheck_without
+  - TestAccELBV2TargetGroup_ALBAlias_basic
+  - TestAccELBV2TargetGroup_ALBAlias_changeNameForceNew
+  - TestAccELBV2TargetGroup_ALBAlias_changePortForceNew
+  - TestAccELBV2TargetGroup_ALBAlias_changeProtocolForceNew
+  - TestAccELBV2TargetGroup_ALBAlias_changeVPCForceNew
+  - TestAccELBV2TargetGroup_ALBAlias_generatedName
+  - TestAccELBV2TargetGroup_ALBAlias_lambda
+  - TestAccELBV2TargetGroup_ALBAlias_lambdaMultiValueHeadersEnabled
+  - TestAccELBV2TargetGroup_ALBAlias_missingPortProtocolVPC
+  - TestAccELBV2TargetGroup_ALBAlias_namePrefix
+  - TestAccELBV2TargetGroup_ALBAlias_setAndUpdateSlowStart
+  - TestAccELBV2TargetGroup_ALBAlias_tags
+  - TestAccELBV2TargetGroup_ALBAlias_updateHealthCheck
+  - TestAccELBV2TargetGroup_ALBAlias_updateLoadBalancingAlgorithmType
+  - TestAccELBV2TargetGroup_ALBAlias_updateLoadBalancingCrossZoneEnabled
+  - TestAccELBV2TargetGroup_ALBAlias_updateStickinessEnabled
+  - TestAccELBV2TargetGroup_Name_noDuplicates
 events:
   - TestAccEventsAPIDestination
   - TestAccEventsArchive
@@ -458,7 +502,10 @@ route53|1:
   - TestAccRoute53Record_Allow_overwrite
 route53|2:
   - TestAccRoute53Zone_
-  - TestAccRoute53ZoneAssociation
+  - TestAccRoute53ZoneAssociation_basic
+  - TestAccRoute53ZoneAssociation_disappears
+  - TestAccRoute53ZoneAssociation_Disappears
+  - TestAccRoute53ZoneAssociation_crossRegion
   - TestAccRoute53ZoneDataSource_id
   - TestAccRoute53ZoneDataSource_name
   - TestAccRoute53ZoneDataSource_tags
diff --git a/tests/test_ec2/test_dhcp_options.py b/tests/test_ec2/test_dhcp_options.py
index d63a48838..7910c0c81 100644
--- a/tests/test_ec2/test_dhcp_options.py
+++ b/tests/test_ec2/test_dhcp_options.py
@@ -66,6 +66,34 @@ def test_dhcp_options_associate_invalid_vpc_id():
     assert ex.value.response["Error"]["Code"] == "InvalidVpcID.NotFound"
 
 
+@mock_ec2
+def test_dhcp_options_disassociation():
+    """Ensure that VPCs can be set to the 'default' DHCP options set for disassociation."""
+    ec2 = boto3.resource("ec2", region_name="us-west-1")
+    client = boto3.client("ec2", region_name="us-west-1")
+    dhcp_options = ec2.create_dhcp_options(
+        DhcpConfigurations=[
+            {"Key": "domain-name", "Values": [SAMPLE_DOMAIN_NAME]},
+            {"Key": "domain-name-servers", "Values": SAMPLE_NAME_SERVERS},
+        ]
+    )
+    vpc = ec2.create_vpc(CidrBlock="10.0.0.0/16")
+
+    # Ensure newly created VPCs without any DHCP options can be disassociated
+    client.associate_dhcp_options(DhcpOptionsId="default", VpcId=vpc.id)
+    vpc.reload()
+    assert vpc.dhcp_options_id == "default"
+
+    client.associate_dhcp_options(DhcpOptionsId=dhcp_options.id, VpcId=vpc.id)
+    vpc.reload()
+    assert vpc.dhcp_options_id == dhcp_options.id
+
+    # Ensure VPCs with already associated DHCP options can be disassociated
+    client.associate_dhcp_options(DhcpOptionsId="default", VpcId=vpc.id)
+    vpc.reload()
+    assert vpc.dhcp_options_id == "default"
+
+
 @mock_ec2
 def test_dhcp_options_delete_with_vpc():
     """Test deletion of dhcp options with vpc"""
diff --git a/tests/test_elbv2/test_elbv2_target_groups.py b/tests/test_elbv2/test_elbv2_target_groups.py
index 0fea02be4..512f1f118 100644
--- a/tests/test_elbv2/test_elbv2_target_groups.py
+++ b/tests/test_elbv2/test_elbv2_target_groups.py
@@ -398,21 +398,24 @@ def test_target_group_attributes():
         Attributes=[
             {"Key": "stickiness.enabled", "Value": "true"},
             {"Key": "stickiness.type", "Value": "app_cookie"},
+            {"Key": "stickiness.app_cookie.cookie_name", "Value": "my_cookie"},
         ],
     )
 
     # The response should have only the keys updated
-    assert len(response["Attributes"]) == 2
+    assert len(response["Attributes"]) == 3
     attributes = {attr["Key"]: attr["Value"] for attr in response["Attributes"]}
     assert attributes["stickiness.type"] == "app_cookie"
     assert attributes["stickiness.enabled"] == "true"
+    assert attributes["stickiness.app_cookie.cookie_name"] == "my_cookie"
 
     # These new values should be in the full attribute list
     response = conn.describe_target_group_attributes(TargetGroupArn=target_group_arn)
-    assert len(response["Attributes"]) == 7
+    assert len(response["Attributes"]) == 8
     attributes = {attr["Key"]: attr["Value"] for attr in response["Attributes"]}
     assert attributes["stickiness.type"] == "app_cookie"
     assert attributes["stickiness.enabled"] == "true"
+    assert attributes["stickiness.app_cookie.cookie_name"] == "my_cookie"
 
 
 @mock_elbv2
@@ -993,3 +996,213 @@ def test_create_target_group_healthcheck_validation(protocol_name, should_raise)
         assert exc.value.response["Error"]["Message"] == _get_error_message(
             protocol_name, 5, 5
         )
+
+
+@mock_ec2
+@mock_elbv2
+@pytest.mark.parametrize(
+    "protocol, should_raise, stickiness_type, error_message",
+    [
+        # stickiness.type = "source_ip"
+        (
+            "HTTP",
+            True,
+            "source_ip",
+            "Stickiness type 'source_ip' is not supported for target groups with the HTTP protocol",
+        ),
+        (
+            "HTTPS",
+            True,
+            "source_ip",
+            "Stickiness type 'source_ip' is not supported for target groups with the HTTPS protocol",
+        ),
+        ("TCP", False, "source_ip", ""),
+        (
+            "TLS",
+            True,
+            "source_ip",
+            "You cannot enable stickiness on target groups with the TLS protocol",
+        ),
+        ("UDP", False, "source_ip", ""),
+        ("TCP_UDP", False, "source_ip", ""),
+        (
+            "GENEVE",
+            True,
+            "source_ip",
+            "'source_ip' must be one of [source_ip_dest_ip_proto, source_ip_dest_ip]",
+        ),
+        # stickiness.type = "lb_cookie"
+        ("HTTP", False, "lb_cookie", ""),
+        ("HTTPS", False, "lb_cookie", ""),
+        (
+            "TCP",
+            True,
+            "lb_cookie",
+            "Stickiness type 'lb_cookie' is not supported for target groups with the TCP protocol",
+        ),
+        (
+            "TLS",
+            True,
+            "lb_cookie",
+            "Stickiness type 'lb_cookie' is not supported for target groups with the TLS protocol",
+        ),
+        (
+            "UDP",
+            True,
+            "lb_cookie",
+            "Stickiness type 'lb_cookie' is not supported for target groups with the UDP protocol",
+        ),
+        (
+            "TCP_UDP",
+            True,
+            "lb_cookie",
+            "Stickiness type 'lb_cookie' is not supported for target groups with the TCP_UDP protocol",
+        ),
+        (
+            "GENEVE",
+            True,
+            "lb_cookie",
+            "'lb_cookie' must be one of [source_ip_dest_ip_proto, source_ip_dest_ip]",
+        ),
+        # stickiness.type = "app_cookie"
+        ("HTTP", False, "app_cookie", ""),
+        ("HTTPS", False, "app_cookie", ""),
+        (
+            "TCP",
+            True,
+            "app_cookie",
+            "Stickiness type 'app_cookie' is not supported for target groups with the TCP protocol",
+        ),
+        (
+            "TLS",
+            True,
+            "app_cookie",
+            "Stickiness type 'app_cookie' is not supported for target groups with the TLS protocol",
+        ),
+        (
+            "UDP",
+            True,
+            "app_cookie",
+            "Stickiness type 'app_cookie' is not supported for target groups with the UDP protocol",
+        ),
+        (
+            "TCP_UDP",
+            True,
+            "app_cookie",
+            "Stickiness type 'app_cookie' is not supported for target groups with the TCP_UDP protocol",
+        ),
+        (
+            "GENEVE",
+            True,
+            "app_cookie",
+            "Target group attribute key 'stickiness.app_cookie.cookie_name' is not recognized",
+        ),
+        # stickiness.type = "source_ip_dest_ip"
+        (
+            "HTTP",
+            True,
+            "source_ip_dest_ip",
+            "'Stickiness type' must be one of [app_cookie, lb_cookie, source_ip]",
+        ),
+        (
+            "HTTPS",
+            True,
+            "source_ip_dest_ip",
+            "'Stickiness type' must be one of [app_cookie, lb_cookie, source_ip]",
+        ),
+        (
+            "TCP",
+            True,
+            "source_ip_dest_ip",
+            "'Stickiness type' must be one of [app_cookie, lb_cookie, source_ip]",
+        ),
+        (
+            "TLS",
+            True,
+            "source_ip_dest_ip",
+            "'Stickiness type' must be one of [app_cookie, lb_cookie, source_ip]",
+        ),
+        (
+            "UDP",
+            True,
+            "source_ip_dest_ip",
+            "'Stickiness type' must be one of [app_cookie, lb_cookie, source_ip]",
+        ),
+        (
+            "TCP_UDP",
+            True,
+            "source_ip_dest_ip",
+            "'Stickiness type' must be one of [app_cookie, lb_cookie, source_ip]",
+        ),
+        ("GENEVE", False, "source_ip_dest_ip", ""),
+        # stickiness.type = "source_ip_dest_ip_proto"
+        (
+            "HTTPS",
+            True,
+            "source_ip_dest_ip_proto",
+            "'Stickiness type' must be one of [app_cookie, lb_cookie, source_ip]",
+        ),
+        (
+            "HTTP",
+            True,
+            "source_ip_dest_ip_proto",
+            "'Stickiness type' must be one of [app_cookie, lb_cookie, source_ip]",
+        ),
+        (
+            "TCP",
+            True,
+            "source_ip_dest_ip_proto",
+            "'Stickiness type' must be one of [app_cookie, lb_cookie, source_ip]",
+        ),
+        (
+            "TLS",
+            True,
+            "source_ip_dest_ip_proto",
+            "'Stickiness type' must be one of [app_cookie, lb_cookie, source_ip]",
+        ),
+        (
+            "UDP",
+            True,
+            "source_ip_dest_ip_proto",
+            "'Stickiness type' must be one of [app_cookie, lb_cookie, source_ip]",
+        ),
+        (
+            "TCP_UDP",
+            True,
+            "source_ip_dest_ip_proto",
+            "'Stickiness type' must be one of [app_cookie, lb_cookie, source_ip]",
+        ),
+        ("GENEVE", False, "source_ip_dest_ip_proto", ""),
+    ],
+)
+def test_target_group_attributes_stickiness(
+    protocol, should_raise, stickiness_type, error_message
+):
+    elbv2 = boto3.client("elbv2", region_name="us-east-1")
+    _, vpc, _, _, _, _ = create_load_balancer()
+
+    response = elbv2.create_target_group(
+        Name="a-target",
+        Protocol=protocol,
+        Port=6081 if protocol == "GENEVE" else 80,
+        VpcId=vpc.id,
+    )
+    target_group_arn = response["TargetGroups"][0]["TargetGroupArn"]
+    attributes = [
+        {"Key": "stickiness.enabled", "Value": "true"},
+        {"Key": "stickiness.type", "Value": stickiness_type},
+    ]
+    if stickiness_type == "app_cookie":
+        attributes.append(
+            {"Key": "stickiness.app_cookie.cookie_name", "Value": "localstack"}
+        )
+    if should_raise:
+        with pytest.raises(ClientError) as exc:
+            elbv2.modify_target_group_attributes(
+                TargetGroupArn=target_group_arn, Attributes=attributes
+            )
+        assert exc.value.response["Error"]["Message"] == error_message
+    else:
+        elbv2.modify_target_group_attributes(
+            TargetGroupArn=target_group_arn, Attributes=attributes
+        )