pkg/providers/amifamily/resolver.go (224 lines of code) (raw):
/*
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 amifamily
import (
"context"
"fmt"
"net"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
"github.com/samber/lo"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
karpv1 "sigs.k8s.io/karpenter/pkg/apis/v1"
"sigs.k8s.io/karpenter/pkg/cloudprovider"
"sigs.k8s.io/karpenter/pkg/scheduling"
v1 "github.com/aws/karpenter-provider-aws/pkg/apis/v1"
"github.com/aws/karpenter-provider-aws/pkg/providers/amifamily/bootstrap"
"github.com/aws/karpenter-provider-aws/pkg/providers/ssm"
)
var DefaultEBS = v1.BlockDevice{
Encrypted: aws.Bool(true),
VolumeType: aws.String(string(ec2types.VolumeTypeGp3)),
VolumeSize: lo.ToPtr(resource.MustParse("20Gi")),
}
type Resolver interface {
Resolve(*v1.EC2NodeClass, *karpv1.NodeClaim, []*cloudprovider.InstanceType, string, *Options) ([]*LaunchTemplate, error)
}
// DefaultResolver is able to fill-in dynamic launch template parameters
type DefaultResolver struct{}
// Options define the static launch template parameters
type Options struct {
ClusterName string
ClusterEndpoint string
ClusterCIDR *string
InstanceProfile string
CABundle *string `hash:"ignore"`
InstanceStorePolicy *v1.InstanceStorePolicy
// Level-triggered fields that may change out of sync.
SecurityGroups []v1.SecurityGroup
Tags map[string]string
Labels map[string]string `hash:"ignore"`
KubeDNSIP net.IP
AssociatePublicIPAddress *bool
NodeClassName string
}
// LaunchTemplate holds the dynamically generated launch template parameters
type LaunchTemplate struct {
*Options
UserData bootstrap.Bootstrapper
BlockDeviceMappings []*v1.BlockDeviceMapping
MetadataOptions *v1.MetadataOptions
AMIID string
InstanceTypes []*cloudprovider.InstanceType `hash:"ignore"`
DetailedMonitoring bool
EFACount int
CapacityType string
CapacityReservationID string
}
// AMIFamily can be implemented to override the default logic for generating dynamic launch template parameters
type AMIFamily interface {
DescribeImageQuery(ctx context.Context, ssmProvider ssm.Provider, k8sVersion string, amiVersion string) (DescribeImageQuery, error)
UserData(kubeletConfig *v1.KubeletConfiguration, taints []corev1.Taint, labels map[string]string, caBundle *string, instanceTypes []*cloudprovider.InstanceType, customUserData *string, instanceStorePolicy *v1.InstanceStorePolicy) bootstrap.Bootstrapper
DefaultBlockDeviceMappings() []*v1.BlockDeviceMapping
DefaultMetadataOptions() *v1.MetadataOptions
EphemeralBlockDevice() *string
FeatureFlags() FeatureFlags
}
type DefaultAMIOutput struct {
Query string
Requirements scheduling.Requirements
}
// FeatureFlags describes whether the features below are enabled for a given AMIFamily
type FeatureFlags struct {
UsesENILimitedMemoryOverhead bool
PodsPerCoreEnabled bool
EvictionSoftEnabled bool
SupportsENILimitedPodDensity bool
}
// DefaultFamily provides default values for AMIFamilies that compose it
type DefaultFamily struct{}
func (d DefaultFamily) FeatureFlags() FeatureFlags {
return FeatureFlags{
UsesENILimitedMemoryOverhead: true,
PodsPerCoreEnabled: true,
EvictionSoftEnabled: true,
SupportsENILimitedPodDensity: true,
}
}
// NewDefaultResolver constructs a new launch template DefaultResolver
func NewDefaultResolver() *DefaultResolver {
return &DefaultResolver{}
}
// Resolve generates launch templates using the static options and dynamically generates launch template parameters.
// Multiple ResolvedTemplates are returned based on the instanceTypes passed in to support special AMIs for certain instance types like GPUs.
func (r DefaultResolver) Resolve(nodeClass *v1.EC2NodeClass, nodeClaim *karpv1.NodeClaim, instanceTypes []*cloudprovider.InstanceType, capacityType string, options *Options) ([]*LaunchTemplate, error) {
amiFamily := GetAMIFamily(nodeClass.AMIFamily(), options)
if len(nodeClass.Status.AMIs) == 0 {
return nil, fmt.Errorf("no amis exist given constraints")
}
mappedAMIs := MapToInstanceTypes(instanceTypes, nodeClass.Status.AMIs)
if len(mappedAMIs) == 0 {
return nil, fmt.Errorf("no instance types satisfy requirements of amis %v", lo.Uniq(lo.Map(nodeClass.Status.AMIs, func(a v1.AMI, _ int) string { return a.ID })))
}
var resolvedTemplates []*LaunchTemplate
for amiID, instanceTypes := range mappedAMIs {
// In order to support reserved ENIs for CNI custom networking setups,
// we need to pass down the max-pods calculation to the kubelet.
// This requires that we resolve a unique launch template per max-pods value.
// Similarly, instance types configured with EFAs require unique launch templates depending on the number of
// EFAs they support.
// Reservations IDs are also included since we need to create a separate LaunchTemplate per reservation ID when
// launching reserved capacity. If it's a reserved capacity launch, we've already filtered the instance types
// further up the call stack.
type launchTemplateParams struct {
efaCount int
maxPods int
// reservationIDs is encoded as a string rather than a slice to ensure this type is comparable for use by `lo.GroupBy`.
reservationIDs string
}
paramsToInstanceTypes := lo.GroupBy(instanceTypes, func(it *cloudprovider.InstanceType) launchTemplateParams {
return launchTemplateParams{
efaCount: lo.Ternary(
lo.Contains(lo.Keys(nodeClaim.Spec.Resources.Requests), v1.ResourceEFA),
int(lo.ToPtr(it.Capacity[v1.ResourceEFA]).Value()),
0,
),
maxPods: int(it.Capacity.Pods().Value()),
// If we're dealing with reserved instances, there's only going to be a single instance per group. This invariant
// is due to reservation IDs not being shared across instance types. Because of this, we don't need to worry about
// ordering in this string.
reservationIDs: lo.Ternary(
capacityType == karpv1.CapacityTypeReserved,
strings.Join(lo.FilterMap(it.Offerings, func(o *cloudprovider.Offering, _ int) (string, bool) {
return o.ReservationID(), o.CapacityType() == karpv1.CapacityTypeReserved
}), ","),
"",
),
}
})
for params, instanceTypes := range paramsToInstanceTypes {
reservationIDs := strings.Split(params.reservationIDs, ",")
resolvedTemplates = append(resolvedTemplates, r.resolveLaunchTemplates(nodeClass, nodeClaim, instanceTypes, capacityType, amiFamily, amiID, params.maxPods, params.efaCount, reservationIDs, options)...)
}
}
return resolvedTemplates, nil
}
func GetAMIFamily(amiFamily string, options *Options) AMIFamily {
switch amiFamily {
case v1.AMIFamilyBottlerocket:
return &Bottlerocket{Options: options}
case v1.AMIFamilyWindows2019:
return &Windows{Options: options, Version: v1.Windows2019, Build: v1.Windows2019Build}
case v1.AMIFamilyWindows2022:
return &Windows{Options: options, Version: v1.Windows2022, Build: v1.Windows2022Build}
case v1.AMIFamilyCustom:
return &Custom{Options: options}
case v1.AMIFamilyAL2023:
return &AL2023{Options: options}
default:
return &AL2{Options: options}
}
}
func (o Options) DefaultMetadataOptions() *v1.MetadataOptions {
return &v1.MetadataOptions{
HTTPEndpoint: aws.String(string(ec2types.InstanceMetadataEndpointStateDisabled)),
HTTPProtocolIPv6: aws.String(lo.Ternary(o.KubeDNSIP == nil || o.KubeDNSIP.To4() != nil, string(ec2types.LaunchTemplateInstanceMetadataProtocolIpv6Disabled), string(ec2types.LaunchTemplateInstanceMetadataProtocolIpv6Enabled))),
HTTPPutResponseHopLimit: aws.Int64(2),
HTTPTokens: aws.String(string(ec2types.LaunchTemplateHttpTokensStateRequired)),
}
}
func (r DefaultResolver) defaultClusterDNS(opts *Options, kubeletConfig *v1.KubeletConfiguration) *v1.KubeletConfiguration {
if opts.KubeDNSIP == nil {
return kubeletConfig
}
if kubeletConfig != nil && len(kubeletConfig.ClusterDNS) != 0 {
return kubeletConfig
}
if kubeletConfig == nil {
return &v1.KubeletConfiguration{
ClusterDNS: []string{opts.KubeDNSIP.String()},
}
}
newKubeletConfig := kubeletConfig.DeepCopy()
newKubeletConfig.ClusterDNS = []string{opts.KubeDNSIP.String()}
return newKubeletConfig
}
func (r DefaultResolver) resolveLaunchTemplates(
nodeClass *v1.EC2NodeClass,
nodeClaim *karpv1.NodeClaim,
instanceTypes []*cloudprovider.InstanceType,
capacityType string,
amiFamily AMIFamily,
amiID string,
maxPods int,
efaCount int,
capacityReservationIDs []string,
options *Options,
) []*LaunchTemplate {
kubeletConfig := &v1.KubeletConfiguration{}
if nodeClass.Spec.Kubelet != nil {
kubeletConfig = nodeClass.Spec.Kubelet.DeepCopy()
}
if kubeletConfig.MaxPods == nil {
// nolint:gosec
// We know that it's not possible to have values that would overflow int32 here since we control
// the maxPods values that we pass in here
kubeletConfig.MaxPods = lo.ToPtr(int32(maxPods))
}
taints := lo.Flatten([][]corev1.Taint{
nodeClaim.Spec.Taints,
nodeClaim.Spec.StartupTaints,
})
if _, found := lo.Find(taints, func(t corev1.Taint) bool {
return t.MatchTaint(&karpv1.UnregisteredNoExecuteTaint)
}); !found {
taints = append(taints, karpv1.UnregisteredNoExecuteTaint)
}
// If no reservation IDs are provided, insert an empty string so the end result is a single launch template with no
// associated capacity reservation.
// TODO: We can simplify this by creating an initial lt, and then copying it for each cr. However, this requires a deep
// copy of the LT struct, which contains an interface causing problems for deepcopy-gen. See review comment for context:
// https://github.com/aws/karpenter-provider-aws/pull/7726#discussion_r1955280055
if len(capacityReservationIDs) == 0 {
capacityReservationIDs = append(capacityReservationIDs, "")
}
return lo.Map(capacityReservationIDs, func(id string, _ int) *LaunchTemplate {
resolved := &LaunchTemplate{
Options: options,
UserData: amiFamily.UserData(
r.defaultClusterDNS(options, kubeletConfig),
taints,
options.Labels,
options.CABundle,
instanceTypes,
nodeClass.Spec.UserData,
options.InstanceStorePolicy,
),
BlockDeviceMappings: nodeClass.Spec.BlockDeviceMappings,
MetadataOptions: nodeClass.Spec.MetadataOptions,
DetailedMonitoring: aws.ToBool(nodeClass.Spec.DetailedMonitoring),
AMIID: amiID,
InstanceTypes: instanceTypes,
EFACount: efaCount,
CapacityType: capacityType,
CapacityReservationID: id,
}
if len(resolved.BlockDeviceMappings) == 0 {
resolved.BlockDeviceMappings = amiFamily.DefaultBlockDeviceMappings()
}
if resolved.MetadataOptions == nil {
resolved.MetadataOptions = amiFamily.DefaultMetadataOptions()
}
return resolved
})
}