|
1 | 1 | package aws |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "context" |
4 | 5 | "fmt" |
| 6 | + "strconv" |
5 | 7 | "strings" |
| 8 | + "time" |
6 | 9 |
|
7 | 10 | "github.com/aws/aws-sdk-go/aws" |
8 | 11 | "github.com/aws/aws-sdk-go/aws/credentials/stscreds" |
9 | 12 | "github.com/aws/aws-sdk-go/aws/endpoints" |
10 | 13 | awss "github.com/aws/aws-sdk-go/aws/session" |
11 | 14 | "github.com/aws/aws-sdk-go/service/route53" |
| 15 | + "github.com/sirupsen/logrus" |
| 16 | + "k8s.io/apimachinery/pkg/util/sets" |
12 | 17 | "k8s.io/apimachinery/pkg/util/validation/field" |
13 | 18 |
|
14 | 19 | "github.com/openshift/installer/pkg/types" |
15 | 20 | ) |
16 | 21 |
|
17 | 22 | //go:generate mockgen -source=./route53.go -destination=mock/awsroute53_generated.go -package=mock |
18 | 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 | + |
19 | 28 | // API represents the calls made to the API. |
20 | 29 | type API interface { |
21 | 30 | GetHostedZone(hostedZone string, cfg *aws.Config) (*route53.GetHostedZoneOutput, error) |
@@ -148,37 +157,227 @@ func GetR53ClientCfg(sess *awss.Session, roleARN string) *aws.Config { |
148 | 157 | } |
149 | 158 |
|
150 | 159 | // CreateOrUpdateRecord Creates or Updates the Route53 Record for the cluster endpoint. |
151 | | -func (c *Client) CreateOrUpdateRecord(ic *types.InstallConfig, target string, cfg *aws.Config) error { |
152 | | - zone, err := c.GetBaseDomain(ic.BaseDomain) |
153 | | - if err != nil { |
154 | | - return err |
| 160 | +func (c *Client) CreateOrUpdateRecord(ctx context.Context, ic *types.InstallConfig, target string, intTarget string, phzID string) error { |
| 161 | + useCNAME := cnameRegions.Has(ic.AWS.Region) |
| 162 | + aliasZoneID := hostedZoneIDPerRegionNLBMap[ic.AWS.Region] |
| 163 | + |
| 164 | + apiName := fmt.Sprintf("api.%s.", ic.ClusterDomain()) |
| 165 | + apiIntName := fmt.Sprintf("api-int.%s.", ic.ClusterDomain()) |
| 166 | + |
| 167 | + // Create api record in public zone |
| 168 | + if ic.Publish == types.ExternalPublishingStrategy { |
| 169 | + zone, err := c.GetBaseDomain(ic.BaseDomain) |
| 170 | + if err != nil { |
| 171 | + return err |
| 172 | + } |
| 173 | + |
| 174 | + svc := route53.New(c.ssn) // we dont want to assume role here |
| 175 | + if _, err := createRecord(ctx, svc, aws.StringValue(zone.Id), apiName, target, aliasZoneID, useCNAME); err != nil { |
| 176 | + return fmt.Errorf("failed to create records for api: %w", err) |
| 177 | + } |
| 178 | + logrus.Debugln("Created public API record in public zone") |
| 179 | + } |
| 180 | + |
| 181 | + // Create service with assumed role for PHZ |
| 182 | + svc := route53.New(c.ssn, GetR53ClientCfg(c.ssn, ic.AWS.HostedZoneRole)) |
| 183 | + |
| 184 | + // Create api record in private zone |
| 185 | + if _, err := createRecord(ctx, svc, phzID, apiName, intTarget, aliasZoneID, useCNAME); err != nil { |
| 186 | + return fmt.Errorf("failed to create records for api: %w", err) |
| 187 | + } |
| 188 | + logrus.Debugln("Created public API record in private zone") |
| 189 | + |
| 190 | + // Create api-int record in private zone |
| 191 | + if _, err := createRecord(ctx, svc, phzID, apiIntName, intTarget, aliasZoneID, useCNAME); err != nil { |
| 192 | + return fmt.Errorf("failed to create records for api-int: %w", err) |
155 | 193 | } |
| 194 | + logrus.Debugln("Created private API record in private zone") |
156 | 195 |
|
157 | | - params := &route53.ChangeResourceRecordSetsInput{ |
| 196 | + return nil |
| 197 | +} |
| 198 | + |
| 199 | +func createRecord(ctx context.Context, client *route53.Route53, zoneID, name, dnsName, aliasZoneID string, useCNAME bool) (*route53.ChangeInfo, error) { |
| 200 | + recordSet := &route53.ResourceRecordSet{ |
| 201 | + Name: aws.String(name), |
| 202 | + } |
| 203 | + if useCNAME { |
| 204 | + recordSet.SetType("CNAME") |
| 205 | + recordSet.SetTTL(10) |
| 206 | + recordSet.SetResourceRecords([]*route53.ResourceRecord{ |
| 207 | + {Value: aws.String(dnsName)}, |
| 208 | + }) |
| 209 | + } else { |
| 210 | + recordSet.SetType("A") |
| 211 | + recordSet.SetAliasTarget(&route53.AliasTarget{ |
| 212 | + DNSName: aws.String(dnsName), |
| 213 | + HostedZoneId: aws.String(aliasZoneID), |
| 214 | + EvaluateTargetHealth: aws.Bool(false), |
| 215 | + }) |
| 216 | + } |
| 217 | + input := &route53.ChangeResourceRecordSetsInput{ |
| 218 | + HostedZoneId: aws.String(zoneID), |
158 | 219 | ChangeBatch: &route53.ChangeBatch{ |
159 | | - Comment: aws.String(fmt.Sprintf("Creating record for api and api-int in domain %s", ic.ClusterDomain())), |
160 | | - }, |
161 | | - HostedZoneId: zone.Id, |
162 | | - } |
163 | | - for _, prefix := range []string{"api", "api-int"} { |
164 | | - params.ChangeBatch.Changes = append(params.ChangeBatch.Changes, &route53.Change{ |
165 | | - Action: aws.String("UPSERT"), |
166 | | - ResourceRecordSet: &route53.ResourceRecordSet{ |
167 | | - Name: aws.String(fmt.Sprintf("%s.%s.", prefix, ic.ClusterDomain())), |
168 | | - Type: aws.String("A"), |
169 | | - AliasTarget: &route53.AliasTarget{ |
170 | | - DNSName: aws.String(target), |
171 | | - HostedZoneId: aws.String(hostedZoneIDPerRegionNLBMap[ic.AWS.Region]), |
172 | | - EvaluateTargetHealth: aws.Bool(true), |
| 220 | + Comment: aws.String(fmt.Sprintf("Creating record %s", name)), |
| 221 | + Changes: []*route53.Change{ |
| 222 | + { |
| 223 | + Action: aws.String("UPSERT"), |
| 224 | + ResourceRecordSet: recordSet, |
173 | 225 | }, |
174 | 226 | }, |
175 | | - }) |
| 227 | + }, |
176 | 228 | } |
| 229 | + res, err := client.ChangeResourceRecordSetsWithContext(ctx, input) |
| 230 | + if err != nil { |
| 231 | + return nil, err |
| 232 | + } |
| 233 | + |
| 234 | + return res.ChangeInfo, nil |
| 235 | +} |
| 236 | + |
| 237 | +// HostedZoneInput defines the input parameters for hosted zone creation. |
| 238 | +type HostedZoneInput struct { |
| 239 | + Name string |
| 240 | + InfraID string |
| 241 | + VpcID string |
| 242 | + Region string |
| 243 | + Role string |
| 244 | + UserTags map[string]string |
| 245 | +} |
| 246 | + |
| 247 | +// CreateHostedZone creates a private hosted zone. |
| 248 | +func (c *Client) CreateHostedZone(ctx context.Context, input *HostedZoneInput) (*route53.HostedZone, error) { |
| 249 | + cfg := GetR53ClientCfg(c.ssn, input.Role) |
177 | 250 | svc := route53.New(c.ssn, cfg) |
178 | | - if _, err := svc.ChangeResourceRecordSets(params); err != nil { |
179 | | - return fmt.Errorf("failed to create records for api/api-int: %w", err) |
| 251 | + |
| 252 | + callRef := fmt.Sprintf("%d", time.Now().Unix()) |
| 253 | + res, err := svc.CreateHostedZoneWithContext(ctx, &route53.CreateHostedZoneInput{ |
| 254 | + CallerReference: aws.String(callRef), |
| 255 | + Name: aws.String(input.Name), |
| 256 | + HostedZoneConfig: &route53.HostedZoneConfig{ |
| 257 | + PrivateZone: aws.Bool(true), |
| 258 | + Comment: aws.String("Created by Openshift Installer"), |
| 259 | + }, |
| 260 | + VPC: &route53.VPC{ |
| 261 | + VPCId: aws.String(input.VpcID), |
| 262 | + VPCRegion: aws.String(input.Region), |
| 263 | + }, |
| 264 | + }) |
| 265 | + if err != nil { |
| 266 | + return nil, fmt.Errorf("error creating private hosted zone: %w", err) |
180 | 267 | } |
181 | | - return nil |
| 268 | + |
| 269 | + if res == nil { |
| 270 | + return nil, fmt.Errorf("error creating private hosted zone: %w", err) |
| 271 | + } |
| 272 | + |
| 273 | + // Tag the hosted zone |
| 274 | + tags := mergeTags(input.UserTags, map[string]string{ |
| 275 | + "Name": fmt.Sprintf("%s-int", input.InfraID), |
| 276 | + }) |
| 277 | + _, err = svc.ChangeTagsForResourceWithContext(ctx, &route53.ChangeTagsForResourceInput{ |
| 278 | + ResourceType: aws.String("hostedzone"), |
| 279 | + ResourceId: res.HostedZone.Id, |
| 280 | + AddTags: r53Tags(tags), |
| 281 | + }) |
| 282 | + if err != nil { |
| 283 | + return nil, fmt.Errorf("failed to tag private hosted zone: %w", err) |
| 284 | + } |
| 285 | + |
| 286 | + // Set SOA minimum TTL |
| 287 | + recordSet, err := existingRecordSet(ctx, svc, res.HostedZone.Id, input.Name, "SOA") |
| 288 | + if err != nil { |
| 289 | + return nil, fmt.Errorf("failed to find SOA record set for private zone: %w", err) |
| 290 | + } |
| 291 | + if len(recordSet.ResourceRecords) == 0 || recordSet.ResourceRecords[0] == nil || recordSet.ResourceRecords[0].Value == nil { |
| 292 | + return nil, fmt.Errorf("failed to find SOA record for private zone") |
| 293 | + } |
| 294 | + record := recordSet.ResourceRecords[0] |
| 295 | + fields := strings.Split(aws.StringValue(record.Value), " ") |
| 296 | + if len(fields) != 7 { |
| 297 | + return nil, fmt.Errorf("SOA record value has %d fields, expected 7", len(fields)) |
| 298 | + } |
| 299 | + fields[0] = "60" |
| 300 | + record.Value = aws.String(strings.Join(fields, " ")) |
| 301 | + req, err := svc.ChangeResourceRecordSetsWithContext(ctx, &route53.ChangeResourceRecordSetsInput{ |
| 302 | + HostedZoneId: res.HostedZone.Id, |
| 303 | + ChangeBatch: &route53.ChangeBatch{ |
| 304 | + Changes: []*route53.Change{ |
| 305 | + { |
| 306 | + Action: aws.String("UPSERT"), |
| 307 | + ResourceRecordSet: recordSet, |
| 308 | + }, |
| 309 | + }, |
| 310 | + }, |
| 311 | + }) |
| 312 | + if err != nil { |
| 313 | + return nil, fmt.Errorf("failed to set SOA TTL to minimum: %w", err) |
| 314 | + } |
| 315 | + |
| 316 | + if err = svc.WaitUntilResourceRecordSetsChangedWithContext(ctx, &route53.GetChangeInput{Id: req.ChangeInfo.Id}); err != nil { |
| 317 | + return nil, fmt.Errorf("failed to wait for SOA TTL change: %w", err) |
| 318 | + } |
| 319 | + |
| 320 | + return res.HostedZone, nil |
| 321 | +} |
| 322 | + |
| 323 | +func existingRecordSet(ctx context.Context, client *route53.Route53, zoneID *string, recordName string, recordType string) (*route53.ResourceRecordSet, error) { |
| 324 | + name := fqdn(strings.ToLower(recordName)) |
| 325 | + res, err := client.ListResourceRecordSetsWithContext(ctx, &route53.ListResourceRecordSetsInput{ |
| 326 | + HostedZoneId: zoneID, |
| 327 | + StartRecordName: aws.String(name), |
| 328 | + StartRecordType: aws.String(recordType), |
| 329 | + MaxItems: aws.String("1"), |
| 330 | + }) |
| 331 | + if err != nil { |
| 332 | + return nil, fmt.Errorf("failed to list record sets: %w", err) |
| 333 | + } |
| 334 | + for _, rs := range res.ResourceRecordSets { |
| 335 | + resName := strings.ToLower(cleanRecordName(aws.StringValue(rs.Name))) |
| 336 | + resType := strings.ToUpper(aws.StringValue(rs.Type)) |
| 337 | + if resName == name && resType == recordType { |
| 338 | + return rs, nil |
| 339 | + } |
| 340 | + } |
| 341 | + |
| 342 | + return nil, fmt.Errorf("not found") |
| 343 | +} |
| 344 | + |
| 345 | +func fqdn(name string) string { |
| 346 | + n := len(name) |
| 347 | + if n == 0 || name[n-1] == '.' { |
| 348 | + return name |
| 349 | + } |
| 350 | + return name + "." |
| 351 | +} |
| 352 | + |
| 353 | +func cleanRecordName(name string) string { |
| 354 | + s, err := strconv.Unquote(`"` + name + `"`) |
| 355 | + if err != nil { |
| 356 | + return name |
| 357 | + } |
| 358 | + return s |
| 359 | +} |
| 360 | + |
| 361 | +func mergeTags(lhsTags, rhsTags map[string]string) map[string]string { |
| 362 | + merged := make(map[string]string, len(lhsTags)+len(rhsTags)) |
| 363 | + for k, v := range lhsTags { |
| 364 | + merged[k] = v |
| 365 | + } |
| 366 | + for k, v := range rhsTags { |
| 367 | + merged[k] = v |
| 368 | + } |
| 369 | + return merged |
| 370 | +} |
| 371 | + |
| 372 | +func r53Tags(tags map[string]string) []*route53.Tag { |
| 373 | + rtags := make([]*route53.Tag, 0, len(tags)) |
| 374 | + for k, v := range tags { |
| 375 | + rtags = append(rtags, &route53.Tag{ |
| 376 | + Key: aws.String(k), |
| 377 | + Value: aws.String(v), |
| 378 | + }) |
| 379 | + } |
| 380 | + return rtags |
182 | 381 | } |
183 | 382 |
|
184 | 383 | // See https://docs.aws.amazon.com/general/latest/gr/elb.html#elb_region |
|
0 commit comments