Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add networkInterfaces configuration to launchTemplate #4353

Closed
wants to merge 24 commits into from
Closed
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ce9358b
feat: add networkInterfaces configuration to launchTemplate
myaser Jul 29, 2023
8a7bf6e
add the missing DeviceIndex Description fields to fix the tests
myaser Aug 18, 2023
8e7413c
fix e2e tests
myaser Aug 21, 2023
653067d
Update test/suites/integration/networkinterfaces_test.go
myaser Aug 22, 2023
9fcde73
Update test/suites/integration/networkinterfaces_test.go
myaser Aug 22, 2023
ad8d96f
Update test/suites/integration/networkinterfaces_test.go
myaser Aug 22, 2023
4c557cb
fix e2e tests
myaser Aug 22, 2023
ea041f6
fix subnet revert in e2e test
bwagner5 Aug 30, 2023
88e1c8f
fix mem alias
bwagner5 Sep 6, 2023
2891487
use v1beta1 apis
bwagner5 Sep 6, 2023
e45ce8f
v1beta1 conversion
bwagner5 Sep 6, 2023
66e3dca
Merge remote-tracking branch 'origin/main' into networkinterfaces-sim…
myaser Sep 14, 2023
fd0915a
Merge remote-tracking branch 'origin/main' into networkinterfaces-sim…
myaser Sep 15, 2023
d96a876
Merge branch 'main' into networkinterfaces-simplified
myaser Sep 18, 2023
4b58068
Merge remote-tracking branch 'origin/main' into networkinterfaces-sim…
myaser Oct 4, 2023
f715164
Merge branch 'main' into networkinterfaces-simplified
myaser Oct 5, 2023
93f2cf9
Merge remote-tracking branch 'upstream/main' into networkinterfaces-s…
jmdeal Oct 25, 2023
d37cd44
v1beta1 only + integration test fixes
jmdeal Oct 25, 2023
9f5a0df
fix merge errors
jmdeal Oct 26, 2023
6771dee
Merge branch 'main' into networkinterfaces-simplified
jmdeal Oct 26, 2023
3ecd2d2
formatting fix
jmdeal Oct 26, 2023
44d2973
update docs
jmdeal Oct 26, 2023
9be6ada
remove focus from e2e test
jmdeal Oct 26, 2023
3d378e3
Merge remote-tracking branch 'upstream/main' into networkinterfaces-s…
jmdeal Oct 26, 2023
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
17 changes: 17 additions & 0 deletions pkg/apis/crds/karpenter.k8s.aws_ec2nodeclasses.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,23 @@ spec:
- optional
type: string
type: object
networkInterfaces:
description: NetworkInterfaces to be applied to provisioned nodes.
items:
properties:
associatePublicIPAddress:
description: Associates a public IPv4 address with eth0 for
a new network interface.
type: boolean
description:
description: A description for the network interface.
type: string
deviceIndex:
description: The device index for the network interface attachment.
format: int64
type: integer
type: object
type: array
role:
description: Role is the AWS identity that nodes use. This field is
immutable. Marking this field as immutable avoids concerns around
Expand Down
15 changes: 15 additions & 0 deletions pkg/apis/v1beta1/ec2nodeclass.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ type EC2NodeClassSpec struct {
// +kubebuilder:default={"httpEndpoint":"enabled","httpProtocolIPv6":"disabled","httpPutResponseHopLimit":2,"httpTokens":"required"}
// +optional
MetadataOptions *MetadataOptions `json:"metadataOptions,omitempty"`
// NetworkInterfaces to be applied to provisioned nodes.
// +optional
NetworkInterfaces []*NetworkInterface `json:"networkInterfaces,omitempty"`
// Context is a Reserved field in EC2 APIs
// https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_CreateFleet.html
// +optional
Expand Down Expand Up @@ -312,6 +315,18 @@ type BlockDevice struct {
VolumeType *string `json:"volumeType,omitempty"`
}

type NetworkInterface struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason that all of these values need to be pointers? I think an empty value is probably equal to not set? Typically, you need pointers when an empty value is still a valid value, but I'm not sure that that's the case for any of these fields

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll need to double check but I believe the zero value for AssociatePublicIPAddress would not be an acceptable default. If it's not specified in the EC2NodeClass we don't want to specify it in the launch template either that way we don't override the subnets behavior.


// Associates a public IPv4 address with eth0 for a new network interface.
AssociatePublicIPAddress *bool `json:"associatePublicIPAddress,omitempty"`

// A description for the network interface.
Description *string `json:"description,omitempty"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How necessary is the description here? This feels ancillary and probably something that we can wait to add until users ask for it

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not really necessary, its used in tests to grab the correct network interface but that can be done with the device index.


// The device index for the network interface attachment.
DeviceIndex *int64 `json:"deviceIndex,omitempty"`
}

// EC2NodeClass is the Schema for the EC2NodeClass API
// +kubebuilder:object:root=true
// +kubebuilder:resource:path=ec2nodeclasses,scope=Cluster,categories=karpenter,shortName={ec2nc,ec2ncs}
Expand Down
41 changes: 41 additions & 0 deletions pkg/apis/v1beta1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pkg/providers/amifamily/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ type LaunchTemplate struct {
AMIID string
InstanceTypes []*cloudprovider.InstanceType `hash:"ignore"`
DetailedMonitoring bool
NetworkInterfaces []*v1beta1.NetworkInterface
}

// AMIFamily can be implemented to override the default logic for generating dynamic launch template parameters
Expand Down Expand Up @@ -161,6 +162,7 @@ func (r Resolver) Resolve(ctx context.Context, nodeClass *v1beta1.EC2NodeClass,
DetailedMonitoring: aws.BoolValue(nodeClass.Spec.DetailedMonitoring),
AMIID: amiID,
InstanceTypes: instanceTypes,
NetworkInterfaces: nodeClass.Spec.NetworkInterfaces,
}
if len(resolved.BlockDeviceMappings) == 0 {
resolved.BlockDeviceMappings = amiFamily.DefaultBlockDeviceMappings()
Expand Down
36 changes: 25 additions & 11 deletions pkg/providers/launchtemplate/launchtemplate.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ func (p *Provider) createLaunchTemplate(ctx context.Context, options *amifamily.
if err != nil {
return nil, err
}
networkInterface := p.generateNetworkInterface(options)
networkInterface := p.generateNetworkInterfaces(options)
output, err := p.ec2api.CreateLaunchTemplateWithContext(ctx, &ec2.CreateLaunchTemplateInput{
LaunchTemplateName: aws.String(launchTemplateName(options)),
LaunchTemplateData: &ec2.RequestLaunchTemplateData{
Expand Down Expand Up @@ -275,22 +275,36 @@ func (p *Provider) createLaunchTemplate(ctx context.Context, options *amifamily.
return output.LaunchTemplate, nil
}

// generateNetworkInterface generates a network interface for the launch template.
// generateNetworkInterfaces generates a network interface for the launch template.
// If all referenced subnets do not assign public IPv4 addresses to EC2 instances therein, we explicitly set
// AssociatePublicIpAddress to 'false' in the Launch Template, generated based on this configuration struct.
// This is done to help comply with AWS account policies that require explicitly setting that field to 'false'.
// https://github.com/aws/karpenter/issues/3815
func (p *Provider) generateNetworkInterface(options *amifamily.LaunchTemplate) []*ec2.LaunchTemplateInstanceNetworkInterfaceSpecificationRequest {
if options.AssociatePublicIPAddress != nil {
return []*ec2.LaunchTemplateInstanceNetworkInterfaceSpecificationRequest{
{
AssociatePublicIpAddress: options.AssociatePublicIPAddress,
DeviceIndex: aws.Int64(0),
Groups: lo.Map(options.SecurityGroups, func(s v1beta1.SecurityGroup, _ int) *string { return aws.String(s.ID) }),
},
func (p *Provider) generateNetworkInterfaces(options *amifamily.LaunchTemplate) []*ec2.LaunchTemplateInstanceNetworkInterfaceSpecificationRequest {
if len(options.NetworkInterfaces) == 0 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking we should drop this behavior now, looking at the quoted issue it looks like the desired long term solution was adding these explicit fields. Thoughts @jonathan-innis?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could see us marking this behavior as deprecated and then choosing to drop this behavior entirely at v1. We can add this to the v1 laundry list that is getting tracked in this issue: #4993

if options.AssociatePublicIPAddress != nil {
return []*ec2.LaunchTemplateInstanceNetworkInterfaceSpecificationRequest{
{
AssociatePublicIpAddress: options.AssociatePublicIPAddress,
DeviceIndex: aws.Int64(0),
Groups: lo.Map(options.SecurityGroups, func(s v1beta1.SecurityGroup, _ int) *string { return aws.String(s.ID) }),
},
}
}
return nil
}
return nil

var networkInterfacesRequest []*ec2.LaunchTemplateInstanceNetworkInterfaceSpecificationRequest
for _, networkInterface := range options.NetworkInterfaces {
networkInterfacesRequest = append(networkInterfacesRequest, &ec2.LaunchTemplateInstanceNetworkInterfaceSpecificationRequest{
AssociatePublicIpAddress: networkInterface.AssociatePublicIPAddress,
DeviceIndex: networkInterface.DeviceIndex,
Description: networkInterface.Description,
Groups: lo.Map(options.SecurityGroups, func(s v1beta1.SecurityGroup, _ int) *string { return aws.String(s.ID) }),
})
}
return networkInterfacesRequest

}

func (p *Provider) blockDeviceMappings(blockDeviceMappings []*v1beta1.BlockDeviceMapping) []*ec2.LaunchTemplateBlockDeviceMappingRequest {
Expand Down
62 changes: 59 additions & 3 deletions pkg/providers/launchtemplate/nodeclass_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1563,7 +1563,7 @@ var _ = Describe("EC2NodeClass/LaunchTemplates", func() {
Expect(*input.LaunchTemplateData.ImageId).To(ContainSubstring("test-ami"))
})
})
Context("Subnet-based Launch Template Configration", func() {
Context("NetworkInterfaces", func() {
It("should explicitly set 'AssignPublicIPv4' to false in the Launch Template", func() {
nodeClass.Spec.SubnetSelectorTerms = []v1beta1.SubnetSelectorTerm{{Tags: map[string]string{"Name": "test-subnet-1,test-subnet-3"}}}
ExpectApplied(ctx, env.Client, nodePool, nodeClass)
Expand All @@ -1573,8 +1573,23 @@ var _ = Describe("EC2NodeClass/LaunchTemplates", func() {
input := awsEnv.EC2API.CalledWithCreateLaunchTemplateInput.Pop()
Expect(*input.LaunchTemplateData.NetworkInterfaces[0].AssociatePublicIpAddress).To(BeFalse())
})

It("should not explicitly set 'AssignPublicIPv4' when the subnets are configured to assign public IPv4 addresses", func() {
It("should overwrite 'AssignPublicIPv4' to true when specified by user in the EC2NodeClass.NetworkInterfaces", func() {
nodeClass.Spec.SubnetSelectorTerms = []v1beta1.SubnetSelectorTerm{{Tags: map[string]string{"Name": "test-subnet-1,test-subnet-3"}}}
nodeClass.Spec.NetworkInterfaces = []*v1beta1.NetworkInterface{
{
AssociatePublicIPAddress: aws.Bool(true),
DeviceIndex: aws.Int64(0),
},
}
ExpectApplied(ctx, env.Client, nodePool, nodeClass)
pod := coretest.UnschedulablePod()
ExpectProvisioned(ctx, env.Client, cluster, cloudProvider, prov, pod)
ExpectScheduled(ctx, env.Client, pod)
input := awsEnv.EC2API.CalledWithCreateLaunchTemplateInput.Pop()
Expect(*input.LaunchTemplateData.NetworkInterfaces[0].AssociatePublicIpAddress).To(BeTrue())
Expect(len(input.LaunchTemplateData.SecurityGroupIds)).To(BeNumerically("==", 0))
})
It("should not explicitly set 'AssignPublicIPv4' when the subnets are configured to assign public IPv4 addresses and the user did not specify otherwise", func() {
nodeClass.Spec.SubnetSelectorTerms = []v1beta1.SubnetSelectorTerm{{Tags: map[string]string{"Name": "test-subnet-2"}}}
ExpectApplied(ctx, env.Client, nodePool, nodeClass)
pod := coretest.UnschedulablePod()
Expand All @@ -1583,6 +1598,47 @@ var _ = Describe("EC2NodeClass/LaunchTemplates", func() {
input := awsEnv.EC2API.CalledWithCreateLaunchTemplateInput.Pop()
Expect(len(input.LaunchTemplateData.NetworkInterfaces)).To(BeNumerically("==", 0))
})
It("should use the same securityGroup for all networkInterfaces ", func() {
nodeClass.Spec.SubnetSelectorTerms = []v1beta1.SubnetSelectorTerm{{Tags: map[string]string{"Name": "test-subnet-2"}}}
nodeClass.Spec.NetworkInterfaces = []*v1beta1.NetworkInterface{
{
DeviceIndex: aws.Int64(0),
},
{
DeviceIndex: aws.Int64(1),
},
}
ExpectApplied(ctx, env.Client, nodePool, nodeClass)
pod := coretest.UnschedulablePod()
ExpectProvisioned(ctx, env.Client, cluster, cloudProvider, prov, pod)
ExpectScheduled(ctx, env.Client, pod)
input := awsEnv.EC2API.CalledWithCreateLaunchTemplateInput.Pop()
Expect(len(input.LaunchTemplateData.SecurityGroupIds)).To(BeNumerically("==", 0))
Expect(len(input.LaunchTemplateData.NetworkInterfaces)).To(BeNumerically("==", 2))
Expect(input.LaunchTemplateData.NetworkInterfaces[0].Groups).To(Equal(input.LaunchTemplateData.NetworkInterfaces[1].Groups))
})
It("should match the values of AWSNodeTemplate.NetworkInterfaces", func() {
nodeClass.Spec.SubnetSelectorTerms = []v1beta1.SubnetSelectorTerm{{Tags: map[string]string{"Name": "test-subnet-2"}}}
nodeClass.Spec.NetworkInterfaces = []*v1beta1.NetworkInterface{
{
AssociatePublicIPAddress: aws.Bool(true),
Description: aws.String("example"),
DeviceIndex: aws.Int64(1),
},
}
ExpectApplied(ctx, env.Client, nodePool, nodeClass)
pod := coretest.UnschedulablePod()
ExpectProvisioned(ctx, env.Client, cluster, cloudProvider, prov, pod)
ExpectScheduled(ctx, env.Client, pod)
input := awsEnv.EC2API.CalledWithCreateLaunchTemplateInput.Pop()
Expect(len(input.LaunchTemplateData.SecurityGroupIds)).To(BeNumerically("==", 0))
Expect(len(input.LaunchTemplateData.NetworkInterfaces)).To(BeNumerically("==", 1))
Expect(input.LaunchTemplateData.NetworkInterfaces[0].AssociatePublicIpAddress).To(HaveValue(BeTrue()))

Expect(input.LaunchTemplateData.NetworkInterfaces[0].Description).To(HaveValue(Equal("example")))
Expect(input.LaunchTemplateData.NetworkInterfaces[0].DeviceIndex).To(HaveValue(BeNumerically("==", 1)))
Expect(len(input.LaunchTemplateData.NetworkInterfaces[0].Groups)).To(BeNumerically(">", 0))
})
})
Context("Kubelet Args", func() {
It("should specify the --dns-cluster-ip flag when clusterDNSIP is set", func() {
Expand Down
96 changes: 96 additions & 0 deletions test/suites/beta/integration/networkinterfaces_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package integration_test

import (
"github.com/aws/aws-sdk-go/service/ec2"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/samber/lo"

"github.com/aws/karpenter-core/pkg/test"
"github.com/aws/karpenter/pkg/apis/v1beta1"
)

var _ = FDescribe("NetworkInterfaces", func() {
BeforeEach(func() {
// Ensure that nodes schedule to private subnets. If a node without a public IP is assigned to a public subnet,
// and that subnet does not contain any private endpoints to the cluster, the node will be unable to join the
// cluster.
nodeClass.Spec.SubnetSelectorTerms = []v1beta1.SubnetSelectorTerm{
{
Tags: map[string]string{
"Name": "*Private*",
"karpenter.sh/discovery": env.ClusterName,
},
},
}
})

DescribeTable(
"should correctly create NetworkInterfaces",
func(interfaces ...*v1beta1.NetworkInterface) {
nodeClass.Spec.NetworkInterfaces = interfaces
pod := test.Pod()
env.ExpectCreated(pod, nodeClass, nodePool)
env.EventuallyExpectHealthy(pod)
env.ExpectCreatedNodeCount("==", 1)
instance := env.GetInstance(pod.Spec.NodeName)
for _, interfaceSpec := range interfaces {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we have a function that grabs the interfaces from the instance? Can we leverage this function rather than grabbing the instance and doing things with checking the fields?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll take a look 👍

ni, ok := lo.Find(instance.NetworkInterfaces, func(ni *ec2.InstanceNetworkInterface) bool {
if ni.Description == nil {
return false
}
return *ni.Description == *interfaceSpec.Description
})
Expect(ok).To(BeTrue())
Expect(ni.Attachment).To(HaveField("DeviceIndex", HaveValue(Equal(*interfaceSpec.DeviceIndex))))
}

if len(interfaces) == 1 && interfaces[0].AssociatePublicIPAddress != nil {
if *interfaces[0].AssociatePublicIPAddress {
Expect(instance.PublicIpAddress).ToNot(BeNil())
} else {
Expect(instance.PublicIpAddress).To(BeNil())
}
}
},
Entry("when a single interface is specified", &v1beta1.NetworkInterface{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each of these tests adds time. I like our bias for code coverage, but I think that we might be able to get away with testing the most complex scenario in the E2E test environment, condensing this down to a single test and then testing a bunch of different edge-case scenarios within the functional/unit testing

Description: lo.ToPtr("a test interface"),
DeviceIndex: lo.ToPtr(int64(0)),
}),
Entry("when a single interface is specified with AssociatePublicIPAddress = true", &v1beta1.NetworkInterface{
AssociatePublicIPAddress: lo.ToPtr(true),
Description: lo.ToPtr("a test interface"),
DeviceIndex: lo.ToPtr(int64(0)),
}),
Entry("when a single interface is specified with AssociatePublicIPAddress = false", &v1beta1.NetworkInterface{
AssociatePublicIPAddress: lo.ToPtr(false),
Description: lo.ToPtr("a test interface"),
DeviceIndex: lo.ToPtr(int64(0)),
}),
Entry(
"when multiple interfaces are specified",
&v1beta1.NetworkInterface{
Description: lo.ToPtr("a test interface"),
DeviceIndex: lo.ToPtr(int64(0)),
},
&v1beta1.NetworkInterface{
Description: lo.ToPtr("another test interface"),
DeviceIndex: lo.ToPtr(int64(1)),
},
),
)
})
Loading