diff --git a/provider/pdns/pdns.go b/provider/pdns/pdns.go index 532c049a37..8cea54f12a 100644 --- a/provider/pdns/pdns.go +++ b/provider/pdns/pdns.go @@ -137,18 +137,16 @@ func stringifyHTTPResponseBody(r *http.Response) string { // well as mock APIClients used in testing type PDNSAPIProvider interface { ListZones() ([]pgo.Zone, *http.Response, error) - PartitionZones(zones []pgo.Zone) ([]pgo.Zone, []pgo.Zone) ListZone(zoneID string) (pgo.Zone, *http.Response, error) PatchZone(zoneID string, zoneStruct pgo.Zone) (*http.Response, error) } // PDNSAPIClient : Struct that encapsulates all the PowerDNS specific implementation details type PDNSAPIClient struct { - dryRun bool - serverID string - authCtx context.Context - client *pgo.APIClient - domainFilter *endpoint.DomainFilter + dryRun bool + serverID string + authCtx context.Context + client *pgo.APIClient } // ListZones : Method returns all enabled zones from PowerDNS @@ -171,23 +169,22 @@ func (c *PDNSAPIClient) ListZones() ([]pgo.Zone, *http.Response, error) { return zones, resp, provider.NewSoftErrorf("unable to list zones: %v", err) } -// PartitionZones : Method returns a slice of zones that adhere to the domain filter and a slice of ones that does not adhere to the filter -func (c *PDNSAPIClient) PartitionZones(zones []pgo.Zone) ([]pgo.Zone, []pgo.Zone) { - var filteredZones []pgo.Zone - var residualZones []pgo.Zone +// partitionZones returns a slice of zones that adhere to the domain filter and a slice of ones that do not adhere to the filter. +func partitionZones(zones []pgo.Zone, domainFilter *endpoint.DomainFilter) ([]pgo.Zone, []pgo.Zone) { + if domainFilter == nil || !domainFilter.IsConfigured() { + return zones, nil + } - if c.domainFilter.IsConfigured() { - for _, zone := range zones { - if c.domainFilter.Match(zone.Name) { - filteredZones = append(filteredZones, zone) - } else { - residualZones = append(residualZones, zone) - } + var filtered, residual []pgo.Zone + for _, zone := range zones { + if domainFilter.Match(zone.Name) { + filtered = append(filtered, zone) + } else { + residual = append(residual, zone) } - } else { - filteredZones = zones } - return filteredZones, residualZones + + return filtered, residual } // ListZone : Method returns the details of a specific zone from PowerDNS @@ -229,7 +226,8 @@ func (c *PDNSAPIClient) PatchZone(zoneID string, zoneStruct pgo.Zone) (*http.Res // PDNSProvider is an implementation of the Provider interface for PowerDNS type PDNSProvider struct { provider.BaseProvider - client PDNSAPIProvider + client PDNSAPIProvider + domainFilter *endpoint.DomainFilter } // NewPDNSProvider initializes a new PowerDNS based Provider. @@ -258,16 +256,47 @@ func NewPDNSProvider(ctx context.Context, config PDNSConfig) (*PDNSProvider, err provider := &PDNSProvider{ client: &PDNSAPIClient{ - dryRun: config.DryRun, - serverID: config.ServerID, - authCtx: context.WithValue(ctx, pgo.ContextAPIKey, pgo.APIKey{Key: config.APIKey}), - client: pgo.NewAPIClient(pdnsClientConfig), - domainFilter: config.DomainFilter, + dryRun: config.DryRun, + serverID: config.ServerID, + authCtx: context.WithValue(ctx, pgo.ContextAPIKey, pgo.APIKey{Key: config.APIKey}), + client: pgo.NewAPIClient(pdnsClientConfig), }, + domainFilter: config.DomainFilter, } return provider, nil } +// filteredZones fetches all zones from the PowerDNS API and partitions them +// using the provider's domain filter. It returns the matching zones, the +// non-matching (residual) zones, and any error from the API call. +func (p *PDNSProvider) filteredZones() ([]pgo.Zone, []pgo.Zone, error) { + zones, _, err := p.client.ListZones() + if err != nil { + return nil, nil, err + } + filtered, residual := partitionZones(zones, p.domainFilter) + return filtered, residual, nil +} + +func (p *PDNSProvider) GetDomainFilter() endpoint.DomainFilterInterface { + // Return all zones the provider manages so the controller can intersect + // with --domain-filter on its own. Do NOT apply p.domainFilter here; + // double-filtering would produce an empty filter when no zones match, + // silently failing open instead of letting the controller see the + // mismatch and produce a safe empty plan. + zones, _, err := p.client.ListZones() + if err != nil { + log.Errorf("Unable to fetch zones from PowerDNS API: %v", err) + return &endpoint.DomainFilter{} + } + + zoneNames := make([]string, 0, 2*len(zones)) + for _, zone := range zones { + zoneNames = append(zoneNames, zone.Name, "."+zone.Name) + } + return endpoint.NewDomainFilter(zoneNames) +} + // hasAliasAnnotation checks if the endpoint has the alias annotation set to true func (p *PDNSProvider) hasAliasAnnotation(ep *endpoint.Endpoint) bool { value, exists := ep.GetProviderSpecificProperty("alias") @@ -308,11 +337,10 @@ func (p *PDNSProvider) ConvertEndpointsToZones(eps []*endpoint.Endpoint, changet return endpoints[i].DNSName < endpoints[j].DNSName }) - zones, _, err := p.client.ListZones() + filteredZones, residualZones, err := p.filteredZones() if err != nil { return nil, err } - filteredZones, residualZones := p.client.PartitionZones(zones) // Sort the zone by length of the name in descending order, we use this // property later to ensure we add a record to the longest matching zone @@ -437,11 +465,10 @@ func (p *PDNSProvider) mutateRecords(endpoints []*endpoint.Endpoint, changetype // Records returns all DNS records controlled by the configured PDNS server (for all zones) func (p *PDNSProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) { - zones, _, err := p.client.ListZones() + filteredZones, _, err := p.filteredZones() if err != nil { return nil, err } - filteredZones, _ := p.client.PartitionZones(zones) var endpoints []*endpoint.Endpoint diff --git a/provider/pdns/pdns_test.go b/provider/pdns/pdns_test.go index d92b20076d..e03cb256a1 100644 --- a/provider/pdns/pdns_test.go +++ b/provider/pdns/pdns_test.go @@ -666,57 +666,11 @@ var ( DomainFilterListSingle = endpoint.NewDomainFilter([]string{"example.com"}) - DomainFilterChildListSingle = endpoint.NewDomainFilter([]string{"a.example.com"}) - DomainFilterListMultiple = endpoint.NewDomainFilter([]string{"example.com", "mock.com"}) - DomainFilterChildListMultiple = endpoint.NewDomainFilter([]string{"a.example.com", "c.example.com"}) - DomainFilterListEmpty = endpoint.NewDomainFilter([]string{}) RegexDomainFilter = endpoint.NewRegexDomainFilter(regexp.MustCompile("example.com"), nil) - - DomainFilterEmptyClient = &PDNSAPIClient{ - dryRun: false, - authCtx: context.WithValue(context.Background(), pgo.ContextAPIKey, pgo.APIKey{Key: "TEST-API-KEY"}), - client: pgo.NewAPIClient(pgo.NewConfiguration()), - domainFilter: DomainFilterListEmpty, - } - - DomainFilterSingleClient = &PDNSAPIClient{ - dryRun: false, - authCtx: context.WithValue(context.Background(), pgo.ContextAPIKey, pgo.APIKey{Key: "TEST-API-KEY"}), - client: pgo.NewAPIClient(pgo.NewConfiguration()), - domainFilter: DomainFilterListSingle, - } - - DomainFilterChildSingleClient = &PDNSAPIClient{ - dryRun: false, - authCtx: context.WithValue(context.Background(), pgo.ContextAPIKey, pgo.APIKey{Key: "TEST-API-KEY"}), - client: pgo.NewAPIClient(pgo.NewConfiguration()), - domainFilter: DomainFilterChildListSingle, - } - - DomainFilterMultipleClient = &PDNSAPIClient{ - dryRun: false, - authCtx: context.WithValue(context.Background(), pgo.ContextAPIKey, pgo.APIKey{Key: "TEST-API-KEY"}), - client: pgo.NewAPIClient(pgo.NewConfiguration()), - domainFilter: DomainFilterListMultiple, - } - - DomainFilterChildMultipleClient = &PDNSAPIClient{ - dryRun: false, - authCtx: context.WithValue(context.Background(), pgo.ContextAPIKey, pgo.APIKey{Key: "TEST-API-KEY"}), - client: pgo.NewAPIClient(pgo.NewConfiguration()), - domainFilter: DomainFilterChildListMultiple, - } - - RegexDomainFilterClient = &PDNSAPIClient{ - dryRun: false, - authCtx: context.WithValue(context.Background(), pgo.ContextAPIKey, pgo.APIKey{Key: "TEST-API-KEY"}), - client: pgo.NewAPIClient(pgo.NewConfiguration()), - domainFilter: RegexDomainFilter, - } ) /******************************************************************************/ @@ -727,10 +681,6 @@ func (c *PDNSAPIClientStub) ListZones() ([]pgo.Zone, *http.Response, error) { return []pgo.Zone{ZoneMixed}, nil, nil } -func (c *PDNSAPIClientStub) PartitionZones(zones []pgo.Zone) ([]pgo.Zone, []pgo.Zone) { - return zones, nil -} - func (c *PDNSAPIClientStub) ListZone(_ string) (pgo.Zone, *http.Response, error) { return ZoneMixed, nil, nil } @@ -750,10 +700,6 @@ func (c *PDNSAPIClientStubEmptyZones) ListZones() ([]pgo.Zone, *http.Response, e return []pgo.Zone{ZoneEmpty, ZoneEmptyLong, ZoneEmpty2}, nil, nil } -func (c *PDNSAPIClientStubEmptyZones) PartitionZones(zones []pgo.Zone) ([]pgo.Zone, []pgo.Zone) { - return zones, nil -} - func (c *PDNSAPIClientStubEmptyZones) ListZone(zoneID string) (pgo.Zone, *http.Response, error) { switch { case strings.Contains(zoneID, "example.com"): @@ -815,7 +761,7 @@ type PDNSAPIClientStubPartitionZones struct { } func (c *PDNSAPIClientStubPartitionZones) ListZones() ([]pgo.Zone, *http.Response, error) { - return []pgo.Zone{ZoneEmpty, ZoneEmptyLong, ZoneEmpty2, ZoneEmptySimilar}, nil, nil + return []pgo.Zone{ZoneEmpty, ZoneEmpty2, ZoneEmptySimilar}, nil, nil } func (c *PDNSAPIClientStubPartitionZones) ListZone(zoneID string) (pgo.Zone, *http.Response, error) { @@ -824,17 +770,34 @@ func (c *PDNSAPIClientStubPartitionZones) ListZone(zoneID string) (pgo.Zone, *ht return ZoneEmpty, nil, nil case strings.Contains(zoneID, "mock.test"): return ZoneEmpty2, nil, nil - case strings.Contains(zoneID, "long.domainname.example.com"): - return ZoneEmptyLong, nil, nil case strings.Contains(zoneID, "simexample.com"): return ZoneEmptySimilar, nil, nil } return pgo.Zone{}, nil, nil } -// Just overwrite the ListZones method to introduce a failure -func (c *PDNSAPIClientStubPartitionZones) PartitionZones(_ []pgo.Zone) ([]pgo.Zone, []pgo.Zone) { - return []pgo.Zone{ZoneEmpty}, []pgo.Zone{ZoneEmptyLong, ZoneEmpty2} +/******************************************************************************/ +// Configurable API stub that performs real domain-filter partitioning. +// Use it to test the intersection logic between ListZones results and the +// provider's domain filter. +type PDNSAPIClientStubConfigurable struct { + zones []pgo.Zone + listErr error +} + +func (c *PDNSAPIClientStubConfigurable) ListZones() ([]pgo.Zone, *http.Response, error) { + if c.listErr != nil { + return nil, nil, c.listErr + } + return c.zones, nil, nil +} + +func (c *PDNSAPIClientStubConfigurable) ListZone(_ string) (pgo.Zone, *http.Response, error) { + return pgo.Zone{}, nil, nil +} + +func (c *PDNSAPIClientStubConfigurable) PatchZone(_ string, _ pgo.Zone) (*http.Response, error) { + return &http.Response{}, nil } /******************************************************************************/ @@ -1091,7 +1054,8 @@ func (suite *NewPDNSProviderTestSuite) TestPDNSConvertEndpointsToZones() { func (suite *NewPDNSProviderTestSuite) TestPDNSConvertEndpointsToZonesPartitionZones() { // Test DomainFilters p := &PDNSProvider{ - client: &PDNSAPIClientStubPartitionZones{}, + client: &PDNSAPIClientStubPartitionZones{}, + domainFilter: endpoint.NewDomainFilter([]string{"example.com"}), } // Check inserting endpoints from a single zone which is specified in DomainFilter @@ -1195,21 +1159,21 @@ func (suite *NewPDNSProviderTestSuite) TestPDNSClientPartitionZones() { } // Check filtered, residual zones when no domain filter specified - filteredZones, residualZones := DomainFilterEmptyClient.PartitionZones(zoneList) + filteredZones, residualZones := partitionZones(zoneList, DomainFilterListEmpty) suite.Equal(partitionResultFilteredEmptyFilter, filteredZones) suite.Equal(partitionResultResidualEmptyFilter, residualZones) // Check filtered, residual zones when a single domain filter specified - filteredZones, residualZones = DomainFilterSingleClient.PartitionZones(zoneList) + filteredZones, residualZones = partitionZones(zoneList, DomainFilterListSingle) suite.Equal(partitionResultFilteredSingleFilter, filteredZones) suite.Equal(partitionResultResidualSingleFilter, residualZones) // Check filtered, residual zones when a multiple domain filter specified - filteredZones, residualZones = DomainFilterMultipleClient.PartitionZones(zoneList) + filteredZones, residualZones = partitionZones(zoneList, DomainFilterListMultiple) suite.Equal(partitionResultFilteredMultipleFilter, filteredZones) suite.Equal(partitionResultResidualMultipleFilter, residualZones) - filteredZones, residualZones = RegexDomainFilterClient.PartitionZones(zoneList) + filteredZones, residualZones = partitionZones(zoneList, RegexDomainFilter) suite.Equal(partitionResultFilteredSingleFilter, filteredZones) suite.Equal(partitionResultResidualSingleFilter, residualZones) } @@ -1259,6 +1223,88 @@ func (suite *NewPDNSProviderTestSuite) TestPDNSAdjustEndpoints() { } } +func (suite *NewPDNSProviderTestSuite) TestPDNSGetDomainFilter() { + allZones := []pgo.Zone{ZoneEmpty, ZoneEmptyLong, ZoneEmpty2} // example.com., long.domainname.example.com., mock.test. + + tests := []struct { + name string + client PDNSAPIProvider + domainFilter *endpoint.DomainFilter + // domains we expect the returned filter to match + shouldMatch []string + // domains we expect the returned filter NOT to match + shouldNotMatch []string + }{ + { + name: "no domain filter — all zones from API are in scope", + client: &PDNSAPIClientStubConfigurable{ + zones: allZones, + }, + domainFilter: nil, + shouldMatch: []string{"example.com", "long.domainname.example.com", "mock.test", "sub.example.com", "sub.mock.test"}, + shouldNotMatch: []string{"other.com"}, + }, + { + name: "domain filter set — all API zones still returned (controller handles intersection with --domain-filter)", + client: &PDNSAPIClientStubConfigurable{ + zones: allZones, + }, + domainFilter: endpoint.NewDomainFilter([]string{"example.com"}), + // GetDomainFilter returns all API zones, not the filtered subset; + // the controller intersects with --domain-filter on its own + shouldMatch: []string{"example.com", "long.domainname.example.com", "mock.test", "sub.example.com", "sub.mock.test"}, + shouldNotMatch: []string{"other.com"}, + }, + { + name: "domain filter excludes all API zones — all zones still returned (no silent fail-open)", + client: &PDNSAPIClientStubConfigurable{ + zones: allZones, + }, + domainFilter: endpoint.NewDomainFilter([]string{"notexist.org"}), + // All provider-managed zones are returned; when the controller + // intersects with --domain-filter=notexist.org, nothing matches + // and the plan is safely empty + shouldMatch: []string{"example.com", "mock.test", "long.domainname.example.com"}, + shouldNotMatch: []string{"notexist.org", "other.com"}, + }, + { + name: "ListZones error — returns empty filter (fail-open)", + client: &PDNSAPIClientStubConfigurable{ + listErr: provider.NewSoftErrorf("API unreachable"), + }, + domainFilter: nil, + // empty DomainFilter matches everything + shouldMatch: []string{"anything.com", "example.com"}, + shouldNotMatch: []string{}, + }, + { + name: "API returns single zone — that zone is returned regardless of domain filter", + client: &PDNSAPIClientStubConfigurable{ + zones: []pgo.Zone{ZoneEmpty}, // only example.com. + }, + domainFilter: endpoint.NewDomainFilter([]string{"example.com"}), + shouldMatch: []string{"example.com", "sub.example.com"}, + shouldNotMatch: []string{"mock.test", "other.com"}, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + p := &PDNSProvider{ + client: tt.client, + domainFilter: tt.domainFilter, + } + df := p.GetDomainFilter() + for _, domain := range tt.shouldMatch { + suite.True(df.Match(domain), "expected filter to match %q", domain) + } + for _, domain := range tt.shouldNotMatch { + suite.False(df.Match(domain), "expected filter NOT to match %q", domain) + } + }) + } +} + func TestNewPDNSProviderTestSuite(t *testing.T) { suite.Run(t, new(NewPDNSProviderTestSuite)) }