Skip to content
This repository was archived by the owner on Oct 23, 2025. It is now read-only.

Commit 2bcce4b

Browse files
authored
Merge pull request #326 from zalando-stups/application-load-balancer
Application load balancer
2 parents 19f785b + cfacba4 commit 2bcce4b

File tree

4 files changed

+310
-1
lines changed

4 files changed

+310
-1
lines changed

senza/components/auto_scaling_group.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,15 @@ def component_auto_scaling_group(definition, configuration, args, info, force, a
150150
asg_properties["LoadBalancerNames"] = [{'Ref': ref} for ref in configuration["ElasticLoadBalancer"]]
151151
# use ELB health check by default
152152
default_health_check_type = 'ELB'
153+
elif "ElasticLoadBalancerV2" in configuration:
154+
if isinstance(configuration["ElasticLoadBalancerV2"], str):
155+
asg_properties["TargetGroupARNs"] = [{"Ref": configuration["ElasticLoadBalancerV2"] + 'TargetGroup'}]
156+
elif isinstance(configuration["ElasticLoadBalancerV2"], list):
157+
asg_properties["TargetGroupARNs"] = [
158+
{'Ref': ref} for ref in configuration["ElasticLoadBalancerV2"] + 'TargetGroup'
159+
]
160+
# use ELB health check by default
161+
default_health_check_type = 'ELB'
153162

154163
asg_properties['HealthCheckType'] = configuration.get('HealthCheckType', default_health_check_type)
155164
asg_properties['HealthCheckGracePeriod'] = configuration.get('HealthCheckGracePeriod', 300)
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import click
2+
from clickclick import fatal_error
3+
from senza.aws import resolve_security_groups
4+
from senza.components.elastic_load_balancer import get_load_balancer_name
5+
6+
from ..cli import AccountArguments, TemplateArguments
7+
from ..manaus import ClientError
8+
from ..manaus.acm import ACM, ACMCertificate
9+
from ..manaus.iam import IAM, IAMServerCertificate
10+
from ..manaus.route53 import convert_domain_records_to_alias
11+
12+
SENZA_PROPERTIES = frozenset(['Domains', 'HealthCheckPath', 'HealthCheckPort', 'HealthCheckProtocol',
13+
'HTTPPort', 'Name', 'SecurityGroups', 'SSLCertificateId', 'Type'])
14+
15+
16+
def get_listeners(lb_name, target_group_name, subdomain, main_zone, configuration,
17+
account_info: AccountArguments):
18+
ssl_cert = configuration.get('SSLCertificateId')
19+
20+
if ACMCertificate.arn_is_acm_certificate(ssl_cert):
21+
# check if certificate really exists
22+
try:
23+
ACMCertificate.get_by_arn(account_info.Region, ssl_cert)
24+
except ClientError as e:
25+
error_msg = e.response['Error']['Message']
26+
fatal_error(error_msg)
27+
elif IAMServerCertificate.arn_is_server_certificate(ssl_cert):
28+
# TODO check if certificate exists
29+
pass
30+
elif ssl_cert is not None:
31+
certificate = IAMServerCertificate.get_by_name(account_info.Region,
32+
ssl_cert)
33+
ssl_cert = certificate.arn
34+
elif main_zone is not None:
35+
if main_zone:
36+
iam_pattern = main_zone.lower().rstrip('.').replace('.', '-')
37+
name = '{sub}.{zone}'.format(sub=subdomain,
38+
zone=main_zone.rstrip('.'))
39+
acm = ACM(account_info.Region)
40+
acm_certificates = sorted(acm.get_certificates(domain_name=name),
41+
reverse=True)
42+
else:
43+
iam_pattern = ''
44+
acm_certificates = []
45+
iam = IAM(account_info.Region)
46+
iam_certificates = sorted(iam.get_certificates(name=iam_pattern))
47+
if not iam_certificates:
48+
# if there are no iam certificates matching the pattern
49+
# try to use any certificate
50+
iam_certificates = sorted(iam.get_certificates(), reverse=True)
51+
52+
# the priority is acm_certificate first and iam_certificate second
53+
certificates = (acm_certificates +
54+
iam_certificates) # type: List[Union[ACMCertificate, IAMServerCertificate]]
55+
try:
56+
certificate = certificates[0]
57+
ssl_cert = certificate.arn
58+
except IndexError:
59+
if main_zone:
60+
fatal_error('Could not find any matching '
61+
'SSL certificate for "{}"'.format(name))
62+
else:
63+
fatal_error('Could not find any SSL certificate')
64+
return [{
65+
'Type': 'AWS::ElasticLoadBalancingV2::Listener',
66+
'Properties': {
67+
"Certificates": [{'CertificateArn': ssl_cert}],
68+
"Protocol": "HTTPS",
69+
"DefaultActions": [{'Type': 'forward', 'TargetGroupArn': {'Ref': target_group_name}}],
70+
'LoadBalancerArn': {'Ref': lb_name},
71+
"Port": 443
72+
}
73+
}]
74+
75+
76+
def component_elastic_load_balancer_v2(definition,
77+
configuration: dict,
78+
args: TemplateArguments,
79+
info: dict,
80+
force,
81+
account_info: AccountArguments):
82+
lb_name = configuration["Name"]
83+
# domains pointing to the load balancer
84+
subdomain = ''
85+
main_zone = None
86+
for name, domain in configuration.get('Domains', {}).items():
87+
name = '{}{}'.format(lb_name, name)
88+
89+
domain_name = "{0}.{1}".format(domain["Subdomain"], domain["Zone"])
90+
91+
convert_domain_records_to_alias(domain_name)
92+
93+
properties = {"Type": "A",
94+
"Name": domain_name,
95+
"HostedZoneName": domain["Zone"],
96+
"AliasTarget": {"HostedZoneId": {"Fn::GetAtt": [lb_name,
97+
"CanonicalHostedZoneID"]},
98+
"DNSName": {"Fn::GetAtt": [lb_name, "DNSName"]}}}
99+
definition["Resources"][name] = {"Type": "AWS::Route53::RecordSet",
100+
"Properties": properties}
101+
102+
if domain["Type"] == "weighted":
103+
definition["Resources"][name]["Properties"]['Weight'] = 0
104+
definition["Resources"][name]["Properties"]['SetIdentifier'] = "{0}-{1}".format(info["StackName"],
105+
info["StackVersion"])
106+
subdomain = domain['Subdomain']
107+
main_zone = domain['Zone'] # type: str
108+
109+
target_group_name = lb_name + 'TargetGroup'
110+
listeners = configuration.get('Listeners') or get_listeners(
111+
lb_name, target_group_name, subdomain, main_zone, configuration, account_info)
112+
113+
health_check_protocol = "HTTP"
114+
allowed_health_check_protocols = ("HTTP", "TCP", "UDP", "SSL")
115+
if "HealthCheckProtocol" in configuration:
116+
health_check_protocol = configuration["HealthCheckProtocol"]
117+
118+
if health_check_protocol not in allowed_health_check_protocols:
119+
raise click.UsageError('Protocol "{}" is not supported for LoadBalancer'.format(health_check_protocol))
120+
121+
health_check_path = "/ui/"
122+
if "HealthCheckPath" in configuration:
123+
health_check_path = configuration["HealthCheckPath"]
124+
125+
health_check_port = configuration["HTTPPort"]
126+
if "HealthCheckPort" in configuration:
127+
health_check_port = configuration["HealthCheckPort"]
128+
129+
if configuration.get('NameSuffix'):
130+
version = '{}-{}'.format(info["StackVersion"],
131+
configuration['NameSuffix'])
132+
loadbalancer_name = get_load_balancer_name(info["StackName"], version)
133+
del(configuration['NameSuffix'])
134+
else:
135+
loadbalancer_name = get_load_balancer_name(info["StackName"],
136+
info["StackVersion"])
137+
138+
loadbalancer_scheme = "internal"
139+
allowed_loadbalancer_schemes = ("internet-facing", "internal")
140+
if "Scheme" in configuration:
141+
loadbalancer_scheme = configuration["Scheme"]
142+
else:
143+
configuration["Scheme"] = loadbalancer_scheme
144+
145+
if loadbalancer_scheme == 'internet-facing':
146+
click.secho('You are deploying an internet-facing ELB that will be '
147+
'publicly accessible! You should have OAUTH2 and HTTPS '
148+
'in place!', bold=True, err=True)
149+
150+
if loadbalancer_scheme not in allowed_loadbalancer_schemes:
151+
raise click.UsageError('Scheme "{}" is not supported for LoadBalancer'.format(loadbalancer_scheme))
152+
153+
if loadbalancer_scheme == "internal":
154+
loadbalancer_subnet_map = "LoadBalancerInternalSubnets"
155+
else:
156+
loadbalancer_subnet_map = "LoadBalancerSubnets"
157+
158+
tags = [
159+
# Tag "Name"
160+
{
161+
"Key": "Name",
162+
"Value": "{0}-{1}".format(info["StackName"], info["StackVersion"])
163+
},
164+
# Tag "StackName"
165+
{
166+
"Key": "StackName",
167+
"Value": info["StackName"],
168+
},
169+
# Tag "StackVersion"
170+
{
171+
"Key": "StackVersion",
172+
"Value": info["StackVersion"]
173+
}
174+
]
175+
176+
# load balancer
177+
definition["Resources"][lb_name] = {
178+
"Type": "AWS::ElasticLoadBalancingV2::LoadBalancer",
179+
"Properties": {
180+
'Name': loadbalancer_name,
181+
'Scheme': loadbalancer_scheme,
182+
'SecurityGroups': resolve_security_groups(configuration["SecurityGroups"], args.region),
183+
'Subnets': {"Fn::FindInMap": [loadbalancer_subnet_map, {"Ref": "AWS::Region"}, "Subnets"]},
184+
"Tags": tags
185+
}
186+
}
187+
definition["Resources"][target_group_name] = {
188+
'Type': 'AWS::ElasticLoadBalancingV2::TargetGroup',
189+
'Properties': {
190+
'Name': loadbalancer_name,
191+
'HealthCheckIntervalSeconds': '10',
192+
'HealthCheckPath': health_check_path,
193+
'HealthCheckPort': health_check_port,
194+
'HealthCheckProtocol': health_check_protocol,
195+
'HealthCheckTimeoutSeconds': '5',
196+
'HealthyThresholdCount': '2',
197+
'Port': configuration['HTTPPort'],
198+
'Protocol': 'HTTP',
199+
'UnhealthyThresholdCount': '2',
200+
'VpcId': account_info.VpcID, # TODO: support multiple VPCs
201+
'Tags': tags,
202+
'TargetGroupAttributes': [{'Key': 'deregistration_delay.timeout_seconds', 'Value': '60'}]
203+
}
204+
}
205+
for i, listener in enumerate(listeners):
206+
if i == 0:
207+
suffix = ''
208+
else:
209+
suffix = str(i + 1)
210+
definition['Resources'][lb_name + 'Listener' + suffix] = listener
211+
for key, val in configuration.items():
212+
# overwrite any specified properties, but
213+
# ignore our special Senza properties as they are not supported by CF
214+
if key not in SENZA_PROPERTIES:
215+
definition['Resources'][lb_name]['Properties'][key] = val
216+
217+
return definition
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
2+
from senza.components.elastic_load_balancer_v2 import component_elastic_load_balancer_v2
3+
4+
5+
def component_weighted_dns_elastic_load_balancer_v2(definition, configuration, args, info, force, account_info):
6+
if 'Domains' not in configuration:
7+
if 'MainDomain' in configuration:
8+
main_domain = configuration['MainDomain']
9+
main_subdomain, main_zone = account_info.split_domain(main_domain)
10+
del configuration['MainDomain']
11+
else:
12+
main_zone = account_info.Domain
13+
main_subdomain = info['StackName']
14+
15+
if 'VersionDomain' in configuration:
16+
version_domain = configuration['VersionDomain']
17+
version_subdomain, version_zone = account_info.split_domain(version_domain)
18+
del configuration['VersionDomain']
19+
else:
20+
version_zone = account_info.Domain
21+
version_subdomain = '{}-{}'.format(info['StackName'], info['StackVersion'])
22+
23+
configuration['Domains'] = {'MainDomain': {'Type': 'weighted',
24+
'Zone': '{}.'.format(main_zone.rstrip('.')),
25+
'Subdomain': main_subdomain},
26+
'VersionDomain': {'Type': 'standalone',
27+
'Zone': '{}.'.format(version_zone.rstrip('.')),
28+
'Subdomain': version_subdomain}}
29+
return component_elastic_load_balancer_v2(definition, configuration, args, info, force, account_info)

tests/test_components.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
generate_user_data)
2424
from senza.components.weighted_dns_elastic_load_balancer import \
2525
component_weighted_dns_elastic_load_balancer
26+
from senza.components.weighted_dns_elastic_load_balancer_v2 import \
27+
component_weighted_dns_elastic_load_balancer_v2
2628

2729
from fixtures import HOSTED_ZONE_ZO_NE_COM, HOSTED_ZONE_ZO_NE_DEV, boto_resource
2830

@@ -546,7 +548,7 @@ def test_component_auto_scaling_group_custom_tags():
546548
'Name': 'Foo',
547549
'InstanceType': 't2.micro',
548550
'Image': 'foo',
549-
'Tags': [
551+
'Tags': [
550552
{ 'Key': 'Tag1', 'Value': 'alpha' },
551553
{ 'Key': 'Tag2', 'Value': 'beta' }
552554
]
@@ -867,3 +869,55 @@ def test_get_load_balancer_name():
867869

868870
get_load_balancer_name('toolong123456789012345678901234567890',
869871
'1') == 'toolong12345678901234567890123-1'
872+
873+
874+
def test_weighted_dns_load_balancer_v2(monkeypatch, boto_resource):
875+
senza.traffic.DNS_ZONE_CACHE = {}
876+
877+
def my_client(rtype, *args):
878+
if rtype == 'route53':
879+
route53 = MagicMock()
880+
route53.list_hosted_zones.return_value = {'HostedZones': [HOSTED_ZONE_ZO_NE_COM],
881+
'IsTruncated': False,
882+
'MaxItems': '100'}
883+
return route53
884+
return MagicMock()
885+
886+
monkeypatch.setattr('boto3.client', my_client)
887+
888+
configuration = {
889+
"Name": "MyLB",
890+
"SecurityGroups": "",
891+
"HTTPPort": "9999",
892+
'MainDomain': 'great.api.zo.ne.com',
893+
'VersionDomain': 'version.api.zo.ne.com'
894+
}
895+
info = {'StackName': 'foobar', 'StackVersion': '0.1'}
896+
definition = {"Resources": {}}
897+
898+
args = MagicMock()
899+
args.region = "foo"
900+
901+
mock_string_result = MagicMock()
902+
mock_string_result.return_value = "foo"
903+
monkeypatch.setattr('senza.components.elastic_load_balancer_v2.resolve_security_groups', mock_string_result)
904+
905+
m_acm = MagicMock()
906+
m_acm_certificate = MagicMock()
907+
m_acm_certificate.arn = "foo"
908+
m_acm.get_certificates.return_value = iter([m_acm_certificate])
909+
monkeypatch.setattr('senza.components.elastic_load_balancer_v2.ACM', m_acm)
910+
911+
result = component_weighted_dns_elastic_load_balancer_v2(definition,
912+
configuration,
913+
args,
914+
info,
915+
False,
916+
AccountArguments('dummyregion'))
917+
918+
assert 'MyLB' in result["Resources"]
919+
assert 'MyLBListener' in result["Resources"]
920+
assert 'MyLBTargetGroup' in result["Resources"]
921+
922+
assert result['Resources']['MyLBTargetGroup']['Properties']['HealthCheckPort'] == '9999'
923+
assert result['Resources']['MyLBListener']['Properties']['Certificates'] == [{'CertificateArn': 'arn:aws:123'}]

0 commit comments

Comments
 (0)