Repository: ansible-collections/amazon.aws

Issue:
Add support for TLS listeners in elbv2 module_utils
### Summary

Currently it's not possible to change the Ssl Policy or ALPN Policy of TLS listener of a Network Load Balancer due to the listener is never added to `listeners_to_modify`

Need something similar to the block we have for `HTTPS` - https://github.com/ansible-collections/amazon.aws/blob/main/plugins/module_utils/elbv2.py#L759-L772

**References**
* Usage: https://github.com/ansible-collections/community.aws/blob/main/plugins/modules/elb_network_lb.py#L371
* SslPolicy: https://github.com/ansible-collections/community.aws/blob/main/plugins/modules/elb_network_lb.py#L445
* AlpnPolicy: https://github.com/ansible-collections/community.aws/issues/1566

### Issue Type

Feature Idea

### Component Name

elbv2 (module_utils)

### Additional Information

<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)

```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
None

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->

Resolving patch:
diff --git a/plugins/module_utils/elbv2.py b/plugins/module_utils/elbv2.py
--- a/plugins/module_utils/elbv2.py
+++ b/plugins/module_utils/elbv2.py
@@ -769,6 +769,9 @@ def __init__(self, connection, module, elb_arn):
                 dict((x, listener_dict[x]) for x in listener_dict if listener_dict[x] is not None)
                 for listener_dict in listeners
             ]
+            # AlpnPolicy is set as str into input but API is expected a list
+            # Transform a single item into a list of one element
+            listeners = self._ensure_listeners_alpn_policy(listeners)
         self.listeners = self._ensure_listeners_default_action_has_arn(listeners)
         self.current_listeners = self._get_elb_listeners()
         self.purge_listeners = module.params.get("purge_listeners")
@@ -799,6 +802,16 @@ def _get_elb_listeners(self):
         except (BotoCoreError, ClientError) as e:
             self.module.fail_json_aws(e)
 
+    @staticmethod
+    def _ensure_listeners_alpn_policy(listeners):
+        result = []
+        for l in listeners:
+            update_listener = deepcopy(l)
+            if "AlpnPolicy" in l:
+                update_listener["AlpnPolicy"] = [update_listener["AlpnPolicy"]]
+            result.append(update_listener)
+        return result
+
     def _ensure_listeners_default_action_has_arn(self, listeners):
         """
         If a listener DefaultAction has been passed with a Target Group Name instead of ARN, lookup the ARN and
@@ -857,7 +870,8 @@ def compare_listeners(self):
 
         return listeners_to_add, listeners_to_modify, listeners_to_delete
 
-    def _compare_listener(self, current_listener, new_listener):
+    @staticmethod
+    def _compare_listener(current_listener, new_listener):
         """
         Compare two listeners.
 
@@ -876,43 +890,53 @@ def _compare_listener(self, current_listener, new_listener):
         if current_listener["Protocol"] != new_listener["Protocol"]:
             modified_listener["Protocol"] = new_listener["Protocol"]
 
-        # If Protocol is HTTPS, check additional attributes
-        if current_listener["Protocol"] == "HTTPS" and new_listener["Protocol"] == "HTTPS":
-            # Cert
-            if current_listener["SslPolicy"] != new_listener["SslPolicy"]:
-                modified_listener["SslPolicy"] = new_listener["SslPolicy"]
-            if (
-                current_listener["Certificates"][0]["CertificateArn"]
-                != new_listener["Certificates"][0]["CertificateArn"]
+        # If Protocol is HTTPS or TLS, check additional attributes
+        # SslPolicy
+        new_ssl_policy = new_listener.get("SslPolicy")
+        if new_ssl_policy and new_listener["Protocol"] in ("HTTPS", "TLS"):
+            current_ssl_policy = current_listener.get("SslPolicy")
+            if not current_ssl_policy or (current_ssl_policy and current_ssl_policy != new_ssl_policy):
+                modified_listener["SslPolicy"] = new_ssl_policy
+
+        # Certificates
+        new_certificates = new_listener.get("Certificates")
+        if new_certificates and new_listener["Protocol"] in ("HTTPS", "TLS"):
+            current_certificates = current_listener.get("Certificates")
+            if not current_certificates or (
+                current_certificates
+                and current_certificates[0]["CertificateArn"] != new_certificates[0]["CertificateArn"]
             ):
-                modified_listener["Certificates"] = []
-                modified_listener["Certificates"].append({})
-                modified_listener["Certificates"][0]["CertificateArn"] = new_listener["Certificates"][0][
-                    "CertificateArn"
-                ]
-        elif current_listener["Protocol"] != "HTTPS" and new_listener["Protocol"] == "HTTPS":
-            modified_listener["SslPolicy"] = new_listener["SslPolicy"]
-            modified_listener["Certificates"] = []
-            modified_listener["Certificates"].append({})
-            modified_listener["Certificates"][0]["CertificateArn"] = new_listener["Certificates"][0]["CertificateArn"]
+                modified_listener["Certificates"] = [{"CertificateArn": new_certificates[0]["CertificateArn"]}]
 
         # Default action
 
         # If the lengths of the actions are the same, we'll have to verify that the
         # contents of those actions are the same
-        if len(current_listener["DefaultActions"]) == len(new_listener["DefaultActions"]):
-            current_actions_sorted = _sort_actions(current_listener["DefaultActions"])
-            new_actions_sorted = _sort_actions(new_listener["DefaultActions"])
-
-            new_actions_sorted_no_secret = [_prune_secret(i) for i in new_actions_sorted]
-
-            if [_prune_ForwardConfig(i) for i in current_actions_sorted] != [
-                _prune_ForwardConfig(i) for i in new_actions_sorted_no_secret
-            ]:
-                modified_listener["DefaultActions"] = new_listener["DefaultActions"]
-        # If the action lengths are different, then replace with the new actions
-        else:
-            modified_listener["DefaultActions"] = new_listener["DefaultActions"]
+        current_default_actions = current_listener.get("DefaultActions")
+        new_default_actions = new_listener.get("DefaultActions")
+        if new_default_actions:
+            if current_default_actions and len(current_default_actions) == len(new_default_actions):
+                current_actions_sorted = _sort_actions(current_default_actions)
+                new_actions_sorted = _sort_actions(new_default_actions)
+
+                new_actions_sorted_no_secret = [_prune_secret(i) for i in new_actions_sorted]
+
+                if [_prune_ForwardConfig(i) for i in current_actions_sorted] != [
+                    _prune_ForwardConfig(i) for i in new_actions_sorted_no_secret
+                ]:
+                    modified_listener["DefaultActions"] = new_default_actions
+            # If the action lengths are different, then replace with the new actions
+            else:
+                modified_listener["DefaultActions"] = new_default_actions
+
+        new_alpn_policy = new_listener.get("AlpnPolicy")
+        if new_alpn_policy:
+            if current_listener["Protocol"] == "TLS" and new_listener["Protocol"] == "TLS":
+                current_alpn_policy = current_listener.get("AlpnPolicy")
+                if not current_alpn_policy or current_alpn_policy[0] != new_alpn_policy[0]:
+                    modified_listener["AlpnPolicy"] = new_alpn_policy
+            elif current_listener["Protocol"] != "TLS" and new_listener["Protocol"] == "TLS":
+                modified_listener["AlpnPolicy"] = new_alpn_policy
 
         if modified_listener:
             return modified_listener


Test patch:
diff --git a/tests/unit/module_utils/test_elbv2.py b/tests/unit/module_utils/test_elbv2.py
--- a/tests/unit/module_utils/test_elbv2.py
+++ b/tests/unit/module_utils/test_elbv2.py
@@ -6,6 +6,8 @@
 
 from unittest.mock import MagicMock
 
+import pytest
+
 from ansible_collections.amazon.aws.plugins.module_utils import elbv2
 
 one_action = [
@@ -159,3 +161,138 @@ def test_get_elb_attributes(self):
         actual_elb_attributes = self.elbv2obj.get_elb_attributes()
         # Assert we got the expected result
         assert actual_elb_attributes == expected_elb_attributes
+
+
+class TestELBListeners:
+    DEFAULT_PORT = 80
+    DEFAULT_PROTOCOL = "TCP"
+
+    def createListener(self, **kwargs):
+        result = {"Port": self.DEFAULT_PORT, "Protocol": self.DEFAULT_PROTOCOL}
+        if kwargs.get("port"):
+            result["Port"] = kwargs.get("port")
+        if kwargs.get("protocol"):
+            result["Protocol"] = kwargs.get("protocol")
+        if kwargs.get("certificate_arn") and kwargs.get("protocol") in ("TLS", "HTTPS"):
+            result["Certificates"] = [{"CertificateArn": kwargs.get("certificate_arn")}]
+        if kwargs.get("sslPolicy") and kwargs.get("protocol") in ("TLS", "HTTPS"):
+            result["SslPolicy"] = kwargs.get("sslPolicy")
+        if kwargs.get("alpnPolicy") and kwargs.get("protocol") == "TLS":
+            result["AlpnPolicy"] = kwargs.get("alpnPolicy")
+        return result
+
+    @pytest.mark.parametrize("current_protocol", ["TCP", "TLS", "UDP"])
+    @pytest.mark.parametrize(
+        "current_alpn,new_alpn",
+        [
+            (None, "None"),
+            (None, "HTTP1Only"),
+            ("HTTP1Only", "HTTP2Only"),
+            ("HTTP1Only", "HTTP1Only"),
+        ],
+    )
+    def test__compare_listener_alpn_policy(self, current_protocol, current_alpn, new_alpn):
+        current_listener = self.createListener(protocol=current_protocol, alpnPolicy=[current_alpn])
+        new_listener = self.createListener(protocol="TLS", alpnPolicy=[new_alpn])
+        result = None
+        if current_protocol != "TLS":
+            result = {"Protocol": "TLS"}
+        if new_alpn and any((current_protocol != "TLS", not current_alpn, current_alpn and current_alpn != new_alpn)):
+            result = result or {}
+            result["AlpnPolicy"] = [new_alpn]
+
+        assert result == elbv2.ELBListeners._compare_listener(current_listener, new_listener)
+
+    @pytest.mark.parametrize(
+        "current_protocol,new_protocol",
+        [
+            ("TCP", "TCP"),
+            ("TLS", "HTTPS"),
+            ("HTTPS", "HTTPS"),
+            ("TLS", "TLS"),
+            ("HTTPS", "TLS"),
+            ("HTTPS", "TCP"),
+            ("TLS", "TCP"),
+        ],
+    )
+    @pytest.mark.parametrize(
+        "current_ssl,new_ssl",
+        [
+            (None, "ELBSecurityPolicy-TLS-1-0-2015-04"),
+            ("ELBSecurityPolicy-TLS13-1-2-Ext2-2021-06", "ELBSecurityPolicy-TLS-1-0-2015-04"),
+            ("ELBSecurityPolicy-TLS-1-0-2015-04", None),
+            ("ELBSecurityPolicy-TLS-1-0-2015-04", "ELBSecurityPolicy-TLS-1-0-2015-04"),
+        ],
+    )
+    def test__compare_listener_sslpolicy(self, current_protocol, new_protocol, current_ssl, new_ssl):
+        current_listener = self.createListener(protocol=current_protocol, sslPolicy=current_ssl)
+
+        new_listener = self.createListener(protocol=new_protocol, sslPolicy=new_ssl)
+
+        expected = None
+        if new_protocol != current_protocol:
+            expected = {"Protocol": new_protocol}
+        if new_protocol in ("HTTPS", "TLS") and new_ssl and new_ssl != current_ssl:
+            expected = expected or {}
+            expected["SslPolicy"] = new_ssl
+        assert expected == elbv2.ELBListeners._compare_listener(current_listener, new_listener)
+
+    @pytest.mark.parametrize(
+        "current_protocol,new_protocol",
+        [
+            ("TCP", "TCP"),
+            ("TLS", "HTTPS"),
+            ("HTTPS", "HTTPS"),
+            ("TLS", "TLS"),
+            ("HTTPS", "TLS"),
+            ("HTTPS", "TCP"),
+            ("TLS", "TCP"),
+        ],
+    )
+    @pytest.mark.parametrize(
+        "current_certificate,new_certificate",
+        [
+            (None, "arn:aws:iam::012345678901:server-certificate/ansible-test-1"),
+            (
+                "arn:aws:iam::012345678901:server-certificate/ansible-test-1",
+                "arn:aws:iam::012345678901:server-certificate/ansible-test-2",
+            ),
+            ("arn:aws:iam::012345678901:server-certificate/ansible-test-1", None),
+            (
+                "arn:aws:iam::012345678901:server-certificate/ansible-test-1",
+                "arn:aws:iam::012345678901:server-certificate/ansible-test-1",
+            ),
+        ],
+    )
+    def test__compare_listener_certificates(self, current_protocol, new_protocol, current_certificate, new_certificate):
+        current_listener = self.createListener(protocol=current_protocol, certificate_arn=current_certificate)
+
+        new_listener = self.createListener(protocol=new_protocol, certificate_arn=new_certificate)
+
+        expected = None
+        if new_protocol != current_protocol:
+            expected = {"Protocol": new_protocol}
+        if new_protocol in ("HTTPS", "TLS") and new_certificate and new_certificate != current_certificate:
+            expected = expected or {}
+            expected["Certificates"] = [{"CertificateArn": new_certificate}]
+        assert expected == elbv2.ELBListeners._compare_listener(current_listener, new_listener)
+
+    @pytest.mark.parametrize(
+        "are_equals",
+        [True, False],
+    )
+    def test__compare_listener_port(self, are_equals):
+        current_listener = self.createListener()
+        new_port = MagicMock() if not are_equals else None
+        new_listener = self.createListener(port=new_port)
+
+        result = elbv2.ELBListeners._compare_listener(current_listener, new_listener)
+        expected = None
+        if not are_equals:
+            expected = {"Port": new_port}
+        assert result == expected
+
+    def test_ensure_listeners_alpn_policy(self):
+        listeners = [{"Port": self.DEFAULT_PORT, "AlpnPolicy": "HTTP2Optional"}]
+        expected = [{"Port": self.DEFAULT_PORT, "AlpnPolicy": ["HTTP2Optional"]}]
+        assert expected == elbv2.ELBListeners._ensure_listeners_alpn_policy(listeners)