diff --git a/deployfish/core/models/elbv2.py b/deployfish/core/models/elbv2.py index 01b3eda..b807399 100644 --- a/deployfish/core/models/elbv2.py +++ b/deployfish/core/models/elbv2.py @@ -147,20 +147,37 @@ def __get_rules_for_load_balancer(self, load_balancer_pk: str) -> Sequence["Load return self.cache["load_balancers"][load_balancer_pk] def __get_rules_for_target_group(self, target_group_arn: str) -> Sequence["LoadBalancerListenerRule"]: + """ + Return listener rules on the target group's load balancer that forward + to ``target_group_arn``. + + Matches both of the shapes AWS uses on ``forward`` actions: + + * A single target group via top-level ``TargetGroupArn`` + * One or more target groups via ``ForwardConfig.TargetGroups`` (weighted + / canary routing) + + Args: + target_group_arn: ARN of the target group to find rules for + + Returns: + Listener rules that forward traffic to the given target group + + """ tg = TargetGroup.objects.get(target_group_arn) load_balancer_pk = tg.data["LoadBalancerArns"][0] rule_objects = self.__get_rules_for_load_balancer(load_balancer_pk) matched_rules = [] for obj in rule_objects: for action in obj.data["Actions"]: - if action["Type"] == "forward" and action["TargetGroupArn"] == target_group_arn: - # I'm making an important assumption here that the relevant target group is the one attached - # to the first 'forward' action on the rule, and that we only have one TargetGroup -- we're - # not using a weighted ForwardConfig with a list of TargetGroupArns. - # - # If we ever start doing green/blue deployments with two services attached to the same - # listener rule, we'll need to fix this. - matched_rules.append(obj) + if action["Type"] == "forward": + if action.get("TargetGroupArn") == target_group_arn: + matched_rules.append(obj) + elif "ForwardConfig" in action: + for target_group in action["ForwardConfig"]["TargetGroups"]: + if target_group["TargetGroupArn"] == target_group_arn: + matched_rules.append(obj) + break return matched_rules def list( @@ -499,11 +516,16 @@ def load_balancers(self) -> Sequence[LoadBalancer]: @property def rules(self) -> Sequence[LoadBalancerListenerRule]: """ + Listener rules that forward to this target group. + .. note:: - The dumb thing here is that you can't ask the target group itself - what listener rules it is attached to -- you have to start at the - load balancer, list all the listener rules that + You cannot ask the target group itself which listener rules + reference it. Deployfish starts at the load balancer, lists its + listener rules, and keeps those whose ``forward`` actions mention + this target group — either via top-level ``TargetGroupArn`` or via + ``ForwardConfig.TargetGroups`` (weighted / canary routing). + """ if "listener_rules" not in self.cache: self.cache["listener_rules"] = LoadBalancerListenerRule.objects.list(target_group_arn=self.arn) diff --git a/deployfish/core/models/test/test_elbv2.py b/deployfish/core/models/test/test_elbv2.py new file mode 100644 index 0000000..fd3c3c7 --- /dev/null +++ b/deployfish/core/models/test/test_elbv2.py @@ -0,0 +1,179 @@ +import logging +import unittest +from unittest.mock import Mock, patch + +from deployfish.core.models.elbv2 import ( + LoadBalancerListenerRule, + LoadBalancerListenerRuleManager, +) + +logging.getLogger("boto3").setLevel(logging.WARNING) +logging.getLogger("botocore").setLevel(logging.WARNING) + + +class TestLoadBalancerListenerRuleManager_get_rules_for_target_group( + unittest.TestCase +): + """ + Tests for matching listener rules that forward to a target group, + including weighted ForwardConfig (canary) layouts. + """ + + TG_ARN_A = ( + "arn:aws:elasticloadbalancing:us-west-2:123456789012:" + "targetgroup/a/aaaaaaaaaaaaaaaa" + ) + TG_ARN_B = ( + "arn:aws:elasticloadbalancing:us-west-2:123456789012:" + "targetgroup/b/bbbbbbbbbbbbbbbb" + ) + TG_ARN_OTHER = ( + "arn:aws:elasticloadbalancing:us-west-2:123456789012:" + "targetgroup/other/cccccccccccccccc" + ) + LB_ARN = ( + "arn:aws:elasticloadbalancing:us-west-2:123456789012:" + "loadbalancer/app/example/dddddddddddddddd" + ) + + def _rule(self, rule_arn: str, actions: list) -> LoadBalancerListenerRule: + return LoadBalancerListenerRule( + { + "RuleArn": rule_arn, + "Actions": actions, + "Conditions": [], + "Priority": "1", + "IsDefault": False, + } + ) + + def _get_rules_for_target_group( + self, + rules: list[LoadBalancerListenerRule], + target_group_arn: str, + ) -> list[LoadBalancerListenerRule]: + manager = LoadBalancerListenerRuleManager() + tg = Mock() + tg.data = {"LoadBalancerArns": [self.LB_ARN]} + with patch( + "deployfish.core.models.elbv2.TargetGroup.objects.get", + return_value=tg, + ): + with patch.object( + manager, + "_LoadBalancerListenerRuleManager__get_rules_for_load_balancer", + return_value=rules, + ): + return manager._LoadBalancerListenerRuleManager__get_rules_for_target_group( # noqa: E501 + target_group_arn + ) + + def test_matches_single_target_group_arn(self): + rule = self._rule( + "arn:aws:elasticloadbalancing:us-west-2:123456789012:listener-rule/app/example/1/aaa", + [ + { + "Type": "forward", + "TargetGroupArn": self.TG_ARN_A, + } + ], + ) + matched = self._get_rules_for_target_group([rule], self.TG_ARN_A) + self.assertEqual(matched, [rule]) + + def test_does_not_match_unrelated_single_target_group_arn(self): + rule = self._rule( + "arn:aws:elasticloadbalancing:us-west-2:123456789012:listener-rule/app/example/1/aaa", + [ + { + "Type": "forward", + "TargetGroupArn": self.TG_ARN_A, + } + ], + ) + matched = self._get_rules_for_target_group([rule], self.TG_ARN_OTHER) + self.assertEqual(matched, []) + + def test_matches_forward_config_target_groups(self): + rule = self._rule( + "arn:aws:elasticloadbalancing:us-west-2:123456789012:listener-rule/app/example/1/bbb", + [ + { + "Type": "forward", + "ForwardConfig": { + "TargetGroups": [ + {"TargetGroupArn": self.TG_ARN_A, "Weight": 90}, + {"TargetGroupArn": self.TG_ARN_B, "Weight": 10}, + ] + }, + } + ], + ) + self.assertEqual( + self._get_rules_for_target_group([rule], self.TG_ARN_A), + [rule], + ) + self.assertEqual( + self._get_rules_for_target_group([rule], self.TG_ARN_B), + [rule], + ) + + def test_does_not_match_unrelated_forward_config_target_group(self): + rule = self._rule( + "arn:aws:elasticloadbalancing:us-west-2:123456789012:listener-rule/app/example/1/bbb", + [ + { + "Type": "forward", + "ForwardConfig": { + "TargetGroups": [ + {"TargetGroupArn": self.TG_ARN_A, "Weight": 90}, + {"TargetGroupArn": self.TG_ARN_B, "Weight": 10}, + ] + }, + } + ], + ) + matched = self._get_rules_for_target_group([rule], self.TG_ARN_OTHER) + self.assertEqual(matched, []) + + def test_ignores_non_forward_actions(self): + rule = self._rule( + "arn:aws:elasticloadbalancing:us-west-2:123456789012:listener-rule/app/example/1/ccc", + [ + { + "Type": "redirect", + "RedirectConfig": { + "Protocol": "HTTPS", + "Port": "443", + "StatusCode": "HTTP_301", + }, + } + ], + ) + matched = self._get_rules_for_target_group([rule], self.TG_ARN_A) + self.assertEqual(matched, []) + + def test_list_by_target_group_arn_uses_matching(self): + rule = self._rule( + "arn:aws:elasticloadbalancing:us-west-2:123456789012:listener-rule/app/example/1/ddd", + [ + { + "Type": "forward", + "ForwardConfig": { + "TargetGroups": [ + {"TargetGroupArn": self.TG_ARN_A, "Weight": 50}, + {"TargetGroupArn": self.TG_ARN_B, "Weight": 50}, + ] + }, + } + ], + ) + manager = LoadBalancerListenerRuleManager() + with patch.object( + manager, + "_LoadBalancerListenerRuleManager__get_rules_for_target_group", + return_value=[rule], + ) as mock_get: + result = manager.list(target_group_arn=self.TG_ARN_B) + mock_get.assert_called_once_with(self.TG_ARN_B) + self.assertEqual(result, [rule]) diff --git a/docs/source/api/models/elbv2.rst b/docs/source/api/models/elbv2.rst index a636b1b..0773a57 100644 --- a/docs/source/api/models/elbv2.rst +++ b/docs/source/api/models/elbv2.rst @@ -1,6 +1,22 @@ Application/Network Load Balancing ================================== +Models and managers for Application Load Balancers (ALB) and Network Load +Balancers (NLB), including listeners, listener rules, and target groups. + +Weighted and canary target groups +--------------------------------- + +When an ALB listener rule uses weighted forwarding (for example canary +deployments), AWS stores the target groups under +``Actions[].ForwardConfig.TargetGroups`` instead of a single top-level +``TargetGroupArn``. + +:py:meth:`deployfish.core.models.elbv2.LoadBalancerListenerRuleManager.list` +with ``target_group_arn=...``, and therefore +:py:attr:`deployfish.core.models.elbv2.TargetGroup.rules`, match rules that +reference a target group in either form. + .. automodule:: deployfish.core.models.elbv2 :members: :undoc-members: