sigs.k8s.io/cluster-api-provider-aws@v1.5.5/pkg/cloud/services/ec2/launchtemplate.go (about) 1 /* 2 Copyright 2018 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 "encoding/base64" 21 "fmt" 22 "sort" 23 "strconv" 24 "strings" 25 26 "github.com/aws/aws-sdk-go/aws" 27 "github.com/aws/aws-sdk-go/service/ec2" 28 "github.com/google/go-cmp/cmp" 29 "github.com/pkg/errors" 30 "k8s.io/utils/pointer" 31 32 infrav1 "sigs.k8s.io/cluster-api-provider-aws/api/v1beta1" 33 expinfrav1 "sigs.k8s.io/cluster-api-provider-aws/exp/api/v1beta1" 34 "sigs.k8s.io/cluster-api-provider-aws/pkg/cloud/awserrors" 35 "sigs.k8s.io/cluster-api-provider-aws/pkg/cloud/scope" 36 "sigs.k8s.io/cluster-api-provider-aws/pkg/cloud/services/userdata" 37 ) 38 39 // GetLaunchTemplate returns the existing LaunchTemplate or nothing if it doesn't exist. 40 // For now by name until we need the input to be something different. 41 func (s *Service) GetLaunchTemplate(launchTemplateName string) (*expinfrav1.AWSLaunchTemplate, string, error) { 42 if launchTemplateName == "" { 43 return nil, "", nil 44 } 45 46 s.scope.V(2).Info("Looking for existing LaunchTemplates") 47 48 input := &ec2.DescribeLaunchTemplateVersionsInput{ 49 LaunchTemplateName: aws.String(launchTemplateName), 50 Versions: aws.StringSlice([]string{expinfrav1.LaunchTemplateLatestVersion}), 51 } 52 53 out, err := s.EC2Client.DescribeLaunchTemplateVersions(input) 54 switch { 55 case awserrors.IsNotFound(err): 56 return nil, "", nil 57 case err != nil: 58 return nil, "", err 59 } 60 61 if out == nil || out.LaunchTemplateVersions == nil || len(out.LaunchTemplateVersions) == 0 { 62 return nil, "", nil 63 } 64 65 return s.SDKToLaunchTemplate(out.LaunchTemplateVersions[0]) 66 } 67 68 // GetLaunchTemplateID returns the existing LaunchTemplateId or empty string if it doesn't exist. 69 func (s *Service) GetLaunchTemplateID(launchTemplateName string) (string, error) { 70 if launchTemplateName == "" { 71 return "", nil 72 } 73 74 input := &ec2.DescribeLaunchTemplateVersionsInput{ 75 LaunchTemplateName: aws.String(launchTemplateName), 76 Versions: aws.StringSlice([]string{expinfrav1.LaunchTemplateLatestVersion}), 77 } 78 79 out, err := s.EC2Client.DescribeLaunchTemplateVersions(input) 80 switch { 81 case awserrors.IsNotFound(err): 82 return "", nil 83 case err != nil: 84 s.scope.Info("", "aerr", err.Error()) 85 return "", err 86 } 87 88 if out == nil || out.LaunchTemplateVersions == nil || len(out.LaunchTemplateVersions) == 0 { 89 return "", nil 90 } 91 92 return aws.StringValue(out.LaunchTemplateVersions[0].LaunchTemplateId), nil 93 } 94 95 // CreateLaunchTemplate generates a launch template to be used with the autoscaling group. 96 func (s *Service) CreateLaunchTemplate(scope *scope.MachinePoolScope, imageID *string, userData []byte) (string, error) { 97 s.scope.Info("Create a new launch template") 98 99 launchTemplateData, err := s.createLaunchTemplateData(scope, imageID, userData) 100 if err != nil { 101 return "", errors.Wrapf(err, "unable to form launch template data") 102 } 103 104 input := &ec2.CreateLaunchTemplateInput{ 105 LaunchTemplateData: launchTemplateData, 106 LaunchTemplateName: aws.String(scope.Name()), 107 } 108 109 additionalTags := scope.AdditionalTags() 110 // Set the cloud provider tag 111 additionalTags[infrav1.ClusterAWSCloudProviderTagKey(s.scope.Name())] = string(infrav1.ResourceLifecycleOwned) 112 113 tags := infrav1.Build(infrav1.BuildParams{ 114 ClusterName: s.scope.Name(), 115 Lifecycle: infrav1.ResourceLifecycleOwned, 116 Name: aws.String(scope.Name()), 117 Role: aws.String("node"), 118 Additional: additionalTags, 119 }) 120 121 if len(tags) > 0 { 122 spec := &ec2.TagSpecification{ResourceType: aws.String(ec2.ResourceTypeLaunchTemplate)} 123 for key, value := range tags { 124 spec.Tags = append(spec.Tags, &ec2.Tag{ 125 Key: aws.String(key), 126 Value: aws.String(value), 127 }) 128 } 129 input.TagSpecifications = append(input.TagSpecifications, spec) 130 } 131 132 result, err := s.EC2Client.CreateLaunchTemplate(input) 133 if err != nil { 134 return "", err 135 } 136 return aws.StringValue(result.LaunchTemplate.LaunchTemplateId), nil 137 } 138 139 // CreateLaunchTemplateVersion will create a launch template. 140 func (s *Service) CreateLaunchTemplateVersion(scope *scope.MachinePoolScope, imageID *string, userData []byte) error { 141 s.scope.V(2).Info("creating new launch template version", "machine-pool", scope.Name()) 142 143 launchTemplateData, err := s.createLaunchTemplateData(scope, imageID, userData) 144 if err != nil { 145 return errors.Wrapf(err, "unable to form launch template data") 146 } 147 148 input := &ec2.CreateLaunchTemplateVersionInput{ 149 LaunchTemplateData: launchTemplateData, 150 LaunchTemplateId: aws.String(scope.AWSMachinePool.Status.LaunchTemplateID), 151 } 152 153 _, err = s.EC2Client.CreateLaunchTemplateVersion(input) 154 if err != nil { 155 return errors.Wrapf(err, "unable to create launch template version") 156 } 157 158 return nil 159 } 160 161 func (s *Service) createLaunchTemplateData(scope *scope.MachinePoolScope, imageID *string, userData []byte) (*ec2.RequestLaunchTemplateData, error) { 162 lt := scope.AWSMachinePool.Spec.AWSLaunchTemplate 163 164 // An explicit empty string for SSHKeyName means do not specify a key in the ASG launch 165 var sshKeyNamePtr *string 166 if lt.SSHKeyName != nil && *lt.SSHKeyName != "" { 167 sshKeyNamePtr = lt.SSHKeyName 168 } 169 170 data := &ec2.RequestLaunchTemplateData{ 171 InstanceType: aws.String(lt.InstanceType), 172 IamInstanceProfile: &ec2.LaunchTemplateIamInstanceProfileSpecificationRequest{ 173 Name: aws.String(lt.IamInstanceProfile), 174 }, 175 KeyName: sshKeyNamePtr, 176 UserData: pointer.StringPtr(base64.StdEncoding.EncodeToString(userData)), 177 } 178 179 ids, err := s.GetCoreNodeSecurityGroups(scope) 180 if err != nil { 181 return nil, err 182 } 183 184 for _, id := range ids { 185 data.SecurityGroupIds = append(data.SecurityGroupIds, aws.String(id)) 186 } 187 188 // add additional security groups as well 189 securityGroupIDs, err := s.GetAdditionalSecurityGroupsIDs(scope.AWSMachinePool.Spec.AWSLaunchTemplate.AdditionalSecurityGroups) 190 if err != nil { 191 return nil, err 192 } 193 data.SecurityGroupIds = append(data.SecurityGroupIds, aws.StringSlice(securityGroupIDs)...) 194 195 // set the AMI ID 196 data.ImageId = imageID 197 198 // Set up root volume 199 if lt.RootVolume != nil { 200 rootDeviceName, err := s.checkRootVolume(lt.RootVolume, *data.ImageId) 201 if err != nil { 202 return nil, err 203 } 204 205 lt.RootVolume.DeviceName = aws.StringValue(rootDeviceName) 206 207 req := volumeToLaunchTemplateBlockDeviceMappingRequest(lt.RootVolume) 208 data.BlockDeviceMappings = []*ec2.LaunchTemplateBlockDeviceMappingRequest{ 209 req, 210 } 211 } 212 213 data.TagSpecifications = s.buildLaunchTemplateTagSpecificationRequest(scope) 214 215 return data, nil 216 } 217 218 func volumeToLaunchTemplateBlockDeviceMappingRequest(v *infrav1.Volume) *ec2.LaunchTemplateBlockDeviceMappingRequest { 219 ltEbsDevice := &ec2.LaunchTemplateEbsBlockDeviceRequest{ 220 DeleteOnTermination: aws.Bool(true), 221 VolumeSize: aws.Int64(v.Size), 222 Encrypted: v.Encrypted, 223 } 224 225 if v.Throughput != nil { 226 ltEbsDevice.Throughput = v.Throughput 227 } 228 229 if v.IOPS != 0 { 230 ltEbsDevice.Iops = aws.Int64(v.IOPS) 231 } 232 233 if v.EncryptionKey != "" { 234 ltEbsDevice.Encrypted = aws.Bool(true) 235 ltEbsDevice.KmsKeyId = aws.String(v.EncryptionKey) 236 } 237 238 if v.Type != "" { 239 ltEbsDevice.VolumeType = aws.String(string(v.Type)) 240 } 241 242 return &ec2.LaunchTemplateBlockDeviceMappingRequest{ 243 DeviceName: &v.DeviceName, 244 Ebs: ltEbsDevice, 245 } 246 } 247 248 // DeleteLaunchTemplate delete a launch template. 249 func (s *Service) DeleteLaunchTemplate(id string) error { 250 s.scope.V(2).Info("Deleting launch template", "id", id) 251 252 input := &ec2.DeleteLaunchTemplateInput{ 253 LaunchTemplateId: aws.String(id), 254 } 255 256 if _, err := s.EC2Client.DeleteLaunchTemplate(input); err != nil { 257 return errors.Wrapf(err, "failed to delete launch template %q", id) 258 } 259 260 s.scope.V(2).Info("Deleted launch template", "id", id) 261 return nil 262 } 263 264 // PruneLaunchTemplateVersions deletes one old launch template version. 265 // It does not delete the "latest" version, because that version may still be in use. 266 // It does not delete the "default" version, because that version cannot be deleted. 267 // It does not assume that versions are sequential. Versions may be deleted out of band. 268 func (s *Service) PruneLaunchTemplateVersions(id string) error { 269 // When there is one version available, it is the default and the latest. 270 // When there are two versions available, one the is the default, the other is the latest. 271 // Therefore we only prune when there are at least 3 versions available. 272 const minCountToAllowPrune = 3 273 274 input := &ec2.DescribeLaunchTemplateVersionsInput{ 275 LaunchTemplateId: aws.String(id), 276 MinVersion: aws.String("0"), 277 MaxVersion: aws.String(expinfrav1.LaunchTemplateLatestVersion), 278 MaxResults: aws.Int64(minCountToAllowPrune), 279 } 280 281 out, err := s.EC2Client.DescribeLaunchTemplateVersions(input) 282 if err != nil { 283 s.scope.Info("", "aerr", err.Error()) 284 return err 285 } 286 287 // len(out.LaunchTemplateVersions) | items 288 // -------------------------------- + ----------------------- 289 // 1 | [default/latest] 290 // 2 | [default, latest] 291 // 3 | [default, versionToPrune, latest] 292 if len(out.LaunchTemplateVersions) < minCountToAllowPrune { 293 return nil 294 } 295 versionToPrune := out.LaunchTemplateVersions[1].VersionNumber 296 return s.deleteLaunchTemplateVersion(id, versionToPrune) 297 } 298 299 func (s *Service) deleteLaunchTemplateVersion(id string, version *int64) error { 300 s.scope.V(2).Info("Deleting launch template version", "id", id) 301 302 if version == nil { 303 return errors.New("version is a nil pointer") 304 } 305 versions := []string{strconv.FormatInt(*version, 10)} 306 307 input := &ec2.DeleteLaunchTemplateVersionsInput{ 308 LaunchTemplateId: aws.String(id), 309 Versions: aws.StringSlice(versions), 310 } 311 312 _, err := s.EC2Client.DeleteLaunchTemplateVersions(input) 313 if err != nil { 314 return err 315 } 316 317 s.scope.V(2).Info("Deleted launch template", "id", id, "version", *version) 318 return nil 319 } 320 321 // SDKToLaunchTemplate converts an AWS EC2 SDK instance to the CAPA instance type. 322 func (s *Service) SDKToLaunchTemplate(d *ec2.LaunchTemplateVersion) (*expinfrav1.AWSLaunchTemplate, string, error) { 323 v := d.LaunchTemplateData 324 i := &expinfrav1.AWSLaunchTemplate{ 325 Name: aws.StringValue(d.LaunchTemplateName), 326 AMI: infrav1.AMIReference{ 327 ID: v.ImageId, 328 }, 329 IamInstanceProfile: aws.StringValue(v.IamInstanceProfile.Name), 330 InstanceType: aws.StringValue(v.InstanceType), 331 SSHKeyName: v.KeyName, 332 VersionNumber: d.VersionNumber, 333 } 334 335 // Extract IAM Instance Profile name from ARN 336 if v.IamInstanceProfile != nil && v.IamInstanceProfile.Arn != nil { 337 split := strings.Split(aws.StringValue(v.IamInstanceProfile.Arn), "instance-profile/") 338 if len(split) > 1 && split[1] != "" { 339 i.IamInstanceProfile = split[1] 340 } 341 } 342 343 for _, id := range v.SecurityGroupIds { 344 // FIXME(dlipovetsky): This will include the core security groups as well, making the 345 // "Additional" a bit dishonest. However, including the core groups drastically simplifies 346 // comparison with the incoming security groups. 347 i.AdditionalSecurityGroups = append(i.AdditionalSecurityGroups, infrav1.AWSResourceReference{ID: id}) 348 } 349 350 if v.UserData == nil { 351 return i, userdata.ComputeHash(nil), nil 352 } 353 decodedUserData, err := base64.StdEncoding.DecodeString(*v.UserData) 354 if err != nil { 355 return nil, "", errors.Wrap(err, "unable to decode UserData") 356 } 357 358 return i, userdata.ComputeHash(decodedUserData), nil 359 } 360 361 // LaunchTemplateNeedsUpdate checks if a new launch template version is needed. 362 // 363 // FIXME(dlipovetsky): This check should account for changed userdata, but does not yet do so. 364 // Although userdata is stored in an EC2 Launch Template, it is not a field of AWSLaunchTemplate. 365 func (s *Service) LaunchTemplateNeedsUpdate(scope *scope.MachinePoolScope, incoming *expinfrav1.AWSLaunchTemplate, existing *expinfrav1.AWSLaunchTemplate) (bool, error) { 366 if incoming.IamInstanceProfile != existing.IamInstanceProfile { 367 return true, nil 368 } 369 370 if incoming.InstanceType != existing.InstanceType { 371 return true, nil 372 } 373 374 incomingIDs, err := s.GetAdditionalSecurityGroupsIDs(incoming.AdditionalSecurityGroups) 375 if err != nil { 376 return false, err 377 } 378 379 coreIDs, err := s.GetCoreNodeSecurityGroups(scope) 380 if err != nil { 381 return false, err 382 } 383 384 incomingIDs = append(incomingIDs, coreIDs...) 385 existingIDs, err := s.GetAdditionalSecurityGroupsIDs(existing.AdditionalSecurityGroups) 386 if err != nil { 387 return false, err 388 } 389 sort.Strings(incomingIDs) 390 sort.Strings(existingIDs) 391 392 if !cmp.Equal(incomingIDs, existingIDs) { 393 return true, nil 394 } 395 396 return false, nil 397 } 398 399 // DiscoverLaunchTemplateAMI will discover the AMI launch template. 400 func (s *Service) DiscoverLaunchTemplateAMI(scope *scope.MachinePoolScope) (*string, error) { 401 lt := scope.AWSMachinePool.Spec.AWSLaunchTemplate 402 403 if lt.AMI.ID != nil { 404 return lt.AMI.ID, nil 405 } 406 407 if scope.MachinePool.Spec.Template.Spec.Version == nil { 408 err := errors.New("Either AWSMachinePool's spec.awslaunchtemplate.ami.id or MachinePool's spec.template.spec.version must be defined") 409 s.scope.Error(err, "") 410 return nil, err 411 } 412 413 var lookupAMI string 414 var err error 415 416 imageLookupFormat := lt.ImageLookupFormat 417 if imageLookupFormat == "" { 418 imageLookupFormat = scope.InfraCluster.ImageLookupFormat() 419 } 420 421 imageLookupOrg := lt.ImageLookupOrg 422 if imageLookupOrg == "" { 423 imageLookupOrg = scope.InfraCluster.ImageLookupOrg() 424 } 425 426 imageLookupBaseOS := lt.ImageLookupBaseOS 427 if imageLookupBaseOS == "" { 428 imageLookupBaseOS = scope.InfraCluster.ImageLookupBaseOS() 429 } 430 431 if scope.IsEKSManaged() && imageLookupFormat == "" && imageLookupOrg == "" && imageLookupBaseOS == "" { 432 lookupAMI, err = s.eksAMILookup(*scope.MachinePool.Spec.Template.Spec.Version, scope.AWSMachinePool.Spec.AWSLaunchTemplate.AMI.EKSOptimizedLookupType) 433 if err != nil { 434 return nil, err 435 } 436 } else { 437 lookupAMI, err = s.defaultAMIIDLookup(imageLookupFormat, imageLookupOrg, imageLookupBaseOS, *scope.MachinePool.Spec.Template.Spec.Version) 438 if err != nil { 439 return nil, err 440 } 441 } 442 443 return aws.String(lookupAMI), nil 444 } 445 446 func (s *Service) GetAdditionalSecurityGroupsIDs(securityGroups []infrav1.AWSResourceReference) ([]string, error) { 447 var additionalSecurityGroupsIDs []string 448 449 for _, sg := range securityGroups { 450 if sg.ID != nil { 451 additionalSecurityGroupsIDs = append(additionalSecurityGroupsIDs, *sg.ID) 452 } else if sg.Filters != nil { 453 id, err := s.getFilteredSecurityGroupID(sg) 454 if err != nil { 455 return nil, err 456 } 457 458 additionalSecurityGroupsIDs = append(additionalSecurityGroupsIDs, id) 459 } 460 } 461 462 return additionalSecurityGroupsIDs, nil 463 } 464 465 func (s *Service) buildLaunchTemplateTagSpecificationRequest(scope *scope.MachinePoolScope) []*ec2.LaunchTemplateTagSpecificationRequest { 466 tagSpecifications := make([]*ec2.LaunchTemplateTagSpecificationRequest, 0) 467 additionalTags := scope.AdditionalTags() 468 // Set the cloud provider tag 469 additionalTags[infrav1.ClusterAWSCloudProviderTagKey(s.scope.Name())] = string(infrav1.ResourceLifecycleOwned) 470 471 tags := infrav1.Build(infrav1.BuildParams{ 472 ClusterName: s.scope.Name(), 473 Lifecycle: infrav1.ResourceLifecycleOwned, 474 Name: aws.String(scope.Name()), 475 Role: aws.String("node"), 476 Additional: additionalTags, 477 }) 478 479 if len(tags) > 0 { 480 // tag instances 481 spec := &ec2.LaunchTemplateTagSpecificationRequest{ResourceType: aws.String(ec2.ResourceTypeInstance)} 482 for key, value := range tags { 483 spec.Tags = append(spec.Tags, &ec2.Tag{ 484 Key: aws.String(key), 485 Value: aws.String(value), 486 }) 487 } 488 tagSpecifications = append(tagSpecifications, spec) 489 490 // tag EBS volumes 491 spec = &ec2.LaunchTemplateTagSpecificationRequest{ResourceType: aws.String(ec2.ResourceTypeVolume)} 492 for key, value := range tags { 493 spec.Tags = append(spec.Tags, &ec2.Tag{ 494 Key: aws.String(key), 495 Value: aws.String(value), 496 }) 497 } 498 tagSpecifications = append(tagSpecifications, spec) 499 } 500 return tagSpecifications 501 } 502 503 // getFilteredSecurityGroupID get security group ID using filters. 504 func (s *Service) getFilteredSecurityGroupID(securityGroup infrav1.AWSResourceReference) (string, error) { 505 if securityGroup.Filters == nil { 506 return "", nil 507 } 508 509 filters := []*ec2.Filter{} 510 for _, f := range securityGroup.Filters { 511 filters = append(filters, &ec2.Filter{Name: aws.String(f.Name), Values: aws.StringSlice(f.Values)}) 512 } 513 514 sgs, err := s.EC2Client.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{Filters: filters}) 515 if err != nil { 516 return "", err 517 } 518 519 if len(sgs.SecurityGroups) == 0 { 520 return "", fmt.Errorf("failed to find security group matching filters: %q, reason: %w", filters, err) 521 } 522 523 return *sgs.SecurityGroups[0].GroupId, nil 524 }