github.com/openshift/installer@v1.4.17/pkg/asset/installconfig/aws/route53.go (about) 1 package aws 2 3 import ( 4 "context" 5 "fmt" 6 "strconv" 7 "strings" 8 9 "github.com/aws/aws-sdk-go/aws" 10 "github.com/aws/aws-sdk-go/aws/credentials/stscreds" 11 "github.com/aws/aws-sdk-go/aws/endpoints" 12 awss "github.com/aws/aws-sdk-go/aws/session" 13 "github.com/aws/aws-sdk-go/service/route53" 14 "github.com/sirupsen/logrus" 15 "k8s.io/apimachinery/pkg/util/rand" 16 "k8s.io/apimachinery/pkg/util/sets" 17 "k8s.io/apimachinery/pkg/util/validation/field" 18 19 "github.com/openshift/installer/pkg/types" 20 ) 21 22 //go:generate mockgen -source=./route53.go -destination=mock/awsroute53_generated.go -package=mock 23 24 // regions for which ALIAS records are not available 25 // https://docs.aws.amazon.com/govcloud-us/latest/UserGuide/govcloud-r53.html 26 var cnameRegions = sets.New[string]("us-gov-west-1", "us-gov-east-1") 27 28 // API represents the calls made to the API. 29 type API interface { 30 GetHostedZone(hostedZone string, cfg *aws.Config) (*route53.GetHostedZoneOutput, error) 31 ValidateZoneRecords(zone *route53.HostedZone, zoneName string, zonePath *field.Path, ic *types.InstallConfig, cfg *aws.Config) field.ErrorList 32 GetBaseDomain(baseDomainName string) (*route53.HostedZone, error) 33 GetSubDomainDNSRecords(hostedZone *route53.HostedZone, ic *types.InstallConfig, cfg *aws.Config) ([]string, error) 34 } 35 36 // Client makes calls to the AWS Route53 API. 37 type Client struct { 38 ssn *awss.Session 39 } 40 41 // NewClient initializes a client with a session. 42 func NewClient(ssn *awss.Session) *Client { 43 client := &Client{ 44 ssn: ssn, 45 } 46 return client 47 } 48 49 // GetHostedZone attempts to get the hosted zone from the AWS Route53 instance 50 func (c *Client) GetHostedZone(hostedZone string, cfg *aws.Config) (*route53.GetHostedZoneOutput, error) { 51 // build a new Route53 instance from the same session that made it here 52 r53 := route53.New(c.ssn, cfg) 53 54 // validate that the hosted zone exists 55 hostedZoneOutput, err := r53.GetHostedZone(&route53.GetHostedZoneInput{Id: aws.String(hostedZone)}) 56 if err != nil { 57 return nil, fmt.Errorf("could not get hosted zone: %s: %w", hostedZone, err) 58 } 59 return hostedZoneOutput, nil 60 } 61 62 // ValidateZoneRecords Attempts to validate each of the candidate HostedZones against the Config 63 func (c *Client) ValidateZoneRecords(zone *route53.HostedZone, zoneName string, zonePath *field.Path, ic *types.InstallConfig, cfg *aws.Config) field.ErrorList { 64 allErrs := field.ErrorList{} 65 66 problematicRecords, err := c.GetSubDomainDNSRecords(zone, ic, cfg) 67 if err != nil { 68 allErrs = append(allErrs, field.InternalError(zonePath, 69 fmt.Errorf("could not list record sets for domain %q: %w", zoneName, err))) 70 } 71 72 if len(problematicRecords) > 0 { 73 detail := fmt.Sprintf( 74 "the zone already has record sets for the domain of the cluster: [%s]", 75 strings.Join(problematicRecords, ", "), 76 ) 77 allErrs = append(allErrs, field.Invalid(zonePath, zoneName, detail)) 78 } 79 80 return allErrs 81 } 82 83 // GetSubDomainDNSRecords Validates the hostedZone against the cluster domain, and ensures that the 84 // cluster domain does not have a current record set for the hostedZone 85 func (c *Client) GetSubDomainDNSRecords(hostedZone *route53.HostedZone, ic *types.InstallConfig, cfg *aws.Config) ([]string, error) { 86 dottedClusterDomain := ic.ClusterDomain() + "." 87 88 // validate that the domain of the hosted zone is the cluster domain or a parent of the cluster domain 89 if !isHostedZoneDomainParentOfClusterDomain(hostedZone, dottedClusterDomain) { 90 return nil, fmt.Errorf("hosted zone domain %q is not a parent of the cluster domain %q", *hostedZone.Name, dottedClusterDomain) 91 } 92 93 r53 := route53.New(c.ssn, cfg) 94 95 var problematicRecords []string 96 // validate that the hosted zone does not already have any record sets for the cluster domain 97 if err := r53.ListResourceRecordSetsPages( 98 &route53.ListResourceRecordSetsInput{HostedZoneId: hostedZone.Id}, 99 func(out *route53.ListResourceRecordSetsOutput, lastPage bool) bool { 100 for _, recordSet := range out.ResourceRecordSets { 101 name := aws.StringValue(recordSet.Name) 102 if skipRecord(name, dottedClusterDomain) { 103 continue 104 } 105 problematicRecords = append(problematicRecords, fmt.Sprintf("%s (%s)", name, aws.StringValue(recordSet.Type))) 106 } 107 return !lastPage 108 }, 109 ); err != nil { 110 return nil, err 111 } 112 113 return problematicRecords, nil 114 } 115 116 func skipRecord(recordName string, dottedClusterDomain string) bool { 117 // skip record sets that are not sub-domains of the cluster domain. Such record sets may exist for 118 // hosted zones that are used for other clusters or other purposes. 119 if !strings.HasSuffix(recordName, "."+dottedClusterDomain) { 120 return true 121 } 122 // skip record sets that are the cluster domain. Record sets for the cluster domain are fine. If the 123 // hosted zone has the name of the cluster domain, then there will be NS and SOA record sets for the 124 // cluster domain. 125 if len(recordName) == len(dottedClusterDomain) { 126 return true 127 } 128 129 return false 130 } 131 132 func isHostedZoneDomainParentOfClusterDomain(hostedZone *route53.HostedZone, dottedClusterDomain string) bool { 133 if *hostedZone.Name == dottedClusterDomain { 134 return true 135 } 136 return strings.HasSuffix(dottedClusterDomain, "."+*hostedZone.Name) 137 } 138 139 // GetBaseDomain Gets the Domain Zone with the matching domain name from the session 140 func (c *Client) GetBaseDomain(baseDomainName string) (*route53.HostedZone, error) { 141 baseDomainZone, err := GetPublicZone(c.ssn, baseDomainName) 142 if err != nil { 143 return nil, fmt.Errorf("could not find public zone: %s: %w", baseDomainName, err) 144 } 145 return baseDomainZone, nil 146 } 147 148 // GetR53ClientCfg creates a config for the route53 client by determining 149 // whether it is needed to obtain STS assume role credentials. 150 func GetR53ClientCfg(sess *awss.Session, roleARN string) *aws.Config { 151 if roleARN == "" { 152 return nil 153 } 154 155 creds := stscreds.NewCredentials(sess, roleARN) 156 return &aws.Config{Credentials: creds} 157 } 158 159 // CreateOrUpdateRecord Creates or Updates the Route53 Record for the cluster endpoint. 160 func (c *Client) CreateOrUpdateRecord(ctx context.Context, ic *types.InstallConfig, target string, intTarget string, phzID string, aliasZoneID string) error { 161 useCNAME := cnameRegions.Has(ic.AWS.Region) 162 163 apiName := fmt.Sprintf("api.%s.", ic.ClusterDomain()) 164 apiIntName := fmt.Sprintf("api-int.%s.", ic.ClusterDomain()) 165 166 // Create api record in public zone 167 if ic.Publish == types.ExternalPublishingStrategy { 168 zone, err := c.GetBaseDomain(ic.BaseDomain) 169 if err != nil { 170 return err 171 } 172 173 svc := route53.New(c.ssn) // we dont want to assume role here 174 if _, err := createRecord(ctx, svc, aws.StringValue(zone.Id), apiName, target, aliasZoneID, useCNAME); err != nil { 175 return fmt.Errorf("failed to create records for api: %w", err) 176 } 177 logrus.Debugln("Created public API record in public zone") 178 } 179 180 // Create service with assumed role for PHZ 181 svc := route53.New(c.ssn, GetR53ClientCfg(c.ssn, ic.AWS.HostedZoneRole)) 182 183 // Create api record in private zone 184 if _, err := createRecord(ctx, svc, phzID, apiName, intTarget, aliasZoneID, useCNAME); err != nil { 185 return fmt.Errorf("failed to create records for api: %w", err) 186 } 187 logrus.Debugln("Created public API record in private zone") 188 189 // Create api-int record in private zone 190 if _, err := createRecord(ctx, svc, phzID, apiIntName, intTarget, aliasZoneID, useCNAME); err != nil { 191 return fmt.Errorf("failed to create records for api-int: %w", err) 192 } 193 logrus.Debugln("Created private API record in private zone") 194 195 return nil 196 } 197 198 func createRecord(ctx context.Context, client *route53.Route53, zoneID, name, dnsName, aliasZoneID string, useCNAME bool) (*route53.ChangeInfo, error) { 199 recordSet := &route53.ResourceRecordSet{ 200 Name: aws.String(name), 201 } 202 if useCNAME { 203 recordSet.SetType("CNAME") 204 recordSet.SetTTL(10) 205 recordSet.SetResourceRecords([]*route53.ResourceRecord{ 206 {Value: aws.String(dnsName)}, 207 }) 208 } else { 209 recordSet.SetType("A") 210 recordSet.SetAliasTarget(&route53.AliasTarget{ 211 DNSName: aws.String(dnsName), 212 HostedZoneId: aws.String(aliasZoneID), 213 EvaluateTargetHealth: aws.Bool(false), 214 }) 215 } 216 input := &route53.ChangeResourceRecordSetsInput{ 217 HostedZoneId: aws.String(zoneID), 218 ChangeBatch: &route53.ChangeBatch{ 219 Comment: aws.String(fmt.Sprintf("Creating record %s", name)), 220 Changes: []*route53.Change{ 221 { 222 Action: aws.String("UPSERT"), 223 ResourceRecordSet: recordSet, 224 }, 225 }, 226 }, 227 } 228 res, err := client.ChangeResourceRecordSetsWithContext(ctx, input) 229 if err != nil { 230 return nil, err 231 } 232 233 return res.ChangeInfo, nil 234 } 235 236 // HostedZoneInput defines the input parameters for hosted zone creation. 237 type HostedZoneInput struct { 238 Name string 239 InfraID string 240 VpcID string 241 Region string 242 Role string 243 UserTags map[string]string 244 } 245 246 // CreateHostedZone creates a private hosted zone. 247 func (c *Client) CreateHostedZone(ctx context.Context, input *HostedZoneInput) (*route53.HostedZone, error) { 248 cfg := GetR53ClientCfg(c.ssn, input.Role) 249 svc := route53.New(c.ssn, cfg) 250 251 // CallerReference needs to be a unique string. We include the infra id, 252 // which is unique, in case that is helpful for human debugging. A random 253 // string of an arbitrary length is appended in case the infra id is reused 254 // which is generally not supposed to happen but does in some edge cases. 255 callerRef := aws.String(fmt.Sprintf("%s-%s", input.InfraID, rand.String(5))) 256 257 res, err := svc.CreateHostedZoneWithContext(ctx, &route53.CreateHostedZoneInput{ 258 CallerReference: callerRef, 259 Name: aws.String(input.Name), 260 HostedZoneConfig: &route53.HostedZoneConfig{ 261 PrivateZone: aws.Bool(true), 262 Comment: aws.String("Created by Openshift Installer"), 263 }, 264 VPC: &route53.VPC{ 265 VPCId: aws.String(input.VpcID), 266 VPCRegion: aws.String(input.Region), 267 }, 268 }) 269 if err != nil { 270 return nil, fmt.Errorf("error creating private hosted zone: %w", err) 271 } 272 273 if res == nil { 274 return nil, fmt.Errorf("error creating private hosted zone: %w", err) 275 } 276 277 // Tag the hosted zone 278 tags := mergeTags(input.UserTags, map[string]string{ 279 "Name": fmt.Sprintf("%s-int", input.InfraID), 280 }) 281 _, err = svc.ChangeTagsForResourceWithContext(ctx, &route53.ChangeTagsForResourceInput{ 282 ResourceType: aws.String("hostedzone"), 283 ResourceId: res.HostedZone.Id, 284 AddTags: r53Tags(tags), 285 }) 286 if err != nil { 287 return nil, fmt.Errorf("failed to tag private hosted zone: %w", err) 288 } 289 290 // Set SOA minimum TTL 291 recordSet, err := existingRecordSet(ctx, svc, res.HostedZone.Id, input.Name, "SOA") 292 if err != nil { 293 return nil, fmt.Errorf("failed to find SOA record set for private zone: %w", err) 294 } 295 if len(recordSet.ResourceRecords) == 0 || recordSet.ResourceRecords[0] == nil || recordSet.ResourceRecords[0].Value == nil { 296 return nil, fmt.Errorf("failed to find SOA record for private zone") 297 } 298 record := recordSet.ResourceRecords[0] 299 fields := strings.Split(aws.StringValue(record.Value), " ") 300 if len(fields) != 7 { 301 return nil, fmt.Errorf("SOA record value has %d fields, expected 7", len(fields)) 302 } 303 fields[0] = "60" 304 record.Value = aws.String(strings.Join(fields, " ")) 305 req, err := svc.ChangeResourceRecordSetsWithContext(ctx, &route53.ChangeResourceRecordSetsInput{ 306 HostedZoneId: res.HostedZone.Id, 307 ChangeBatch: &route53.ChangeBatch{ 308 Changes: []*route53.Change{ 309 { 310 Action: aws.String("UPSERT"), 311 ResourceRecordSet: recordSet, 312 }, 313 }, 314 }, 315 }) 316 if err != nil { 317 return nil, fmt.Errorf("failed to set SOA TTL to minimum: %w", err) 318 } 319 320 if err = svc.WaitUntilResourceRecordSetsChangedWithContext(ctx, &route53.GetChangeInput{Id: req.ChangeInfo.Id}); err != nil { 321 return nil, fmt.Errorf("failed to wait for SOA TTL change: %w", err) 322 } 323 324 return res.HostedZone, nil 325 } 326 327 func existingRecordSet(ctx context.Context, client *route53.Route53, zoneID *string, recordName string, recordType string) (*route53.ResourceRecordSet, error) { 328 name := fqdn(strings.ToLower(recordName)) 329 res, err := client.ListResourceRecordSetsWithContext(ctx, &route53.ListResourceRecordSetsInput{ 330 HostedZoneId: zoneID, 331 StartRecordName: aws.String(name), 332 StartRecordType: aws.String(recordType), 333 MaxItems: aws.String("1"), 334 }) 335 if err != nil { 336 return nil, fmt.Errorf("failed to list record sets: %w", err) 337 } 338 for _, rs := range res.ResourceRecordSets { 339 resName := strings.ToLower(cleanRecordName(aws.StringValue(rs.Name))) 340 resType := strings.ToUpper(aws.StringValue(rs.Type)) 341 if resName == name && resType == recordType { 342 return rs, nil 343 } 344 } 345 346 return nil, fmt.Errorf("not found") 347 } 348 349 func fqdn(name string) string { 350 n := len(name) 351 if n == 0 || name[n-1] == '.' { 352 return name 353 } 354 return name + "." 355 } 356 357 func cleanRecordName(name string) string { 358 s, err := strconv.Unquote(`"` + name + `"`) 359 if err != nil { 360 return name 361 } 362 return s 363 } 364 365 func mergeTags(lhsTags, rhsTags map[string]string) map[string]string { 366 merged := make(map[string]string, len(lhsTags)+len(rhsTags)) 367 for k, v := range lhsTags { 368 merged[k] = v 369 } 370 for k, v := range rhsTags { 371 merged[k] = v 372 } 373 return merged 374 } 375 376 func r53Tags(tags map[string]string) []*route53.Tag { 377 rtags := make([]*route53.Tag, 0, len(tags)) 378 for k, v := range tags { 379 rtags = append(rtags, &route53.Tag{ 380 Key: aws.String(k), 381 Value: aws.String(v), 382 }) 383 } 384 return rtags 385 } 386 387 // See https://docs.aws.amazon.com/general/latest/gr/elb.html#elb_region 388 389 // HostedZoneIDPerRegionNLBMap maps HostedZoneIDs from known regions. 390 var HostedZoneIDPerRegionNLBMap = map[string]string{ 391 endpoints.AfSouth1RegionID: "Z203XCE67M25HM", 392 endpoints.ApEast1RegionID: "Z12Y7K3UBGUAD1", 393 endpoints.ApNortheast1RegionID: "Z31USIVHYNEOWT", 394 endpoints.ApNortheast2RegionID: "ZIBE1TIR4HY56", 395 endpoints.ApNortheast3RegionID: "Z1GWIQ4HH19I5X", 396 endpoints.ApSouth1RegionID: "ZVDDRBQ08TROA", 397 endpoints.ApSouth2RegionID: "Z0711778386UTO08407HT", 398 endpoints.ApSoutheast1RegionID: "ZKVM4W9LS7TM", 399 endpoints.ApSoutheast2RegionID: "ZCT6FZBF4DROD", 400 endpoints.ApSoutheast3RegionID: "Z01971771FYVNCOVWJU1G", 401 endpoints.ApSoutheast4RegionID: "Z01156963G8MIIL7X90IV", 402 endpoints.CaCentral1RegionID: "Z2EPGBW3API2WT", 403 endpoints.CnNorth1RegionID: "Z3QFB96KMJ7ED6", 404 endpoints.CnNorthwest1RegionID: "ZQEIKTCZ8352D", 405 endpoints.EuCentral1RegionID: "Z3F0SRJ5LGBH90", 406 endpoints.EuCentral2RegionID: "Z02239872DOALSIDCX66S", 407 endpoints.EuNorth1RegionID: "Z1UDT6IFJ4EJM", 408 endpoints.EuSouth1RegionID: "Z23146JA1KNAFP", 409 endpoints.EuSouth2RegionID: "Z1011216NVTVYADP1SSV", 410 endpoints.EuWest1RegionID: "Z2IFOLAFXWLO4F", 411 endpoints.EuWest2RegionID: "ZD4D7Y8KGAS4G", 412 endpoints.EuWest3RegionID: "Z1CMS0P5QUZ6D5", 413 endpoints.MeCentral1RegionID: "Z00282643NTTLPANJJG2P", 414 endpoints.MeSouth1RegionID: "Z3QSRYVP46NYYV", 415 endpoints.SaEast1RegionID: "ZTK26PT1VY4CU", 416 endpoints.UsEast1RegionID: "Z26RNL4JYFTOTI", 417 endpoints.UsEast2RegionID: "ZLMOA37VPKANP", 418 endpoints.UsGovEast1RegionID: "Z1ZSMQQ6Q24QQ8", 419 endpoints.UsGovWest1RegionID: "ZMG1MZ2THAWF1", 420 endpoints.UsWest1RegionID: "Z24FKFUX50B4VW", 421 endpoints.UsWest2RegionID: "Z18D5FSROUN65G", 422 }