sigs.k8s.io/external-dns@v0.14.1/provider/designate/designate.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 designate 18 19 import ( 20 "context" 21 "fmt" 22 "net" 23 "net/http" 24 "os" 25 "strings" 26 "time" 27 28 "github.com/gophercloud/gophercloud" 29 "github.com/gophercloud/gophercloud/openstack" 30 "github.com/gophercloud/gophercloud/openstack/dns/v2/recordsets" 31 "github.com/gophercloud/gophercloud/openstack/dns/v2/zones" 32 "github.com/gophercloud/gophercloud/pagination" 33 log "github.com/sirupsen/logrus" 34 35 "sigs.k8s.io/external-dns/endpoint" 36 "sigs.k8s.io/external-dns/pkg/tlsutils" 37 "sigs.k8s.io/external-dns/plan" 38 "sigs.k8s.io/external-dns/provider" 39 ) 40 41 const ( 42 // ID of the RecordSet from which endpoint was created 43 designateRecordSetID = "designate-recordset-id" 44 // Zone ID of the RecordSet 45 designateZoneID = "designate-record-id" 46 47 // Initial records values of the RecordSet. This label is required in order not to loose records that haven't 48 // changed where there are several targets per domain and only some of them changed. 49 // Values are joined by zero-byte to in order to get a single string 50 designateOriginalRecords = "designate-original-records" 51 ) 52 53 // interface between provider and OpenStack DNS API 54 type designateClientInterface interface { 55 // ForEachZone calls handler for each zone managed by the Designate 56 ForEachZone(handler func(zone *zones.Zone) error) error 57 58 // ForEachRecordSet calls handler for each recordset in the given DNS zone 59 ForEachRecordSet(zoneID string, handler func(recordSet *recordsets.RecordSet) error) error 60 61 // CreateRecordSet creates recordset in the given DNS zone 62 CreateRecordSet(zoneID string, opts recordsets.CreateOpts) (string, error) 63 64 // UpdateRecordSet updates recordset in the given DNS zone 65 UpdateRecordSet(zoneID, recordSetID string, opts recordsets.UpdateOpts) error 66 67 // DeleteRecordSet deletes recordset in the given DNS zone 68 DeleteRecordSet(zoneID, recordSetID string) error 69 } 70 71 // implementation of the designateClientInterface 72 type designateClient struct { 73 serviceClient *gophercloud.ServiceClient 74 } 75 76 // factory function for the designateClientInterface 77 func newDesignateClient() (designateClientInterface, error) { 78 serviceClient, err := createDesignateServiceClient() 79 if err != nil { 80 return nil, err 81 } 82 return &designateClient{serviceClient}, nil 83 } 84 85 // copies environment variables to new names without overwriting existing values 86 func remapEnv(mapping map[string]string) { 87 for k, v := range mapping { 88 currentVal := os.Getenv(k) 89 newVal := os.Getenv(v) 90 if currentVal == "" && newVal != "" { 91 os.Setenv(k, newVal) 92 } 93 } 94 } 95 96 // returns OpenStack Keystone authentication settings by obtaining values from standard environment variables. 97 // also fixes incompatibilities between gophercloud implementation and *-stackrc files that can be downloaded 98 // from OpenStack dashboard in latest versions 99 func getAuthSettings() (gophercloud.AuthOptions, error) { 100 remapEnv(map[string]string{ 101 "OS_TENANT_NAME": "OS_PROJECT_NAME", 102 "OS_TENANT_ID": "OS_PROJECT_ID", 103 "OS_DOMAIN_NAME": "OS_USER_DOMAIN_NAME", 104 "OS_DOMAIN_ID": "OS_USER_DOMAIN_ID", 105 }) 106 107 opts, err := openstack.AuthOptionsFromEnv() 108 if err != nil { 109 return gophercloud.AuthOptions{}, err 110 } 111 opts.AllowReauth = true 112 if !strings.HasSuffix(opts.IdentityEndpoint, "/") { 113 opts.IdentityEndpoint += "/" 114 } 115 if !strings.HasSuffix(opts.IdentityEndpoint, "/v2.0/") && !strings.HasSuffix(opts.IdentityEndpoint, "/v3/") { 116 opts.IdentityEndpoint += "v2.0/" 117 } 118 return opts, nil 119 } 120 121 // authenticate in OpenStack and obtain Designate service endpoint 122 func createDesignateServiceClient() (*gophercloud.ServiceClient, error) { 123 opts, err := getAuthSettings() 124 if err != nil { 125 return nil, err 126 } 127 log.Infof("Using OpenStack Keystone at %s", opts.IdentityEndpoint) 128 authProvider, err := openstack.NewClient(opts.IdentityEndpoint) 129 if err != nil { 130 return nil, err 131 } 132 133 tlsConfig, err := tlsutils.CreateTLSConfig("OPENSTACK") 134 if err != nil { 135 return nil, err 136 } 137 138 transport := &http.Transport{ 139 Proxy: http.ProxyFromEnvironment, 140 DialContext: (&net.Dialer{ 141 Timeout: 30 * time.Second, 142 KeepAlive: 30 * time.Second, 143 }).DialContext, 144 MaxIdleConns: 100, 145 IdleConnTimeout: 90 * time.Second, 146 TLSHandshakeTimeout: 10 * time.Second, 147 ExpectContinueTimeout: 1 * time.Second, 148 TLSClientConfig: tlsConfig, 149 } 150 authProvider.HTTPClient.Transport = transport 151 152 if err = openstack.Authenticate(authProvider, opts); err != nil { 153 return nil, err 154 } 155 156 eo := gophercloud.EndpointOpts{ 157 Region: os.Getenv("OS_REGION_NAME"), 158 } 159 160 client, err := openstack.NewDNSV2(authProvider, eo) 161 if err != nil { 162 return nil, err 163 } 164 log.Infof("Found OpenStack Designate service at %s", client.Endpoint) 165 return client, nil 166 } 167 168 // ForEachZone calls handler for each zone managed by the Designate 169 func (c designateClient) ForEachZone(handler func(zone *zones.Zone) error) error { 170 pager := zones.List(c.serviceClient, zones.ListOpts{}) 171 return pager.EachPage( 172 func(page pagination.Page) (bool, error) { 173 list, err := zones.ExtractZones(page) 174 if err != nil { 175 return false, err 176 } 177 for _, zone := range list { 178 err := handler(&zone) 179 if err != nil { 180 return false, err 181 } 182 } 183 return true, nil 184 }, 185 ) 186 } 187 188 // ForEachRecordSet calls handler for each recordset in the given DNS zone 189 func (c designateClient) ForEachRecordSet(zoneID string, handler func(recordSet *recordsets.RecordSet) error) error { 190 pager := recordsets.ListByZone(c.serviceClient, zoneID, recordsets.ListOpts{}) 191 return pager.EachPage( 192 func(page pagination.Page) (bool, error) { 193 list, err := recordsets.ExtractRecordSets(page) 194 if err != nil { 195 return false, err 196 } 197 for _, recordSet := range list { 198 err := handler(&recordSet) 199 if err != nil { 200 return false, err 201 } 202 } 203 return true, nil 204 }, 205 ) 206 } 207 208 // CreateRecordSet creates recordset in the given DNS zone 209 func (c designateClient) CreateRecordSet(zoneID string, opts recordsets.CreateOpts) (string, error) { 210 r, err := recordsets.Create(c.serviceClient, zoneID, opts).Extract() 211 if err != nil { 212 return "", err 213 } 214 return r.ID, nil 215 } 216 217 // UpdateRecordSet updates recordset in the given DNS zone 218 func (c designateClient) UpdateRecordSet(zoneID, recordSetID string, opts recordsets.UpdateOpts) error { 219 _, err := recordsets.Update(c.serviceClient, zoneID, recordSetID, opts).Extract() 220 return err 221 } 222 223 // DeleteRecordSet deletes recordset in the given DNS zone 224 func (c designateClient) DeleteRecordSet(zoneID, recordSetID string) error { 225 return recordsets.Delete(c.serviceClient, zoneID, recordSetID).ExtractErr() 226 } 227 228 // designate provider type 229 type designateProvider struct { 230 provider.BaseProvider 231 client designateClientInterface 232 233 // only consider hosted zones managing domains ending in this suffix 234 domainFilter endpoint.DomainFilter 235 dryRun bool 236 } 237 238 // NewDesignateProvider is a factory function for OpenStack designate providers 239 func NewDesignateProvider(domainFilter endpoint.DomainFilter, dryRun bool) (provider.Provider, error) { 240 client, err := newDesignateClient() 241 if err != nil { 242 return nil, err 243 } 244 return &designateProvider{ 245 client: client, 246 domainFilter: domainFilter, 247 dryRun: dryRun, 248 }, nil 249 } 250 251 // converts domain names to FQDN 252 func canonicalizeDomainNames(domains []string) []string { 253 var cDomains []string 254 for _, d := range domains { 255 if !strings.HasSuffix(d, ".") { 256 d += "." 257 cDomains = append(cDomains, strings.ToLower(d)) 258 } 259 } 260 return cDomains 261 } 262 263 // converts domain name to FQDN 264 func canonicalizeDomainName(d string) string { 265 if !strings.HasSuffix(d, ".") { 266 d += "." 267 } 268 return strings.ToLower(d) 269 } 270 271 // returns ZoneID -> ZoneName mapping for zones that are managed by the Designate and match domain filter 272 func (p designateProvider) getZones() (map[string]string, error) { 273 result := map[string]string{} 274 275 err := p.client.ForEachZone( 276 func(zone *zones.Zone) error { 277 if zone.Type != "" && strings.ToUpper(zone.Type) != "PRIMARY" || zone.Status != "ACTIVE" { 278 return nil 279 } 280 281 zoneName := canonicalizeDomainName(zone.Name) 282 if !p.domainFilter.Match(zoneName) { 283 return nil 284 } 285 result[zone.ID] = zoneName 286 return nil 287 }, 288 ) 289 290 return result, err 291 } 292 293 // finds best suitable DNS zone for the hostname 294 func (p designateProvider) getHostZoneID(hostname string, managedZones map[string]string) (string, error) { 295 longestZoneLength := 0 296 resultID := "" 297 298 for zoneID, zoneName := range managedZones { 299 if !strings.HasSuffix(hostname, zoneName) { 300 continue 301 } 302 ln := len(zoneName) 303 if ln > longestZoneLength { 304 resultID = zoneID 305 longestZoneLength = ln 306 } 307 } 308 309 return resultID, nil 310 } 311 312 // Records returns the list of records. 313 func (p designateProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { 314 var result []*endpoint.Endpoint 315 managedZones, err := p.getZones() 316 if err != nil { 317 return nil, err 318 } 319 for zoneID := range managedZones { 320 err = p.client.ForEachRecordSet(zoneID, 321 func(recordSet *recordsets.RecordSet) error { 322 if recordSet.Type != endpoint.RecordTypeA && recordSet.Type != endpoint.RecordTypeTXT && recordSet.Type != endpoint.RecordTypeCNAME { 323 return nil 324 } 325 326 ep := endpoint.NewEndpoint(recordSet.Name, recordSet.Type, recordSet.Records...) 327 ep.Labels[designateRecordSetID] = recordSet.ID 328 ep.Labels[designateZoneID] = recordSet.ZoneID 329 ep.Labels[designateOriginalRecords] = strings.Join(recordSet.Records, "\000") 330 result = append(result, ep) 331 332 return nil 333 }, 334 ) 335 if err != nil { 336 return nil, err 337 } 338 } 339 340 return result, nil 341 } 342 343 // temporary structure to hold recordset parameters so that we could aggregate endpoints into recordsets 344 type recordSet struct { 345 dnsName string 346 recordType string 347 zoneID string 348 recordSetID string 349 names map[string]bool 350 } 351 352 // adds endpoint into recordset aggregation, loading original values from endpoint labels first 353 func addEndpoint(ep *endpoint.Endpoint, recordSets map[string]*recordSet, oldEndpoints []*endpoint.Endpoint, delete bool) { 354 key := fmt.Sprintf("%s/%s", ep.DNSName, ep.RecordType) 355 rs := recordSets[key] 356 if rs == nil { 357 rs = &recordSet{ 358 dnsName: canonicalizeDomainName(ep.DNSName), 359 recordType: ep.RecordType, 360 names: make(map[string]bool), 361 } 362 } 363 364 addDesignateIDLabelsFromExistingEndpoints(oldEndpoints, ep) 365 366 if rs.zoneID == "" { 367 rs.zoneID = ep.Labels[designateZoneID] 368 } 369 if rs.recordSetID == "" { 370 rs.recordSetID = ep.Labels[designateRecordSetID] 371 } 372 for _, rec := range strings.Split(ep.Labels[designateOriginalRecords], "\000") { 373 if _, ok := rs.names[rec]; !ok && rec != "" { 374 rs.names[rec] = true 375 } 376 } 377 targets := ep.Targets 378 if ep.RecordType == endpoint.RecordTypeCNAME { 379 targets = canonicalizeDomainNames(targets) 380 } 381 for _, t := range targets { 382 rs.names[t] = !delete 383 } 384 recordSets[key] = rs 385 } 386 387 // addDesignateIDLabelsFromExistingEndpoints adds the labels identified by the constants designateZoneID and designateRecordSetID 388 // to an Endpoint. Therefore, it searches all given existing endpoints for an endpoint with the same record type and record 389 // value. If the given Endpoint already has the labels set, they are left untouched. This fixes an issue with the 390 // TXTRegistry which generates new TXT entries instead of updating the old ones. 391 func addDesignateIDLabelsFromExistingEndpoints(existingEndpoints []*endpoint.Endpoint, ep *endpoint.Endpoint) { 392 _, hasZoneIDLabel := ep.Labels[designateZoneID] 393 _, hasRecordSetIDLabel := ep.Labels[designateRecordSetID] 394 if hasZoneIDLabel && hasRecordSetIDLabel { 395 return 396 } 397 for _, oep := range existingEndpoints { 398 if ep.RecordType == oep.RecordType && ep.DNSName == oep.DNSName { 399 if !hasZoneIDLabel { 400 ep.Labels[designateZoneID] = oep.Labels[designateZoneID] 401 } 402 if !hasRecordSetIDLabel { 403 ep.Labels[designateRecordSetID] = oep.Labels[designateRecordSetID] 404 } 405 return 406 } 407 } 408 } 409 410 // ApplyChanges applies a given set of changes in a given zone. 411 func (p designateProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { 412 managedZones, err := p.getZones() 413 if err != nil { 414 return err 415 } 416 417 endpoints, err := p.Records(ctx) 418 if err != nil { 419 return fmt.Errorf("failed to fetch active records: %w", err) 420 } 421 422 recordSets := map[string]*recordSet{} 423 for _, ep := range changes.Create { 424 addEndpoint(ep, recordSets, endpoints, false) 425 } 426 for _, ep := range changes.UpdateOld { 427 addEndpoint(ep, recordSets, endpoints, true) 428 } 429 for _, ep := range changes.UpdateNew { 430 addEndpoint(ep, recordSets, endpoints, false) 431 } 432 for _, ep := range changes.Delete { 433 addEndpoint(ep, recordSets, endpoints, true) 434 } 435 436 for _, rs := range recordSets { 437 if err2 := p.upsertRecordSet(rs, managedZones); err == nil { 438 err = err2 439 } 440 } 441 return err 442 } 443 444 // apply recordset changes by inserting/updating/deleting recordsets 445 func (p designateProvider) upsertRecordSet(rs *recordSet, managedZones map[string]string) error { 446 if rs.zoneID == "" { 447 var err error 448 rs.zoneID, err = p.getHostZoneID(rs.dnsName, managedZones) 449 if err != nil { 450 return err 451 } 452 if rs.zoneID == "" { 453 log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", rs.dnsName) 454 return nil 455 } 456 } 457 var records []string 458 for rec, v := range rs.names { 459 if v { 460 records = append(records, rec) 461 } 462 } 463 if rs.recordSetID == "" && records == nil { 464 return nil 465 } 466 if rs.recordSetID == "" { 467 opts := recordsets.CreateOpts{ 468 Name: rs.dnsName, 469 Type: rs.recordType, 470 Records: records, 471 } 472 log.Infof("Creating records: %s/%s: %s", rs.dnsName, rs.recordType, strings.Join(records, ",")) 473 if p.dryRun { 474 return nil 475 } 476 _, err := p.client.CreateRecordSet(rs.zoneID, opts) 477 return err 478 } else if len(records) == 0 { 479 log.Infof("Deleting records for %s/%s", rs.dnsName, rs.recordType) 480 if p.dryRun { 481 return nil 482 } 483 return p.client.DeleteRecordSet(rs.zoneID, rs.recordSetID) 484 } else { 485 ttl := 0 486 opts := recordsets.UpdateOpts{ 487 Records: records, 488 TTL: &ttl, 489 } 490 log.Infof("Updating records: %s/%s: %s", rs.dnsName, rs.recordType, strings.Join(records, ",")) 491 if p.dryRun { 492 return nil 493 } 494 return p.client.UpdateRecordSet(rs.zoneID, rs.recordSetID, opts) 495 } 496 }