diff --git a/controller/execute.go b/controller/execute.go index 5782db8daa..dc0a37a343 100644 --- a/controller/execute.go +++ b/controller/execute.go @@ -301,12 +301,13 @@ func buildProvider( case "ns1": p, err = ns1.NewNS1Provider( ns1.NS1Config{ - DomainFilter: domainFilter, - ZoneIDFilter: zoneIDFilter, - NS1Endpoint: cfg.NS1Endpoint, - NS1IgnoreSSL: cfg.NS1IgnoreSSL, - DryRun: cfg.DryRun, - MinTTLSeconds: cfg.NS1MinTTLSeconds, + DomainFilter: domainFilter, + ZoneIDFilter: zoneIDFilter, + NS1Endpoint: cfg.NS1Endpoint, + NS1IgnoreSSL: cfg.NS1IgnoreSSL, + DryRun: cfg.DryRun, + MinTTLSeconds: cfg.NS1MinTTLSeconds, + ZoneHandleOverrides: cfg.NS1ZoneHandleMap, }, ) case "transip": diff --git a/docs/flags.md b/docs/flags.md index 61ce893406..2c4a13c9c6 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -118,6 +118,7 @@ | `--ns1-endpoint=""` | When using the NS1 provider, specify the URL of the API endpoint to target (default: https://api.nsone.net/v1/) | | `--[no-]ns1-ignoressl` | When using the NS1 provider, specify whether to verify the SSL certificate (default: false) | | `--ns1-min-ttl=0` | Minimal TTL (in seconds) for records. This value will be used if the provided TTL for a service/ingress is lower than this. | +| `--ns1-zone-handle-map=fqdn=handle` | Map FQDN (or suffix) to an NS1 zone handle/ID. Repeatable; k=v form. . | | `--digitalocean-api-page-size=50` | Configure the page size used when querying the DigitalOcean API. | | `--godaddy-api-key=""` | When using the GoDaddy provider, specify the API Key (required when --provider=godaddy) | | `--godaddy-api-secret=""` | When using the GoDaddy provider, specify the API secret (required when --provider=godaddy) | diff --git a/docs/tutorials/ns1.md b/docs/tutorials/ns1.md index 695f63036f..2d41670517 100644 --- a/docs/tutorials/ns1.md +++ b/docs/tutorials/ns1.md @@ -97,13 +97,18 @@ spec: args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. + - --ns1-zone-handle-map="example.com=corp-prod" # (optional) map example.com FQDN to corp-prod zone handle + - --ns1-zone-handle-map="dev.example.com=dev-view" # (optional) multiple FQDN-zone handle mappings - --provider=ns1 env: - - name: NS1_APIKEY + - name: NS1_APIKEY valueFrom: secretKeyRef: name: NS1_APIKEY key: NS1_API_KEY + - name: EXTERNAL_DNS_NS1_ZONE_HANDLE_MAP + value: "example.com=corp-prod\ndev.example.com=dev-view" + ``` ### Manifest (for clusters with RBAC enabled) diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index d9f8e6efb5..d8f03445bb 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -190,6 +190,7 @@ type Config struct { NS1Endpoint string NS1IgnoreSSL bool NS1MinTTLSeconds int + NS1ZoneHandleMap map[string]string TransIPAccountName string TransIPPrivateKeyFile string DigitalOceanAPIPageSize int @@ -312,6 +313,7 @@ var defaultConfig = &Config{ NAT64Networks: []string{}, NS1Endpoint: "", NS1IgnoreSSL: false, + NS1ZoneHandleMap: map[string]string{}, OCIConfigFile: "/etc/kubernetes/oci.yaml", OCIZoneCacheDuration: 0 * time.Second, OCIZoneScope: "GLOBAL", @@ -447,7 +449,8 @@ var allowedSources = []string{ // NewConfig returns new Config object func NewConfig() *Config { return &Config{ - AWSSDCreateTag: map[string]string{}, + AWSSDCreateTag: map[string]string{}, + NS1ZoneHandleMap: map[string]string{}, } } diff --git a/pkg/apis/externaldns/types_test.go b/pkg/apis/externaldns/types_test.go index 9a35cd3eb9..c5e818c703 100644 --- a/pkg/apis/externaldns/types_test.go +++ b/pkg/apis/externaldns/types_test.go @@ -134,6 +134,7 @@ var ( WebhookProviderReadTimeout: 5 * time.Second, WebhookProviderWriteTimeout: 10 * time.Second, ExcludeUnschedulable: true, + NS1ZoneHandleMap: map[string]string{}, } overriddenConfig = &Config{ @@ -235,18 +236,22 @@ var ( CRDSourceKind: "Endpoint", NS1Endpoint: "https://api.example.com/v1", NS1IgnoreSSL: true, - TransIPAccountName: "transip", - TransIPPrivateKeyFile: "/path/to/transip.key", - DigitalOceanAPIPageSize: 100, - ManagedDNSRecordTypes: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME, endpoint.RecordTypeNS}, - RFC2136BatchChangeSize: 100, - RFC2136Host: []string{"rfc2136-host1", "rfc2136-host2"}, - RFC2136LoadBalancingStrategy: "round-robin", - PiholeApiVersion: "6", - WebhookProviderURL: "http://localhost:8888", - WebhookProviderReadTimeout: 5 * time.Second, - WebhookProviderWriteTimeout: 10 * time.Second, - ExcludeUnschedulable: false, + NS1ZoneHandleMap: map[string]string{ + "example.com": "corp-prod", + "dev.example.com": "dev-view", + }, + TransIPAccountName: "transip", + TransIPPrivateKeyFile: "/path/to/transip.key", + DigitalOceanAPIPageSize: 100, + ManagedDNSRecordTypes: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME, endpoint.RecordTypeNS}, + RFC2136BatchChangeSize: 100, + RFC2136Host: []string{"rfc2136-host1", "rfc2136-host2"}, + RFC2136LoadBalancingStrategy: "round-robin", + PiholeApiVersion: "6", + WebhookProviderURL: "http://localhost:8888", + WebhookProviderReadTimeout: 5 * time.Second, + WebhookProviderWriteTimeout: 10 * time.Second, + ExcludeUnschedulable: false, } ) @@ -380,6 +385,8 @@ func TestParseFlags(t *testing.T) { "--crd-source-kind=Endpoint", "--ns1-endpoint=https://api.example.com/v1", "--ns1-ignoressl", + "--ns1-zone-handle-map=example.com=corp-prod", + "--ns1-zone-handle-map=dev.example.com=dev-view", "--transip-account=transip", "--transip-keyfile=/path/to/transip.key", "--digitalocean-api-page-size=100", @@ -501,6 +508,7 @@ func TestParseFlags(t *testing.T) { "EXTERNAL_DNS_CRD_SOURCE_KIND": "Endpoint", "EXTERNAL_DNS_NS1_ENDPOINT": "https://api.example.com/v1", "EXTERNAL_DNS_NS1_IGNORESSL": "1", + "EXTERNAL_DNS_NS1_ZONE_HANDLE_MAP": "example.com=corp-prod\ndev.example.com=dev-view", "EXTERNAL_DNS_TRANSIP_ACCOUNT": "transip", "EXTERNAL_DNS_TRANSIP_KEYFILE": "/path/to/transip.key", "EXTERNAL_DNS_DIGITALOCEAN_API_PAGE_SIZE": "100", diff --git a/provider/ns1/ns1.go b/provider/ns1/ns1.go index 135235ad44..e082c9648f 100644 --- a/provider/ns1/ns1.go +++ b/provider/ns1/ns1.go @@ -85,12 +85,13 @@ func (n NS1DomainService) ListZones() ([]*dns.Zone, *http.Response, error) { // NS1Config passes cli args to the NS1Provider type NS1Config struct { - DomainFilter *endpoint.DomainFilter - ZoneIDFilter provider.ZoneIDFilter - NS1Endpoint string - NS1IgnoreSSL bool - DryRun bool - MinTTLSeconds int + DomainFilter *endpoint.DomainFilter + ZoneIDFilter provider.ZoneIDFilter + NS1Endpoint string + NS1IgnoreSSL bool + DryRun bool + MinTTLSeconds int + ZoneHandleOverrides map[string]string } // NS1Provider is the NS1 provider @@ -101,6 +102,8 @@ type NS1Provider struct { zoneIDFilter provider.ZoneIDFilter dryRun bool minTTLSeconds int + // normalized overrides: fqdn (no trailing dot, lowercased) -> handle/ID (lowercased) + zoneHandleOverrides map[string]string } // NewNS1Provider creates a new NS1 Provider @@ -137,10 +140,11 @@ func newNS1ProviderWithHTTPClient(config NS1Config, client *http.Client) (*NS1Pr apiClient := api.NewClient(client, clientArgs...) return &NS1Provider{ - client: NS1DomainService{apiClient}, - domainFilter: config.DomainFilter, - zoneIDFilter: config.ZoneIDFilter, - minTTLSeconds: config.MinTTLSeconds, + client: NS1DomainService{apiClient}, + domainFilter: config.DomainFilter, + zoneIDFilter: config.ZoneIDFilter, + minTTLSeconds: config.MinTTLSeconds, + zoneHandleOverrides: normalizeOverrides(config.ZoneHandleOverrides), }, nil } @@ -155,9 +159,19 @@ func (p *NS1Provider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) for _, zone := range zones { // TODO handle Header Codes - zoneData, _, err := p.client.GetZone(zone.String()) + // Prefer lookup via handle/ID if an override exists; fall back to FQDN. + lookup := p.longestMatch(zone.Zone) + zoneData, _, err := p.client.GetZone(lookup) if err != nil { - return nil, err + if lookup != strings.TrimSuffix(zone.Zone, ".") { + // fallback to FQDN lookup if override missed + zoneData, _, err = p.client.GetZone(zone.Zone) + if err != nil { + return nil, err + } + } else { + return nil, err + } } for _, record := range zoneData.Records { @@ -208,7 +222,7 @@ func (p *NS1Provider) ns1SubmitChanges(changes []*ns1Change) error { } // separate into per-zone change sets to be passed to the API. - changesByZone := ns1ChangesByZone(zones, changes) + changesByZone := p.ns1ChangesByZone(zones, changes) for zoneName, changes := range changesByZone { for _, change := range changes { record := p.ns1BuildRecord(zoneName, change) @@ -302,15 +316,38 @@ func newNS1Changes(action string, endpoints []*endpoint.Endpoint) []*ns1Change { return changes } +// normalizeOverrides lowercases keys/values and strips any trailing dot on keys. +func normalizeOverrides(m map[string]string) map[string]string { + if len(m) == 0 { + return map[string]string{} + } + out := make(map[string]string) + for k, v := range m { + kk := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(k)), ".") + vv := strings.ToLower(strings.TrimSpace(v)) + + if kk == "" || vv == "" { + log.Debugf("Encountered empty string for zone handle override: key=%s, value=%s", kk, vv) + continue + } + + out[kk] = vv + } + return out +} + // ns1ChangesByZone separates a multi-zone change into a single change per zone. -func ns1ChangesByZone(zones []*dns.Zone, changeSets []*ns1Change) map[string][]*ns1Change { +// The map key becomes the "write key": handle/ID if overridden, else FQDN. +func (p *NS1Provider) ns1ChangesByZone(zones []*dns.Zone, changeSets []*ns1Change) map[string][]*ns1Change { changes := make(map[string][]*ns1Change) zoneNameIDMapper := provider.ZoneIDName{} + for _, z := range zones { zoneNameIDMapper.Add(z.Zone, z.Zone) changes[z.Zone] = []*ns1Change{} } + // group changes by zone FQDN for _, c := range changeSets { zone, _ := zoneNameIDMapper.FindZone(c.Endpoint.DNSName) if zone == "" { @@ -320,5 +357,34 @@ func ns1ChangesByZone(zones []*dns.Zone, changeSets []*ns1Change) map[string][]* changes[zone] = append(changes[zone], c) } + // replace zone FQDN with zone handle if FQDN is overridden + for k, v := range changes { + writeKey := p.longestMatch(k) + + if writeKey != k { + changes[writeKey] = v + delete(changes, k) + } + } + return changes } + +// longestMatch returns the preferred key to pass to GetZone: +// if an override exists for fqdn (or a more specific suffix), return its mapped handle/ID; +// otherwise return the normalized FQDN. +func (p *NS1Provider) longestMatch(fqdn string) string { + name := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(fqdn)), ".") + bestKey := "" + for k := range p.zoneHandleOverrides { + if name == k || strings.HasSuffix(name, "."+k) { + if len(k) > len(bestKey) { + bestKey = k + } + } + } + if bestKey != "" { + return p.zoneHandleOverrides[bestKey] + } + return name +} diff --git a/provider/ns1/ns1_test.go b/provider/ns1/ns1_test.go index 930fce1db2..b1f1cec596 100644 --- a/provider/ns1/ns1_test.go +++ b/provider/ns1/ns1_test.go @@ -314,7 +314,118 @@ func TestNewNS1ChangesByZone(t *testing.T) { }, } - changes := ns1ChangesByZone(zones, changeSets) + changes := provider.ns1ChangesByZone(zones, changeSets) assert.Len(t, changes["bar.com"], 1) assert.Len(t, changes["foo.com"], 3) } + +func change(action, name, rtype string) *ns1Change { + return &ns1Change{ + Action: action, + Endpoint: &endpoint.Endpoint{ + DNSName: name, + Targets: endpoint.Targets{"target"}, + RecordType: rtype, + }, + } +} + +func TestNS1ChangesByZone_WithZoneOverrideForOneZone(t *testing.T) { + provider := &NS1Provider{ + client: &MockNS1DomainClient{}, + zoneHandleOverrides: normalizeOverrides(map[string]string{"foo.com": "foo-handle"}), + } + + zones, _ := provider.zonesFiltered() + changeSets := []*ns1Change{ + change(ns1Create, "new.foo.com", "A"), + change(ns1Create, "unrelated.bar.com", "A"), + change(ns1Delete, "test.foo.com", "A"), + change(ns1Update, "test.foo.com", "A"), + } + + changes := provider.ns1ChangesByZone(zones, changeSets) + + // bar.com unchanged; foo.com should be bucketed under the handle + assert.Len(t, changes["bar.com"], 1, "bar.com should still use its FQDN key") + _, hasFooFQDN := changes["foo.com"] + assert.False(t, hasFooFQDN, "foo.com key should not exist when overridden") + + assert.Len(t, changes["foo-handle"], 3, "foo records should bucket under handle when overridden") +} + +func TestNS1ChangesByZone_WithOverridesForBothZones(t *testing.T) { + provider := &NS1Provider{ + client: &MockNS1DomainClient{}, + zoneHandleOverrides: normalizeOverrides(map[string]string{ + "foo.com": "corp-prod-zone", + "bar.com": "bar-view-handle", + }), + } + + zones, _ := provider.zonesFiltered() + changeSets := []*ns1Change{ + change(ns1Create, "new.foo.com", "A"), + change(ns1Create, "unrelated.bar.com", "A"), + change(ns1Delete, "test.foo.com", "A"), + change(ns1Update, "test.foo.com", "A"), + } + + changes := provider.ns1ChangesByZone(zones, changeSets) + + // Both zones should use their mapped handles; no FQDN keys present + _, hasFooFQDN := changes["foo.com"] + _, hasBarFQDN := changes["bar.com"] + assert.False(t, hasFooFQDN, "foo.com key should not exist when overridden") + assert.False(t, hasBarFQDN, "bar.com key should not exist when overridden") + + assert.Len(t, changes["corp-prod-zone"], 3) + assert.Len(t, changes["bar-view-handle"], 1) +} + +func TestNS1ChangesByZone_OverrideNormalizationAndSuffix(t *testing.T) { + // Uppercase + trailing dot should normalize; override should still take effect. + provider := &NS1Provider{ + client: &MockNS1DomainClient{}, + zoneHandleOverrides: normalizeOverrides(map[string]string{"Foo.COM.": "FOO-HANDLE"}), + } + + zones, _ := provider.zonesFiltered() + changeSets := []*ns1Change{ + change(ns1Create, "new.foo.com", "A"), + change(ns1Delete, "test.foo.com", "A"), + change(ns1Update, "test.foo.com", "A"), + } + + changes := provider.ns1ChangesByZone(zones, changeSets) + + _, hasFooFQDN := changes["foo.com"] + assert.False(t, hasFooFQDN, "normalized override should suppress FQDN key") + if _, ok := changes["foo-handle"]; ok { + assert.Len(t, changes["foo-handle"], 3) + } else { + assert.Len(t, changes["FOO-HANDLE"], 3) + } +} + +func TestNS1ChangesByZone_IgnoresUnmatchedRecords(t *testing.T) { + provider := &NS1Provider{ + client: &MockNS1DomainClient{}, + zoneHandleOverrides: normalizeOverrides(map[string]string{"foo.com": "foo-handle"}), + } + + zones, _ := provider.zonesFiltered() + changeSets := []*ns1Change{ + change(ns1Create, "unknown.baz.com", "A"), // does not match foo.com or bar.com + } + + changes := provider.ns1ChangesByZone(zones, changeSets) + + // Should still have exactly the zone keys for the provided zones, but no entries inside. + if gs, ok := changes["foo-handle"]; ok { + assert.Empty(t, gs) + } + if gs, ok := changes["bar.com"]; ok { + assert.Empty(t, gs) + } +}