sigs.k8s.io/external-dns@v0.14.1/provider/akamai/akamai.go (about) 1 /* 2 Copyright 2017 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 akamai 18 19 import ( 20 "context" 21 "fmt" 22 "os" 23 "strconv" 24 "strings" 25 26 dns "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v2" 27 "github.com/akamai/AkamaiOPEN-edgegrid-golang/edgegrid" 28 log "github.com/sirupsen/logrus" 29 30 "sigs.k8s.io/external-dns/endpoint" 31 "sigs.k8s.io/external-dns/plan" 32 "sigs.k8s.io/external-dns/provider" 33 ) 34 35 const ( 36 // Default Record TTL 37 edgeDNSRecordTTL = 600 38 maxUint = ^uint(0) 39 maxInt = int(maxUint >> 1) 40 ) 41 42 // edgeDNSClient is a proxy interface of the Akamai edgegrid configdns-v2 package that can be stubbed for testing. 43 type AkamaiDNSService interface { 44 ListZones(queryArgs dns.ZoneListQueryArgs) (*dns.ZoneListResponse, error) 45 GetRecordsets(zone string, queryArgs dns.RecordsetQueryArgs) (*dns.RecordSetResponse, error) 46 GetRecord(zone string, name string, recordtype string) (*dns.RecordBody, error) 47 DeleteRecord(record *dns.RecordBody, zone string, recLock bool) error 48 UpdateRecord(record *dns.RecordBody, zone string, recLock bool) error 49 CreateRecordsets(recordsets *dns.Recordsets, zone string, recLock bool) error 50 } 51 52 type AkamaiConfig struct { 53 DomainFilter endpoint.DomainFilter 54 ZoneIDFilter provider.ZoneIDFilter 55 ServiceConsumerDomain string 56 ClientToken string 57 ClientSecret string 58 AccessToken string 59 EdgercPath string 60 EdgercSection string 61 MaxBody int 62 AccountKey string 63 DryRun bool 64 } 65 66 // AkamaiProvider implements the DNS provider for Akamai. 67 type AkamaiProvider struct { 68 provider.BaseProvider 69 // Edgedns zones to filter on 70 domainFilter endpoint.DomainFilter 71 // Contract Ids to filter on 72 zoneIDFilter provider.ZoneIDFilter 73 // Edgegrid library configuration 74 config *edgegrid.Config 75 dryRun bool 76 // Defines client. Allows for mocking. 77 client AkamaiDNSService 78 } 79 80 type akamaiZones struct { 81 Zones []akamaiZone `json:"zones"` 82 } 83 84 type akamaiZone struct { 85 ContractID string `json:"contractId"` 86 Zone string `json:"zone"` 87 } 88 89 // NewAkamaiProvider initializes a new Akamai DNS based Provider. 90 func NewAkamaiProvider(akamaiConfig AkamaiConfig, akaService AkamaiDNSService) (provider.Provider, error) { 91 var edgeGridConfig edgegrid.Config 92 93 /* 94 log.Debugf("Host: %s", akamaiConfig.ServiceConsumerDomain) 95 log.Debugf("ClientToken: %s", akamaiConfig.ClientToken) 96 log.Debugf("ClientSecret: %s", akamaiConfig.ClientSecret) 97 log.Debugf("AccessToken: %s", akamaiConfig.AccessToken) 98 log.Debugf("EdgePath: %s", akamaiConfig.EdgercPath) 99 log.Debugf("EdgeSection: %s", akamaiConfig.EdgercSection) 100 */ 101 // environment overrides edgerc file but config needs to be complete 102 if akamaiConfig.ServiceConsumerDomain == "" || akamaiConfig.ClientToken == "" || akamaiConfig.ClientSecret == "" || akamaiConfig.AccessToken == "" { 103 // Kubernetes config incomplete or non existent. Can't mix and match. 104 // Look for Akamai environment or .edgerd creds 105 var err error 106 edgeGridConfig, err = edgegrid.Init(akamaiConfig.EdgercPath, akamaiConfig.EdgercSection) // use default .edgerc location and section 107 if err != nil { 108 log.Errorf("Edgegrid Init Failed") 109 return &AkamaiProvider{}, err // return empty provider for backward compatibility 110 } 111 edgeGridConfig.HeaderToSign = append(edgeGridConfig.HeaderToSign, "X-External-DNS") 112 } else { 113 // Use external-dns config 114 edgeGridConfig = edgegrid.Config{ 115 Host: akamaiConfig.ServiceConsumerDomain, 116 ClientToken: akamaiConfig.ClientToken, 117 ClientSecret: akamaiConfig.ClientSecret, 118 AccessToken: akamaiConfig.AccessToken, 119 MaxBody: 131072, // same default val as used by Edgegrid 120 HeaderToSign: []string{ 121 "X-External-DNS", 122 }, 123 Debug: false, 124 } 125 // Check for edgegrid overrides 126 if envval, ok := os.LookupEnv("AKAMAI_MAX_BODY"); ok { 127 if i, err := strconv.Atoi(envval); err == nil { 128 edgeGridConfig.MaxBody = i 129 log.Debugf("Edgegrid maxbody set to %s", envval) 130 } 131 } 132 if envval, ok := os.LookupEnv("AKAMAI_ACCOUNT_KEY"); ok { 133 edgeGridConfig.AccountKey = envval 134 log.Debugf("Edgegrid applying account key %s", envval) 135 } 136 if envval, ok := os.LookupEnv("AKAMAI_DEBUG"); ok { 137 if dbgval, err := strconv.ParseBool(envval); err == nil { 138 edgeGridConfig.Debug = dbgval 139 log.Debugf("Edgegrid debug set to %s", envval) 140 } 141 } 142 } 143 144 provider := &AkamaiProvider{ 145 domainFilter: akamaiConfig.DomainFilter, 146 zoneIDFilter: akamaiConfig.ZoneIDFilter, 147 config: &edgeGridConfig, 148 dryRun: akamaiConfig.DryRun, 149 } 150 if akaService != nil { 151 log.Debugf("Using STUB") 152 provider.client = akaService 153 } else { 154 provider.client = provider 155 } 156 157 // Init library for direct endpoint calls 158 dns.Init(edgeGridConfig) 159 160 return provider, nil 161 } 162 163 func (p AkamaiProvider) ListZones(queryArgs dns.ZoneListQueryArgs) (*dns.ZoneListResponse, error) { 164 return dns.ListZones(queryArgs) 165 } 166 167 func (p AkamaiProvider) GetRecordsets(zone string, queryArgs dns.RecordsetQueryArgs) (*dns.RecordSetResponse, error) { 168 return dns.GetRecordsets(zone, queryArgs) 169 } 170 171 func (p AkamaiProvider) CreateRecordsets(recordsets *dns.Recordsets, zone string, reclock bool) error { 172 return recordsets.Save(zone, reclock) 173 } 174 175 func (p AkamaiProvider) GetRecord(zone string, name string, recordtype string) (*dns.RecordBody, error) { 176 return dns.GetRecord(zone, name, recordtype) 177 } 178 179 func (p AkamaiProvider) DeleteRecord(record *dns.RecordBody, zone string, recLock bool) error { 180 return record.Delete(zone, recLock) 181 } 182 183 func (p AkamaiProvider) UpdateRecord(record *dns.RecordBody, zone string, recLock bool) error { 184 return record.Update(zone, recLock) 185 } 186 187 // Fetch zones using Edgegrid DNS v2 API 188 func (p AkamaiProvider) fetchZones() (akamaiZones, error) { 189 filteredZones := akamaiZones{Zones: make([]akamaiZone, 0)} 190 queryArgs := dns.ZoneListQueryArgs{Types: "primary", ShowAll: true} 191 // filter based on contractIds 192 if len(p.zoneIDFilter.ZoneIDs) > 0 { 193 queryArgs.ContractIds = strings.Join(p.zoneIDFilter.ZoneIDs, ",") 194 } 195 resp, err := p.client.ListZones(queryArgs) // retrieve all primary zones filtered by contract ids 196 if err != nil { 197 log.Errorf("Failed to fetch zones from Akamai") 198 return filteredZones, err 199 } 200 201 for _, zone := range resp.Zones { 202 if p.domainFilter.Match(zone.Zone) { 203 filteredZones.Zones = append(filteredZones.Zones, akamaiZone{ContractID: zone.ContractId, Zone: zone.Zone}) 204 log.Debugf("Fetched zone: '%s' (ZoneID: %s)", zone.Zone, zone.ContractId) 205 } 206 } 207 lenFilteredZones := len(filteredZones.Zones) 208 if lenFilteredZones == 0 { 209 log.Warnf("No zones could be fetched") 210 } else { 211 log.Debugf("Fetched '%d' zones from Akamai", lenFilteredZones) 212 } 213 214 return filteredZones, nil 215 } 216 217 // Records returns the list of records in a given zone. 218 func (p AkamaiProvider) Records(context.Context) (endpoints []*endpoint.Endpoint, err error) { 219 zones, err := p.fetchZones() // returns a filtered set of zones 220 if err != nil { 221 log.Warnf("Failed to identify target zones! Error: %s", err.Error()) 222 return endpoints, err 223 } 224 for _, zone := range zones.Zones { 225 recordsets, err := p.client.GetRecordsets(zone.Zone, dns.RecordsetQueryArgs{ShowAll: true}) 226 if err != nil { 227 log.Errorf("Recordsets retrieval for zone: '%s' failed! %s", zone.Zone, err.Error()) 228 continue 229 } 230 if len(recordsets.Recordsets) == 0 { 231 log.Warnf("Zone %s contains no recordsets", zone.Zone) 232 } 233 234 for _, recordset := range recordsets.Recordsets { 235 if !provider.SupportedRecordType(recordset.Type) { 236 log.Debugf("Skipping endpoint DNSName: '%s' RecordType: '%s'. Record type not supported.", recordset.Name, recordset.Type) 237 continue 238 } 239 if !p.domainFilter.Match(recordset.Name) { 240 log.Debugf("Skipping endpoint. Record name %s doesn't match containing zone %s.", recordset.Name, zone) 241 continue 242 } 243 var temp interface{} = int64(recordset.TTL) 244 ttl := endpoint.TTL(temp.(int64)) 245 endpoints = append(endpoints, endpoint.NewEndpointWithTTL(recordset.Name, 246 recordset.Type, 247 ttl, 248 trimTxtRdata(recordset.Rdata, recordset.Type)...)) 249 log.Debugf("Fetched endpoint DNSName: '%s' RecordType: '%s' Rdata: '%s')", recordset.Name, recordset.Type, recordset.Rdata) 250 } 251 } 252 lenEndpoints := len(endpoints) 253 if lenEndpoints == 0 { 254 log.Warnf("No endpoints could be fetched") 255 } else { 256 log.Debugf("Fetched '%d' endpoints from Akamai", lenEndpoints) 257 log.Debugf("Endpoints [%v]", endpoints) 258 } 259 260 return endpoints, nil 261 } 262 263 // ApplyChanges applies a given set of changes in a given zone. 264 func (p AkamaiProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { 265 zoneNameIDMapper := provider.ZoneIDName{} 266 zones, err := p.fetchZones() 267 if err != nil { 268 log.Errorf("Failed to fetch zones from Akamai") 269 return err 270 } 271 272 for _, z := range zones.Zones { 273 zoneNameIDMapper[z.Zone] = z.Zone 274 } 275 log.Debugf("Processing zones: [%v]", zoneNameIDMapper) 276 277 // Create recordsets 278 log.Debugf("Create Changes requested [%v]", changes.Create) 279 if err := p.createRecordsets(zoneNameIDMapper, changes.Create); err != nil { 280 return err 281 } 282 // Delete recordsets 283 log.Debugf("Delete Changes requested [%v]", changes.Delete) 284 if err := p.deleteRecordsets(zoneNameIDMapper, changes.Delete); err != nil { 285 return err 286 } 287 // Update recordsets 288 log.Debugf("Update Changes requested [%v]", changes.UpdateNew) 289 if err := p.updateNewRecordsets(zoneNameIDMapper, changes.UpdateNew); err != nil { 290 return err 291 } 292 // Check that all old endpoints were accounted for 293 revRecs := changes.Delete 294 revRecs = append(revRecs, changes.UpdateNew...) 295 for _, rec := range changes.UpdateOld { 296 found := false 297 for _, r := range revRecs { 298 if rec.DNSName == r.DNSName { 299 found = true 300 break 301 } 302 } 303 if !found { 304 log.Warnf("UpdateOld endpoint '%s' is not accounted for in UpdateNew|Delete endpoint list", rec.DNSName) 305 } 306 } 307 308 return nil 309 } 310 311 // Create DNS Recordset 312 func newAkamaiRecordset(dnsName, recordType string, ttl int, targets []string) dns.Recordset { 313 return dns.Recordset{ 314 Name: strings.TrimSuffix(dnsName, "."), 315 Rdata: targets, 316 Type: recordType, 317 TTL: ttl, 318 } 319 } 320 321 // cleanTargets preps recordset rdata if necessary for EdgeDNS 322 func cleanTargets(rtype string, targets ...string) []string { 323 log.Debugf("Targets to clean: [%v]", targets) 324 if rtype == "CNAME" || rtype == "SRV" { 325 for idx, target := range targets { 326 targets[idx] = strings.TrimSuffix(target, ".") 327 } 328 } else if rtype == "TXT" { 329 for idx, target := range targets { 330 log.Debugf("TXT data to clean: [%s]", target) 331 // need to embed text data in quotes. Make sure not piling on 332 target = strings.Trim(target, "\"") 333 // bug in DNS API with embedded quotes. 334 if strings.Contains(target, "owner") && strings.Contains(target, "\"") { 335 target = strings.ReplaceAll(target, "\"", "`") 336 } 337 targets[idx] = "\"" + target + "\"" 338 } 339 } 340 log.Debugf("Clean targets: [%v]", targets) 341 342 return targets 343 } 344 345 // trimTxtRdata removes surrounding quotes for received TXT rdata 346 func trimTxtRdata(rdata []string, rtype string) []string { 347 if rtype == "TXT" { 348 for idx, d := range rdata { 349 if strings.Contains(d, "`") { 350 rdata[idx] = strings.ReplaceAll(d, "`", "\"") 351 } 352 } 353 } 354 log.Debugf("Trimmed data: [%v]", rdata) 355 356 return rdata 357 } 358 359 func ttlAsInt(src endpoint.TTL) int { 360 var temp interface{} = int64(src) 361 temp64 := temp.(int64) 362 var ttl int = edgeDNSRecordTTL 363 if temp64 > 0 && temp64 <= int64(maxInt) { 364 ttl = int(temp64) 365 } 366 367 return ttl 368 } 369 370 // Create Endpoint Recordsets 371 func (p AkamaiProvider) createRecordsets(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) error { 372 if len(endpoints) == 0 { 373 log.Info("No endpoints to create") 374 return nil 375 } 376 377 endpointsByZone := edgeChangesByZone(zoneNameIDMapper, endpoints) 378 379 // create all recordsets by zone 380 for zone, endpoints := range endpointsByZone { 381 recordsets := &dns.Recordsets{Recordsets: make([]dns.Recordset, 0)} 382 for _, endpoint := range endpoints { 383 newrec := newAkamaiRecordset(endpoint.DNSName, 384 endpoint.RecordType, 385 ttlAsInt(endpoint.RecordTTL), 386 cleanTargets(endpoint.RecordType, endpoint.Targets...)) 387 logfields := log.Fields{ 388 "record": newrec.Name, 389 "type": newrec.Type, 390 "ttl": newrec.TTL, 391 "target": fmt.Sprintf("%v", newrec.Rdata), 392 "zone": zone, 393 } 394 log.WithFields(logfields).Info("Creating recordsets") 395 recordsets.Recordsets = append(recordsets.Recordsets, newrec) 396 } 397 398 if p.dryRun { 399 continue 400 } 401 // Create recordsets all at once 402 err := p.client.CreateRecordsets(recordsets, zone, true) 403 if err != nil { 404 log.Errorf("Failed to create endpoints for DNS zone %s. Error: %s", zone, err.Error()) 405 return err 406 } 407 } 408 409 return nil 410 } 411 412 func (p AkamaiProvider) deleteRecordsets(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) error { 413 for _, endpoint := range endpoints { 414 zoneName, _ := zoneNameIDMapper.FindZone(endpoint.DNSName) 415 if zoneName == "" { 416 log.Debugf("Skipping Akamai Edge DNS endpoint deletion: '%s' type: '%s', it does not match against Domain filters", endpoint.DNSName, endpoint.RecordType) 417 continue 418 } 419 log.Infof("Akamai Edge DNS recordset deletion- Zone: '%s', DNSName: '%s', RecordType: '%s', Targets: '%+v'", zoneName, endpoint.DNSName, endpoint.RecordType, endpoint.Targets) 420 421 if p.dryRun { 422 continue 423 } 424 425 recName := strings.TrimSuffix(endpoint.DNSName, ".") 426 rec, err := p.client.GetRecord(zoneName, recName, endpoint.RecordType) 427 if err != nil { 428 if _, ok := err.(*dns.RecordError); !ok { 429 return fmt.Errorf("endpoint deletion. record validation failed. error: %w", err) 430 } 431 log.Infof("Endpoint deletion. Record doesn't exist. Name: %s, Type: %s", recName, endpoint.RecordType) 432 continue 433 } 434 if err := p.client.DeleteRecord(rec, zoneName, true); err != nil { 435 log.Errorf("edge dns recordset deletion failed. error: %s", err.Error()) 436 return err 437 } 438 } 439 440 return nil 441 } 442 443 // Update endpoint recordsets 444 func (p AkamaiProvider) updateNewRecordsets(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) error { 445 for _, endpoint := range endpoints { 446 zoneName, _ := zoneNameIDMapper.FindZone(endpoint.DNSName) 447 if zoneName == "" { 448 log.Debugf("Skipping Akamai Edge DNS endpoint update: '%s' type: '%s', it does not match against Domain filters", endpoint.DNSName, endpoint.RecordType) 449 continue 450 } 451 log.Infof("Akamai Edge DNS recordset update - Zone: '%s', DNSName: '%s', RecordType: '%s', Targets: '%+v'", zoneName, endpoint.DNSName, endpoint.RecordType, endpoint.Targets) 452 453 if p.dryRun { 454 continue 455 } 456 457 recName := strings.TrimSuffix(endpoint.DNSName, ".") 458 rec, err := p.client.GetRecord(zoneName, recName, endpoint.RecordType) 459 if err != nil { 460 log.Errorf("Endpoint update. Record validation failed. Error: %s", err.Error()) 461 return err 462 } 463 rec.TTL = ttlAsInt(endpoint.RecordTTL) 464 rec.Target = cleanTargets(endpoint.RecordType, endpoint.Targets...) 465 if err := p.client.UpdateRecord(rec, zoneName, true); err != nil { 466 log.Errorf("Akamai Edge DNS recordset update failed. Error: %s", err.Error()) 467 return err 468 } 469 } 470 471 return nil 472 } 473 474 // edgeChangesByZone separates a multi-zone change into a single change per zone. 475 func edgeChangesByZone(zoneMap provider.ZoneIDName, endpoints []*endpoint.Endpoint) map[string][]*endpoint.Endpoint { 476 createsByZone := make(map[string][]*endpoint.Endpoint, len(zoneMap)) 477 for _, z := range zoneMap { 478 createsByZone[z] = make([]*endpoint.Endpoint, 0) 479 } 480 for _, ep := range endpoints { 481 zone, _ := zoneMap.FindZone(ep.DNSName) 482 if zone != "" { 483 createsByZone[zone] = append(createsByZone[zone], ep) 484 continue 485 } 486 log.Debugf("Skipping Akamai Edge DNS creation of endpoint: '%s' type: '%s', it does not match against Domain filters", ep.DNSName, ep.RecordType) 487 } 488 489 return createsByZone 490 }