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 }