sigs.k8s.io/cluster-api-provider-aws@v1.5.5/pkg/cloud/services/ec2/ami.go (about)

     1  /*
     2  Copyright 2019 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8  	http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package ec2
    18  
    19  import (
    20  	"bytes"
    21  	"fmt"
    22  	"sort"
    23  	"strings"
    24  	"text/template"
    25  	"time"
    26  
    27  	"github.com/aws/aws-sdk-go/aws"
    28  	"github.com/aws/aws-sdk-go/service/ec2"
    29  	"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
    30  	"github.com/aws/aws-sdk-go/service/ssm"
    31  	"github.com/blang/semver"
    32  	"github.com/pkg/errors"
    33  
    34  	infrav1 "sigs.k8s.io/cluster-api-provider-aws/api/v1beta1"
    35  	"sigs.k8s.io/cluster-api-provider-aws/pkg/record"
    36  )
    37  
    38  const (
    39  	// DefaultMachineAMIOwnerID is a heptio/VMware owned account. Please see:
    40  	// https://github.com/kubernetes-sigs/cluster-api-provider-aws/issues/487
    41  	DefaultMachineAMIOwnerID = "258751437250"
    42  
    43  	// ubuntuOwnerID is Ubuntu owned account. Please see:
    44  	// https://ubuntu.com/server/docs/cloud-images/amazon-ec2
    45  	ubuntuOwnerID = "099720109477"
    46  
    47  	// Description regex for fetching Ubuntu AMIs for bastion host.
    48  	ubuntuImageDescription = "Canonical??Ubuntu??20.04?LTS??amd64?focal?image*"
    49  
    50  	// defaultMachineAMILookupBaseOS is the default base operating system to use
    51  	// when looking up machine AMIs.
    52  	defaultMachineAMILookupBaseOS = "ubuntu-18.04"
    53  
    54  	// DefaultAmiNameFormat is defined in the build/ directory of this project.
    55  	// The pattern is:
    56  	// 1. the string value `capa-ami-`
    57  	// 2. the baseOS of the AMI, for example: ubuntu-18.04, centos-7, amazon-2
    58  	// 3. the kubernetes version as defined by the packages produced by kubernetes/release with or without v as a prefix, for example: 1.13.0, 1.12.5-mybuild.1, v1.17.3
    59  	// 4. a `-` followed by any additional characters.
    60  	DefaultAmiNameFormat = "capa-ami-{{.BaseOS}}-?{{.K8sVersion}}-*"
    61  
    62  	// Amazon's AMI timestamp format.
    63  	createDateTimestampFormat = "2006-01-02T15:04:05.000Z"
    64  
    65  	// EKS AMI ID SSM Parameter name.
    66  	eksAmiSSMParameterFormat = "/aws/service/eks/optimized-ami/%s/amazon-linux-2/recommended/image_id"
    67  
    68  	// EKS GPU AMI ID SSM Parameter name.
    69  	eksGPUAmiSSMParameterFormat = "/aws/service/eks/optimized-ami/%s/amazon-linux-2-gpu/recommended/image_id"
    70  )
    71  
    72  // AMILookup contains the parameters used to template AMI names used for lookup.
    73  type AMILookup struct {
    74  	BaseOS     string
    75  	K8sVersion string
    76  }
    77  
    78  // GenerateAmiName will generate an AMI name.
    79  func GenerateAmiName(amiNameFormat, baseOS, kubernetesVersion string) (string, error) {
    80  	amiNameParameters := AMILookup{baseOS, strings.TrimPrefix(kubernetesVersion, "v")}
    81  	// revert to default if not specified
    82  	if amiNameFormat == "" {
    83  		amiNameFormat = DefaultAmiNameFormat
    84  	}
    85  	var templateBytes bytes.Buffer
    86  	template, err := template.New("amiName").Parse(amiNameFormat)
    87  	if err != nil {
    88  		return amiNameFormat, errors.Wrapf(err, "failed create template from string: %q", amiNameFormat)
    89  	}
    90  	err = template.Execute(&templateBytes, amiNameParameters)
    91  	if err != nil {
    92  		return amiNameFormat, errors.Wrapf(err, "failed to substitute string: %q", amiNameFormat)
    93  	}
    94  	return templateBytes.String(), nil
    95  }
    96  
    97  // DefaultAMILookup will do a default AMI lookup.
    98  func DefaultAMILookup(ec2Client ec2iface.EC2API, ownerID, baseOS, kubernetesVersion, amiNameFormat string) (*ec2.Image, error) {
    99  	if amiNameFormat == "" {
   100  		amiNameFormat = DefaultAmiNameFormat
   101  	}
   102  	if ownerID == "" {
   103  		ownerID = DefaultMachineAMIOwnerID
   104  	}
   105  	if baseOS == "" {
   106  		baseOS = defaultMachineAMILookupBaseOS
   107  	}
   108  
   109  	amiName, err := GenerateAmiName(amiNameFormat, baseOS, kubernetesVersion)
   110  	if err != nil {
   111  		return nil, errors.Wrapf(err, "failed to process ami format: %q", amiNameFormat)
   112  	}
   113  	describeImageInput := &ec2.DescribeImagesInput{
   114  		Filters: []*ec2.Filter{
   115  			{
   116  				Name:   aws.String("owner-id"),
   117  				Values: []*string{aws.String(ownerID)},
   118  			},
   119  			{
   120  				Name:   aws.String("name"),
   121  				Values: []*string{aws.String(amiName)},
   122  			},
   123  			{
   124  				Name:   aws.String("architecture"),
   125  				Values: []*string{aws.String("x86_64")},
   126  			},
   127  			{
   128  				Name:   aws.String("state"),
   129  				Values: []*string{aws.String("available")},
   130  			},
   131  			{
   132  				Name:   aws.String("virtualization-type"),
   133  				Values: []*string{aws.String("hvm")},
   134  			},
   135  		},
   136  	}
   137  
   138  	out, err := ec2Client.DescribeImages(describeImageInput)
   139  	if err != nil {
   140  		return nil, errors.Wrapf(err, "failed to find ami: %q", amiName)
   141  	}
   142  	if out == nil || len(out.Images) == 0 {
   143  		return nil, errors.Errorf("found no AMIs with the name: %q", amiName)
   144  	}
   145  	latestImage, err := GetLatestImage(out.Images)
   146  	if err != nil {
   147  		return nil, err
   148  	}
   149  
   150  	return latestImage, nil
   151  }
   152  
   153  // defaultAMIIDLookup returns the default AMI based on region.
   154  func (s *Service) defaultAMIIDLookup(amiNameFormat, ownerID, baseOS, kubernetesVersion string) (string, error) {
   155  	latestImage, err := DefaultAMILookup(s.EC2Client, ownerID, baseOS, kubernetesVersion, amiNameFormat)
   156  	if err != nil {
   157  		record.Eventf(s.scope.InfraCluster(), "FailedDescribeImages", "Failed to find ami for OS=%s and Kubernetes-version=%s: %v", baseOS, kubernetesVersion, err)
   158  		return "", errors.Wrapf(err, "failed to find ami")
   159  	}
   160  
   161  	s.scope.V(2).Info("Found and using an existing AMI", "ami-id", aws.StringValue(latestImage.ImageId))
   162  	return aws.StringValue(latestImage.ImageId), nil
   163  }
   164  
   165  type images []*ec2.Image
   166  
   167  // Len is the number of elements in the collection.
   168  func (i images) Len() int {
   169  	return len(i)
   170  }
   171  
   172  // Less reports whether the element with
   173  // index i should sort before the element with index j.
   174  // At this point all CreationDates have been checked for errors so ignoring the error is ok.
   175  func (i images) Less(k, j int) bool {
   176  	firstTime, _ := time.Parse(createDateTimestampFormat, aws.StringValue(i[k].CreationDate))
   177  	secondTime, _ := time.Parse(createDateTimestampFormat, aws.StringValue(i[j].CreationDate))
   178  	return firstTime.Before(secondTime)
   179  }
   180  
   181  // Swap swaps the elements with indexes i and j.
   182  func (i images) Swap(k, j int) {
   183  	i[k], i[j] = i[j], i[k]
   184  }
   185  
   186  // GetLatestImage assumes imgs is not empty. Responsibility of the caller to check.
   187  func GetLatestImage(imgs []*ec2.Image) (*ec2.Image, error) {
   188  	for _, img := range imgs {
   189  		if _, err := time.Parse(createDateTimestampFormat, aws.StringValue(img.CreationDate)); err != nil {
   190  			return nil, err
   191  		}
   192  	}
   193  	// old to new (newest one is last)
   194  	sort.Sort(images(imgs))
   195  	return imgs[len(imgs)-1], nil
   196  }
   197  
   198  func (s *Service) defaultBastionAMILookup() (string, error) {
   199  	describeImageInput := &ec2.DescribeImagesInput{
   200  		Filters: []*ec2.Filter{
   201  			{
   202  				Name:   aws.String("owner-id"),
   203  				Values: []*string{aws.String(ubuntuOwnerID)},
   204  			},
   205  			{
   206  				Name:   aws.String("architecture"),
   207  				Values: []*string{aws.String("x86_64")},
   208  			},
   209  			{
   210  				Name:   aws.String("state"),
   211  				Values: []*string{aws.String("available")},
   212  			},
   213  			{
   214  				Name:   aws.String("virtualization-type"),
   215  				Values: []*string{aws.String("hvm")},
   216  			},
   217  			{
   218  				Name:   aws.String("description"),
   219  				Values: aws.StringSlice([]string{ubuntuImageDescription}),
   220  			},
   221  		},
   222  	}
   223  	out, err := s.EC2Client.DescribeImages(describeImageInput)
   224  	if err != nil {
   225  		return "", errors.Wrapf(err, "failed to describe images within region: %q", s.scope.Region())
   226  	}
   227  	if len(out.Images) == 0 {
   228  		return "", errors.Errorf("found no AMIs within the region: %q", s.scope.Region())
   229  	}
   230  	latestImage, err := GetLatestImage(out.Images)
   231  	if err != nil {
   232  		return "", err
   233  	}
   234  	return *latestImage.ImageId, nil
   235  }
   236  
   237  func (s *Service) eksAMILookup(kubernetesVersion string, amiType *infrav1.EKSAMILookupType) (string, error) {
   238  	// format ssm parameter path properly
   239  	formattedVersion, err := formatVersionForEKS(kubernetesVersion)
   240  	if err != nil {
   241  		return "", err
   242  	}
   243  
   244  	var paramName string
   245  
   246  	if amiType == nil {
   247  		amiType = new(infrav1.EKSAMILookupType)
   248  	}
   249  
   250  	switch *amiType {
   251  	case infrav1.AmazonLinuxGPU:
   252  		paramName = fmt.Sprintf(eksGPUAmiSSMParameterFormat, formattedVersion)
   253  	default:
   254  		paramName = fmt.Sprintf(eksAmiSSMParameterFormat, formattedVersion)
   255  	}
   256  
   257  	input := &ssm.GetParameterInput{
   258  		Name: aws.String(paramName),
   259  	}
   260  
   261  	out, err := s.SSMClient.GetParameter(input)
   262  	if err != nil {
   263  		record.Eventf(s.scope.InfraCluster(), "FailedGetParameter", "Failed to get ami SSM parameter %q: %v", paramName, err)
   264  
   265  		return "", errors.Wrapf(err, "failed to get ami SSM parameter: %q", paramName)
   266  	}
   267  
   268  	if out.Parameter == nil || out.Parameter.Value == nil {
   269  		return "", errors.Errorf("SSM parameter returned with nil value: %q", paramName)
   270  	}
   271  
   272  	id := aws.StringValue(out.Parameter.Value)
   273  	s.scope.Info("found AMI", "id", id, "version", formattedVersion)
   274  
   275  	return id, nil
   276  }
   277  
   278  func formatVersionForEKS(version string) (string, error) {
   279  	parsed, err := semver.ParseTolerant(version)
   280  	if err != nil {
   281  		return "", err
   282  	}
   283  
   284  	return fmt.Sprintf("%d.%d", parsed.Major, parsed.Minor), nil
   285  }