Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 33 additions & 11 deletions deployfish/core/models/elbv2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
179 changes: 179 additions & 0 deletions deployfish/core/models/test/test_elbv2.py
Original file line number Diff line number Diff line change
@@ -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])
16 changes: 16 additions & 0 deletions docs/source/api/models/elbv2.rst
Original file line number Diff line number Diff line change
@@ -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:
Expand Down