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  }