@@ -19,8 +19,12 @@ package ecs
19
19
import (
20
20
"context"
21
21
"fmt"
22
+ "hash/crc32"
22
23
"io/ioutil"
24
+ "net/url"
23
25
"regexp"
26
+ "sort"
27
+ "strconv"
24
28
"strings"
25
29
26
30
ecsapi "github.com/aws/aws-sdk-go/service/ecs"
@@ -79,8 +83,13 @@ func (b *ecsAPIService) convert(ctx context.Context, project *types.Project) (*c
79
83
80
84
b .createAccessPoints (project , resources , template )
81
85
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 )
84
93
if err != nil {
85
94
return nil , err
86
95
}
@@ -99,7 +108,7 @@ func (b *ecsAPIService) convert(ctx context.Context, project *types.Project) (*c
99
108
return template , nil
100
109
}
101
110
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 {
103
112
taskExecutionRole := b .createTaskExecutionRole (project , service , template )
104
113
taskRole := b .createTaskRole (project , service , template , resources )
105
114
@@ -124,22 +133,57 @@ func (b *ecsAPIService) createService(project *types.Project, service types.Serv
124
133
)
125
134
for _ , port := range service .Ports {
126
135
for net := range service .Networks {
127
- b .createIngress (service , net , port , template , resources )
136
+ b .createIngress (net , port , template , resources )
128
137
}
129
138
130
139
protocol := strings .ToUpper (port .Protocol )
131
140
if resources .loadBalancerType == elbv2 .LoadBalancerTypeEnumApplication {
132
141
// we don't set Https as a certificate must be specified for HTTPS listeners
133
142
protocol = elbv2 .ProtocolEnumHttp
134
143
}
144
+
135
145
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 )
138
146
serviceLB = append (serviceLB , ecs.Service_LoadBalancer {
139
147
ContainerName : service .Name ,
140
148
ContainerPort : int (port .Target ),
141
149
TargetGroupArn : cloudformation .Ref (targetGroupName ),
142
150
})
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
+ }
143
187
}
144
188
145
189
desiredCount := 1
@@ -195,22 +239,22 @@ func (b *ecsAPIService) createService(project *types.Project, service types.Serv
195
239
SchedulingStrategy : ecsapi .SchedulingStrategyReplica ,
196
240
ServiceRegistries : []ecs.Service_ServiceRegistry {serviceRegistry },
197
241
Tags : serviceTags (project , service ),
198
- TaskDefinition : cloudformation .Ref (normalizeResourceName ( taskDefinition ) ),
242
+ TaskDefinition : cloudformation .Ref (taskDefinition ),
199
243
}
200
244
return nil
201
245
}
202
246
203
247
const allProtocols = "-1"
204
248
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 ) {
206
250
protocol := strings .ToUpper (port .Protocol )
207
251
if protocol == "" {
208
252
protocol = allProtocols
209
253
}
210
254
ingress := fmt .Sprintf ("%s%dIngress" , normalizeResourceName (net ), port .Target )
211
255
template .Resources [ingress ] = & ec2.SecurityGroupIngress {
212
256
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 ),
214
258
GroupId : resources .securityGroups [net ],
215
259
FromPort : int (port .Target ),
216
260
IpProtocol : protocol ,
@@ -321,6 +365,174 @@ func (b *ecsAPIService) createListener(service types.ServiceConfig, port types.S
321
365
return listenerName
322
366
}
323
367
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
+
324
536
func (b * ecsAPIService ) createTargetGroup (project * types.Project , service types.ServiceConfig , port types.ServicePortConfig , template * cloudformation.Template , protocol string , vpc string ) string {
325
537
targetGroupName := fmt .Sprintf (
326
538
"%s%s%dTargetGroup" ,
@@ -461,5 +673,21 @@ func volumeResourceName(service string) string {
461
673
}
462
674
463
675
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
465
693
}
0 commit comments