From 3431e28fcce2eac558e7cf2af2011880ad50411e Mon Sep 17 00:00:00 2001 From: Robert Rollins Date: Fri, 21 Aug 2026 14:55:59 -0700 Subject: [PATCH 1/2] Added ability to parse TargetGroupArns from multi-group configs In order to support Canary Deployment (deploying new code to only a small portion of the live containers), you need to put the new code into a separate target group, and configure the ALB to weight incoming traffic between the two target groups. This changes the way the TargetGroupArn has to be retrieved by the LoadBalancerListenerRuleManager, as when there are more than one of them, they are placed in a different dictionary structure. --- deployfish/core/models/elbv2.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/deployfish/core/models/elbv2.py b/deployfish/core/models/elbv2.py index 01b3eda..0f74f07 100644 --- a/deployfish/core/models/elbv2.py +++ b/deployfish/core/models/elbv2.py @@ -153,14 +153,14 @@ def __get_rules_for_target_group(self, target_group_arn: str) -> Sequence["LoadB 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( From afb81237c91687cafafbb45f9ce0918272c3f146 Mon Sep 17 00:00:00 2001 From: Robert Rollins Date: Mon, 24 Aug 2026 17:21:04 -0700 Subject: [PATCH 2/2] Added docs and tests for the target groups fix. --- deployfish/core/models/elbv2.py | 28 +++- deployfish/core/models/test/test_elbv2.py | 179 ++++++++++++++++++++++ docs/source/api/models/elbv2.rst | 16 ++ 3 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 deployfish/core/models/test/test_elbv2.py diff --git a/deployfish/core/models/elbv2.py b/deployfish/core/models/elbv2.py index 0f74f07..b807399 100644 --- a/deployfish/core/models/elbv2.py +++ b/deployfish/core/models/elbv2.py @@ -147,6 +147,23 @@ 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) @@ -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: