Skip to content

Commit

Permalink
feat: enable importing public-facing ALBs (aws#5438)
Browse files Browse the repository at this point in the history
Import an existing ALB that is within the deployment environment's VPC by adding
```
http:
  alb: [name or ARN of ALB]
```
to a Load-Balanced Web Service's manifest. 

Related: aws#3319, aws#1457, aws#3936.


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the Apache 2.0 License.
  • Loading branch information
huanjani authored Nov 8, 2023
1 parent 752e751 commit 80d4794
Show file tree
Hide file tree
Showing 34 changed files with 904 additions and 101 deletions.
10 changes: 5 additions & 5 deletions cf-custom-resources/lib/alb-rule-priority-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

const aws = require("aws-sdk");

// minPriorityForRootRule is the min priority number for the the root path "/".
// minPriorityForRootRule is the min priority number for the root path "/".
const minPriorityForRootRule = 48000;
// maxPriorityForRootRule is the max priority number for the the root path "/".
// maxPriorityForRootRule is the max priority number for the root path "/".
const maxPriorityForRootRule = 50000;

// These are used for test purposes only
Expand Down Expand Up @@ -75,7 +75,7 @@ let report = function (
};

/**
* Lists all the existing rules for a ALB Listener, finds the max of their
* Lists all the existing rules for an ALB Listener, finds the max of their
* priorities, and then returns max + 1.
*
* @param {string} listenerArn the ARN of the ALB listener.
Expand All @@ -93,7 +93,7 @@ const calculateNextRulePriority = async function (listenerArn) {
rule.Priority >= minPriorityForRootRule
) {
// Ignore the root rule's priority.
// Ignore the default rule's prority since it's the same as 0.
// Ignore the default rule's priority since it's the same as 0.
return 0;
}
return parseInt(rule.Priority);
Expand All @@ -104,7 +104,7 @@ const calculateNextRulePriority = async function (listenerArn) {
};

/**
* Lists all the existing rules for a ALB Listener, finds the min of their root rule
* Lists all the existing rules for an ALB Listener, finds the min of their root rule
* priorities, and then returns min - 1.
*
* @param {string} listenerArn the ARN of the ALB listener.
Expand Down
2 changes: 1 addition & 1 deletion internal/pkg/aws/cloudformation/testdata/parse/env.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ Resources:

# Adds records for this environment's hostedzone
# into the application's hostedzone. This lets this
# environment own the DNS of the it's subdomain.
# environment own the DNS of its subdomain.
DelegateDNSAction:
Condition: DelegateDNS
Type: Custom::DNSDelegationFunction
Expand Down
42 changes: 30 additions & 12 deletions internal/pkg/aws/elbv2/elbv2.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ type LoadBalancer struct {
ARN string
Name string
DNSName string
HostedZoneID string
Listeners []Listener
Scheme string // "internet-facing" or "internal"
SecurityGroups []string
}
Expand All @@ -186,11 +188,17 @@ func (e *ELBV2) LoadBalancer(nameOrARN string) (*LoadBalancer, error) {
return nil, fmt.Errorf("no load balancer %q found", nameOrARN)
}
lb := output.LoadBalancers[0]
listeners, err := e.listeners(aws.StringValue(lb.LoadBalancerArn))
if err != nil {
return nil, err
}
return &LoadBalancer{
ARN: aws.StringValue(lb.LoadBalancerArn),
Name: aws.StringValue(lb.LoadBalancerName),
DNSName: aws.StringValue(lb.DNSName),
Scheme: aws.StringValue(lb.Scheme),
HostedZoneID: aws.StringValue(lb.CanonicalHostedZoneId),
Listeners: listeners,
SecurityGroups: aws.StringValueSlice(lb.SecurityGroups),
}, nil
}
Expand All @@ -202,19 +210,29 @@ type Listener struct {
Protocol string
}

// Listeners returns select information about all listeners on a given load balancer.
func (e *ELBV2) Listeners(lbARN string) ([]Listener, error) {
output, err := e.client.DescribeListeners(&elbv2.DescribeListenersInput{LoadBalancerArn: aws.String(lbARN)})
if err != nil {
return nil, fmt.Errorf("describe listeners on load balancer %q: %w", lbARN, err)
}
// listeners returns select information about all listeners on a given load balancer.
func (e *ELBV2) listeners(lbARN string) ([]Listener, error) {
var listeners []Listener
for _, listener := range output.Listeners {
listeners = append(listeners, Listener{
ARN: aws.StringValue(listener.ListenerArn),
Port: aws.Int64Value(listener.Port),
Protocol: aws.StringValue(listener.Protocol),
})
in := &elbv2.DescribeListenersInput{LoadBalancerArn: aws.String(lbARN)}
for {
output, err := e.client.DescribeListeners(in)
if err != nil {
return nil, fmt.Errorf("describe listeners on load balancer %q: %w", lbARN, err)
}
if output == nil {
break
}
for _, listener := range output.Listeners {
listeners = append(listeners, Listener{
ARN: aws.StringValue(listener.ListenerArn),
Port: aws.Int64Value(listener.Port),
Protocol: aws.StringValue(listener.Protocol),
})
}
if output.NextMarker == nil {
break
}
in.Marker = output.NextMarker
}
return listeners, nil
}
88 changes: 71 additions & 17 deletions internal/pkg/aws/elbv2/elbv2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ func TestELBV2_ListenerRuleHostHeaders(t *testing.T) {
},
wanted: []string{"archer.com", "copilot.com"},
},
"succes in case of multiple rules": {
"success in case of multiple rules": {
inARNs: []string{mockARN1, mockARN2},
setUpMock: func(m *mocks.Mockapi) {
m.EXPECT().DescribeRules(&elbv2.DescribeRulesInput{
Expand Down Expand Up @@ -416,11 +416,12 @@ func TestELBV2_LoadBalancer(t *testing.T) {
mockOutput := &elbv2.DescribeLoadBalancersOutput{
LoadBalancers: []*elbv2.LoadBalancer{
{
LoadBalancerArn: aws.String("mockLBARN"),
LoadBalancerName: aws.String("mockLBName"),
DNSName: aws.String("mockDNSName"),
Scheme: aws.String("internet-facing"),
SecurityGroups: aws.StringSlice([]string{"sg1", "sg2"}),
LoadBalancerArn: aws.String("mockLBARN"),
LoadBalancerName: aws.String("mockLBName"),
DNSName: aws.String("mockDNSName"),
Scheme: aws.String("internet-facing"),
CanonicalHostedZoneId: aws.String("mockHostedZoneID"),
SecurityGroups: aws.StringSlice([]string{"sg1", "sg2"}),
},
},
}
Expand All @@ -437,12 +438,33 @@ func TestELBV2_LoadBalancer(t *testing.T) {
m.EXPECT().DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{
Names: []*string{aws.String("loadBalancerName")},
}).Return(mockOutput, nil)
m.EXPECT().DescribeListeners(&elbv2.DescribeListenersInput{
LoadBalancerArn: aws.String("mockLBARN"),
}).Return(&elbv2.DescribeListenersOutput{
Listeners: []*elbv2.Listener{
{
ListenerArn: aws.String("mockListenerARN"),
Port: aws.Int64(80),
Protocol: aws.String("http"),
},
},
NextMarker: nil,
}, nil)
},

expectedLB: &LoadBalancer{
ARN: "mockLBARN",
Name: "mockLBName",
DNSName: "mockDNSName",
Scheme: "internet-facing",
ARN: "mockLBARN",
Name: "mockLBName",
DNSName: "mockDNSName",
Scheme: "internet-facing",
HostedZoneID: "mockHostedZoneID",
Listeners: []Listener{
{
ARN: "mockListenerARN",
Port: 80,
Protocol: "http",
},
},
SecurityGroups: []string{"sg1", "sg2"},
},
},
Expand All @@ -451,16 +473,36 @@ func TestELBV2_LoadBalancer(t *testing.T) {
setUpMock: func(m *mocks.Mockapi) {
m.EXPECT().DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{
LoadBalancerArns: []*string{aws.String("arn:aws:elasticloadbalancing:us-west-2:123594734248:loadbalancer/app/ALBForImport/8db123b49az6de94")}}).Return(mockOutput, nil)
m.EXPECT().DescribeListeners(&elbv2.DescribeListenersInput{
LoadBalancerArn: aws.String("mockLBARN"),
}).Return(&elbv2.DescribeListenersOutput{
Listeners: []*elbv2.Listener{
{
ListenerArn: aws.String("mockListenerARN"),
Port: aws.Int64(80),
Protocol: aws.String("http"),
},
},
NextMarker: nil,
}, nil)
},
expectedLB: &LoadBalancer{
ARN: "mockLBARN",
Name: "mockLBName",
DNSName: "mockDNSName",
Scheme: "internet-facing",
ARN: "mockLBARN",
Name: "mockLBName",
DNSName: "mockDNSName",
Scheme: "internet-facing",
HostedZoneID: "mockHostedZoneID",
Listeners: []Listener{
{
ARN: "mockListenerARN",
Port: 80,
Protocol: "http",
},
},
SecurityGroups: []string{"sg1", "sg2"},
},
},
"error if describe call fails": {
"error if describe LB call fails": {
mockID: "loadBalancerName",
setUpMock: func(m *mocks.Mockapi) {
m.EXPECT().DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{
Expand All @@ -479,6 +521,18 @@ func TestELBV2_LoadBalancer(t *testing.T) {
},
expectedErr: `no load balancer "mockLBName" found`,
},
"error if describe listeners call fails": {
mockID: "loadBalancerName",
setUpMock: func(m *mocks.Mockapi) {
m.EXPECT().DescribeLoadBalancers(&elbv2.DescribeLoadBalancersInput{
Names: []*string{aws.String("loadBalancerName")},
}).Return(mockOutput, nil)
m.EXPECT().DescribeListeners(&elbv2.DescribeListenersInput{
LoadBalancerArn: aws.String("mockLBARN"),
}).Return(nil, errors.New("some error"))
},
expectedErr: `describe listeners on load balancer "mockLBARN": some error`,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
Expand All @@ -502,7 +556,7 @@ func TestELBV2_LoadBalancer(t *testing.T) {
}
}

func TestELBV2_Listeners(t *testing.T) {
func TestELBV2_listeners(t *testing.T) {
mockLBARN := aws.String("mockLoadBalancerARN")
mockOutput := &elbv2.DescribeListenersOutput{
Listeners: []*elbv2.Listener{
Expand Down Expand Up @@ -564,7 +618,7 @@ func TestELBV2_Listeners(t *testing.T) {
client: mockAPI,
}

actual, err := elbv2Client.Listeners(aws.StringValue(mockLBARN))
actual, err := elbv2Client.listeners(aws.StringValue(mockLBARN))
if tc.expectedErr != "" {
require.EqualError(t, err, tc.expectedErr)
} else {
Expand Down
54 changes: 50 additions & 4 deletions internal/pkg/cli/deploy/lbws.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package deploy

import (
"fmt"
"github.com/aws/copilot-cli/internal/pkg/aws/elbv2"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
Expand Down Expand Up @@ -47,10 +48,15 @@ type publicCIDRBlocksGetter interface {
PublicCIDRBlocks() ([]string, error)
}

type elbGetter interface {
LoadBalancer(nameOrARN string) (*elbv2.LoadBalancer, error)
}

type lbWebSvcDeployer struct {
*svcDeployer
appVersionGetter versionGetter
publicCIDRBlocksGetter publicCIDRBlocksGetter
elbGetter elbGetter
lbMft *manifest.LoadBalancedWebService

// Overriden in tests.
Expand Down Expand Up @@ -93,6 +99,7 @@ func NewLBWSDeployer(in *WorkloadDeployerInput) (*lbWebSvcDeployer, error) {
svcDeployer: svcDeployer,
appVersionGetter: versionGetter,
publicCIDRBlocksGetter: envDescriber,
elbGetter: elbv2.New(svcDeployer.envSess),
lbMft: lbMft,
newAliasCertValidator: func(optionalRegion *string) aliasCertValidator {
sess := svcDeployer.envSess.Copy(&aws.Config{
Expand Down Expand Up @@ -154,6 +161,13 @@ func (d *lbWebSvcDeployer) stackConfiguration(in *StackRuntimeConfiguration) (*s
if err := d.validateNLBRuntime(); err != nil {
return nil, err
}
var appHostedZoneID string
if d.app.Domain != "" {
appHostedZoneID, err = appDomainHostedZoneId(d.app.Name, d.app.Domain, d.domainHostedZoneGetter)
if err != nil {
return nil, err
}
}
var opts []stack.LoadBalancedWebServiceOption
if !d.lbMft.NLBConfig.IsEmpty() {
cidrBlocks, err := d.publicCIDRBlocksGetter.PublicCIDRBlocks()
Expand All @@ -162,12 +176,12 @@ func (d *lbWebSvcDeployer) stackConfiguration(in *StackRuntimeConfiguration) (*s
}
opts = append(opts, stack.WithNLB(cidrBlocks))
}
var appHostedZoneID string
if d.app.Domain != "" {
appHostedZoneID, err = appDomainHostedZoneId(d.app.Name, d.app.Domain, d.domainHostedZoneGetter)
if d.lbMft.HTTPOrBool.ImportedALB != nil {
lb, err := d.elbGetter.LoadBalancer(aws.StringValue(d.lbMft.HTTPOrBool.ImportedALB))
if err != nil {
return nil, err
}
opts = append(opts, stack.WithImportedALB(lb))
}

var conf cloudformation.StackConfiguration
Expand Down Expand Up @@ -204,6 +218,10 @@ func (d *lbWebSvcDeployer) validateALBRuntime() error {
return nil
}

if err := d.validateImportedALBConfig(); err != nil {
return fmt.Errorf(`validate imported ALB configuration for "http": %w`, err)
}

if err := d.validateRuntimeRoutingRule(d.lbMft.HTTPOrBool.Main); err != nil {
return fmt.Errorf(`validate ALB runtime configuration for "http": %w`, err)
}
Expand All @@ -216,6 +234,34 @@ func (d *lbWebSvcDeployer) validateALBRuntime() error {
return nil
}

func (d *lbWebSvcDeployer) validateImportedALBConfig() error {
if d.lbMft.HTTPOrBool.ImportedALB == nil {
return nil
}
alb, err := d.elbGetter.LoadBalancer(aws.StringValue(d.lbMft.HTTPOrBool.ImportedALB))
if err != nil {
return fmt.Errorf(`retrieve load balancer %q: %w`, aws.StringValue(d.lbMft.HTTPOrBool.ImportedALB), err)
}
if len(alb.Listeners) == 0 || len(alb.Listeners) > 2 {
return fmt.Errorf(`imported ALB %q must have either one or two listeners`, alb.ARN)
}
if len(alb.Listeners) == 1 {
return nil
}
var isHTTP, isHTTPS bool
for _, listener := range alb.Listeners {
if listener.Protocol == "HTTP" {
isHTTP = true
} else if listener.Protocol == "HTTPS" {
isHTTPS = true
}
}
if !(isHTTP && isHTTPS) {
return fmt.Errorf("imported ALB must have listeners of protocol HTTP and HTTPS")
}
return nil
}

func (d *lbWebSvcDeployer) validateRuntimeRoutingRule(rule manifest.RoutingRule) error {
hasALBCerts := len(d.envConfig.HTTPConfig.Public.Certificates) != 0
hasCDNCerts := d.envConfig.CDNConfig.Config.Certificate != nil
Expand All @@ -224,7 +270,7 @@ func (d *lbWebSvcDeployer) validateRuntimeRoutingRule(rule manifest.RoutingRule)
return fmt.Errorf("cannot configure http to https redirect without having a domain associated with the app %q or importing any certificates in env %q", d.app.Name, d.env.Name)
}
if rule.Alias.IsEmpty() {
if hasImportedCerts {
if hasImportedCerts && d.lbMft.HTTPOrBool.ImportedALB == nil {
return &errSvcWithNoALBAliasDeployingToEnvWithImportedCerts{
name: d.name,
envName: d.env.Name,
Expand Down
Loading

0 comments on commit 80d4794

Please sign in to comment.