Skip to content
This repository was archived by the owner on Nov 27, 2023. It is now read-only.

Commit 1f86075

Browse files
committed
Implementing LB forwarding rules by URL to ECS tasks
Signed-off-by: flaviostutz <[email protected]>
1 parent dd5f66f commit 1f86075

File tree

3 files changed

+243
-10
lines changed

3 files changed

+243
-10
lines changed

ecs/cloudformation.go

+238-10
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,12 @@ package ecs
1919
import (
2020
"context"
2121
"fmt"
22+
"hash/crc32"
2223
"io/ioutil"
24+
"net/url"
2325
"regexp"
26+
"sort"
27+
"strconv"
2428
"strings"
2529

2630
ecsapi "github.com/aws/aws-sdk-go/service/ecs"
@@ -79,8 +83,13 @@ func (b *ecsAPIService) convert(ctx context.Context, project *types.Project) (*c
7983

8084
b.createAccessPoints(project, resources, template)
8185

82-
for _, service := range project.Services {
83-
err := b.createService(project, service, template, resources)
86+
//order services for idempotence between calls (because of following rule orders)
87+
sort.Slice(project.Services, func(i, j int) bool {
88+
return project.Services[i].Name < project.Services[j].Name
89+
})
90+
91+
for i, service := range project.Services {
92+
err := b.createService(project, service, template, resources, i+1)
8493
if err != nil {
8594
return nil, err
8695
}
@@ -99,7 +108,7 @@ func (b *ecsAPIService) convert(ctx context.Context, project *types.Project) (*c
99108
return template, nil
100109
}
101110

102-
func (b *ecsAPIService) createService(project *types.Project, service types.ServiceConfig, template *cloudformation.Template, resources awsResources) error {
111+
func (b *ecsAPIService) createService(project *types.Project, service types.ServiceConfig, template *cloudformation.Template, resources awsResources, serviceOrder int) error {
103112
taskExecutionRole := b.createTaskExecutionRole(project, service, template)
104113
taskRole := b.createTaskRole(project, service, template, resources)
105114

@@ -124,22 +133,57 @@ func (b *ecsAPIService) createService(project *types.Project, service types.Serv
124133
)
125134
for _, port := range service.Ports {
126135
for net := range service.Networks {
127-
b.createIngress(service, net, port, template, resources)
136+
b.createIngress(net, port, template, resources)
128137
}
129138

130139
protocol := strings.ToUpper(port.Protocol)
131140
if resources.loadBalancerType == elbv2.LoadBalancerTypeEnumApplication {
132141
// we don't set Https as a certificate must be specified for HTTPS listeners
133142
protocol = elbv2.ProtocolEnumHttp
134143
}
144+
135145
targetGroupName := b.createTargetGroup(project, service, port, template, protocol, resources.vpc)
136-
listenerName := b.createListener(service, port, template, targetGroupName, resources.loadBalancer, protocol)
137-
dependsOn = append(dependsOn, listenerName)
138146
serviceLB = append(serviceLB, ecs.Service_LoadBalancer{
139147
ContainerName: service.Name,
140148
ContainerPort: int(port.Target),
141149
TargetGroupArn: cloudformation.Ref(targetGroupName),
142150
})
151+
152+
urls, hasURLExtension := port.Extensions[extensionURLs]
153+
if hasURLExtension {
154+
if resources.loadBalancerType != elbv2.LoadBalancerTypeEnumApplication {
155+
return fmt.Errorf("%s:%d has extension x-aws-loadbalancer_urls, so the loadbalancer must be of type 'application'", service.Name, port.Target)
156+
}
157+
158+
urlss := urls.(string)
159+
for i, url0 := range strings.Split(urlss, " ") {
160+
ruleOrder := serviceOrder*1000 + i
161+
redirOrder := serviceOrder*1000 + 100 + i
162+
httpcertArn := ""
163+
httpcert, ok := project.Extensions[extensionHTTPSCert]
164+
if ok {
165+
httpcertArn = httpcert.(string)
166+
}
167+
168+
//create listener and url rules if not created yet
169+
listenerName, additionalListenerName, err := b.createOrUpdateListenerURLRules(service, port, template, targetGroupName, resources.loadBalancer, url0, httpcertArn, ruleOrder, redirOrder)
170+
if err != nil {
171+
return err
172+
}
173+
if additionalListenerName != "" {
174+
if !elementExists(dependsOn, additionalListenerName) {
175+
dependsOn = append(dependsOn, additionalListenerName)
176+
}
177+
}
178+
if !elementExists(dependsOn, listenerName) {
179+
dependsOn = append(dependsOn, listenerName)
180+
}
181+
}
182+
183+
} else {
184+
listenerName := b.createListener(service, port, template, targetGroupName, resources.loadBalancer, protocol)
185+
dependsOn = append(dependsOn, listenerName)
186+
}
143187
}
144188

145189
desiredCount := 1
@@ -195,22 +239,22 @@ func (b *ecsAPIService) createService(project *types.Project, service types.Serv
195239
SchedulingStrategy: ecsapi.SchedulingStrategyReplica,
196240
ServiceRegistries: []ecs.Service_ServiceRegistry{serviceRegistry},
197241
Tags: serviceTags(project, service),
198-
TaskDefinition: cloudformation.Ref(normalizeResourceName(taskDefinition)),
242+
TaskDefinition: cloudformation.Ref(taskDefinition),
199243
}
200244
return nil
201245
}
202246

203247
const allProtocols = "-1"
204248

205-
func (b *ecsAPIService) createIngress(service types.ServiceConfig, net string, port types.ServicePortConfig, template *cloudformation.Template, resources awsResources) {
249+
func (b *ecsAPIService) createIngress(net string, port types.ServicePortConfig, template *cloudformation.Template, resources awsResources) {
206250
protocol := strings.ToUpper(port.Protocol)
207251
if protocol == "" {
208252
protocol = allProtocols
209253
}
210254
ingress := fmt.Sprintf("%s%dIngress", normalizeResourceName(net), port.Target)
211255
template.Resources[ingress] = &ec2.SecurityGroupIngress{
212256
CidrIp: "0.0.0.0/0",
213-
Description: fmt.Sprintf("%s:%d/%s on %s network", service.Name, port.Target, port.Protocol, net),
257+
Description: fmt.Sprintf("%d/%s on %s network", port.Target, port.Protocol, net),
214258
GroupId: resources.securityGroups[net],
215259
FromPort: int(port.Target),
216260
IpProtocol: protocol,
@@ -321,6 +365,174 @@ func (b *ecsAPIService) createListener(service types.ServiceConfig, port types.S
321365
return listenerName
322366
}
323367

368+
func (b *ecsAPIService) createOrUpdateListenerURLRules(service types.ServiceConfig, port types.ServicePortConfig,
369+
template *cloudformation.Template,
370+
targetGroupName string,
371+
loadBalancer awsResource,
372+
url0 string,
373+
httpcertArn string,
374+
ruleOrder int,
375+
redirOrder int) (string, string, error) {
376+
377+
//parse url
378+
p, err := url.Parse(url0)
379+
if err != nil {
380+
return "", "", fmt.Errorf("%s:%d/%s invalid url. Must be in format 'http://example.com:8880'. err=%s", service.Name, port.Target, url0, err)
381+
}
382+
383+
proto := elbv2.ProtocolEnumHttp
384+
switch p.Scheme {
385+
case "http":
386+
port.Published = 80
387+
case "https":
388+
port.Published = 443
389+
proto = elbv2.ProtocolEnumHttps
390+
default:
391+
return "", "", fmt.Errorf("%s:%d/%s url scheme must be either 'http' or 'https'", service.Name, port.Target, url0)
392+
}
393+
394+
hp := strings.Split(p.Host, ":")
395+
hhost := hp[0]
396+
if len(hp) == 2 {
397+
//found custom port in url
398+
hport, err := strconv.ParseUint(hp[1], 10, 32)
399+
if err != nil {
400+
return "", "", fmt.Errorf("%s:%d/%s invalid url port", service.Name, port.Target, url0)
401+
}
402+
port.Published = uint32(hport)
403+
}
404+
405+
listenerName := fmt.Sprintf(
406+
"%s%dListener",
407+
strings.ToUpper(port.Protocol),
408+
port.Published,
409+
)
410+
411+
//create listener for this url scheme if it doesn't exist yet
412+
//https://stackoverflow.com/questions/53971873/the-target-group-does-not-have-an-associated-load-balancer
413+
certs := []elasticloadbalancingv2.Listener_Certificate{}
414+
if proto == elbv2.ProtocolEnumHttps {
415+
if httpcertArn == "" {
416+
return "", "", fmt.Errorf("%s:%d a valid https certificate is required. check x-aws-loadbalancer_https_certificate", service.Name, port.Target)
417+
}
418+
certs = []elasticloadbalancingv2.Listener_Certificate{
419+
{
420+
CertificateArn: httpcertArn,
421+
},
422+
}
423+
}
424+
425+
listener, ok := template.Resources[listenerName]
426+
if !ok {
427+
//create new listener
428+
listener = &elasticloadbalancingv2.Listener{
429+
DefaultActions: []elasticloadbalancingv2.Listener_Action{
430+
{
431+
Type: elbv2.ActionTypeEnumFixedResponse,
432+
FixedResponseConfig: &elasticloadbalancingv2.Listener_FixedResponseConfig{
433+
StatusCode: "404",
434+
ContentType: "text/plain",
435+
MessageBody: "Page not found",
436+
},
437+
},
438+
},
439+
LoadBalancerArn: loadBalancer.ARN(),
440+
Protocol: proto,
441+
Certificates: certs,
442+
Port: int(port.Published),
443+
}
444+
template.Resources[listenerName] = listener
445+
}
446+
447+
//add forward rules for this url
448+
listenerRuleName := fmt.Sprintf(
449+
"%s%s%dListenerRule%s%s",
450+
normalizeResourceName(service.Name),
451+
strings.ToUpper(port.Protocol),
452+
port.Published,
453+
normalizeResourceName(p.Host),
454+
normalizeResourceName(p.Path),
455+
)
456+
457+
template.Resources[listenerRuleName] = &elasticloadbalancingv2.ListenerRule{
458+
ListenerArn: cloudformation.Ref(listenerName),
459+
Priority: ruleOrder,
460+
Conditions: []elasticloadbalancingv2.ListenerRule_RuleCondition{
461+
{
462+
Field: "host-header",
463+
HostHeaderConfig: &elasticloadbalancingv2.ListenerRule_HostHeaderConfig{
464+
Values: []string{hhost},
465+
},
466+
},
467+
{
468+
Field: "path-pattern",
469+
PathPatternConfig: &elasticloadbalancingv2.ListenerRule_PathPatternConfig{
470+
Values: []string{fmt.Sprintf("%s%s", p.Path, "*")},
471+
},
472+
},
473+
},
474+
Actions: []elasticloadbalancingv2.ListenerRule_Action{
475+
{
476+
Type: "forward",
477+
TargetGroupArn: cloudformation.Ref(targetGroupName),
478+
},
479+
},
480+
}
481+
482+
//http->https redirect rule
483+
additionalListenerName := ""
484+
if proto == elbv2.ProtocolEnumHttps {
485+
listenerName80 := fmt.Sprintf(
486+
"%s%dListener",
487+
strings.ToUpper(port.Protocol),
488+
80,
489+
)
490+
listenerPort := 80
491+
listenerRedirRuleName := fmt.Sprintf(
492+
"%s%s%dListenerRule%s%s",
493+
normalizeResourceName(service.Name),
494+
strings.ToUpper(port.Protocol),
495+
listenerPort,
496+
normalizeResourceName(p.Host),
497+
normalizeResourceName(p.Path),
498+
)
499+
additionalListenerName = listenerName80
500+
501+
template.Resources[listenerRedirRuleName] = &elasticloadbalancingv2.ListenerRule{
502+
ListenerArn: cloudformation.Ref(listenerName80),
503+
Priority: redirOrder,
504+
Conditions: []elasticloadbalancingv2.ListenerRule_RuleCondition{
505+
{
506+
Field: "host-header",
507+
HostHeaderConfig: &elasticloadbalancingv2.ListenerRule_HostHeaderConfig{
508+
Values: []string{hhost},
509+
},
510+
},
511+
{
512+
Field: "path-pattern",
513+
PathPatternConfig: &elasticloadbalancingv2.ListenerRule_PathPatternConfig{
514+
Values: []string{fmt.Sprintf("%s%s", p.Path, "*")},
515+
},
516+
},
517+
},
518+
Actions: []elasticloadbalancingv2.ListenerRule_Action{
519+
{
520+
Type: "redirect",
521+
RedirectConfig: &elasticloadbalancingv2.ListenerRule_RedirectConfig{
522+
Protocol: "HTTPS",
523+
Host: "#{host}",
524+
Port: "443",
525+
Path: "/#{path}",
526+
StatusCode: "HTTP_301",
527+
},
528+
},
529+
},
530+
}
531+
}
532+
533+
return listenerName, additionalListenerName, nil
534+
}
535+
324536
func (b *ecsAPIService) createTargetGroup(project *types.Project, service types.ServiceConfig, port types.ServicePortConfig, template *cloudformation.Template, protocol string, vpc string) string {
325537
targetGroupName := fmt.Sprintf(
326538
"%s%s%dTargetGroup",
@@ -461,5 +673,21 @@ func volumeResourceName(service string) string {
461673
}
462674

463675
func normalizeResourceName(s string) string {
464-
return strings.Title(regexp.MustCompile("[^a-zA-Z0-9]+").ReplaceAllString(s, ""))
676+
chk := fmt.Sprintf("%08x", crc32.ChecksumIEEE([]byte(s)))
677+
ts := strings.Title(regexp.MustCompile("[^a-zA-Z0-9]+").ReplaceAllString(s, ""))
678+
if len(ts) > 6 {
679+
ts = ts[:6]
680+
}
681+
return fmt.Sprintf("%s%s", ts, chk)
682+
}
683+
684+
func elementExists(source []string, elem string) bool {
685+
found := false
686+
for _, do := range source {
687+
if do == elem {
688+
found = true
689+
break
690+
}
691+
}
692+
return found
465693
}

ecs/x.go

+2
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,6 @@ const (
3030
extensionRole = "x-aws-role"
3131
extensionManagedPolicies = "x-aws-policies"
3232
extensionAutoScaling = "x-aws-autoscaling"
33+
extensionURLs = "x-aws-loadbalancer_urls"
34+
extensionHTTPSCert = "x-aws-loadbalancer_https_certificate"
3335
)

progress/tty.go

+3
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ func lineText(event Event, terminalWidth, statusPadding int, color bool) string
149149
// calculate the max length for the status text, on errors it
150150
// is 2-3 lines long and breaks the line formating
151151
maxStatusLen := terminalWidth - textLen - statusPadding - 15
152+
if maxStatusLen < 0 {
153+
maxStatusLen = 0
154+
}
152155
status := event.StatusText
153156
if len(status) > maxStatusLen {
154157
status = status[:maxStatusLen] + "..."

0 commit comments

Comments
 (0)