Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 48 additions & 19 deletions provider/pdns/pdns.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,18 +137,17 @@ 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)
PartitionZones(zones []pgo.Zone, domainFilter *endpoint.DomainFilter) ([]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
Expand All @@ -172,13 +171,13 @@ func (c *PDNSAPIClient) ListZones() ([]pgo.Zone, *http.Response, error) {
}

// 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) {
func (c *PDNSAPIClient) PartitionZones(zones []pgo.Zone, domainFilter *endpoint.DomainFilter) ([]pgo.Zone, []pgo.Zone) {
var filteredZones []pgo.Zone
var residualZones []pgo.Zone

if c.domainFilter.IsConfigured() {
if domainFilter.IsConfigured() {
for _, zone := range zones {
if c.domainFilter.Match(zone.Name) {
if domainFilter.Match(zone.Name) {
filteredZones = append(filteredZones, zone)
} else {
residualZones = append(residualZones, zone)
Expand Down Expand Up @@ -229,7 +228,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.
Expand Down Expand Up @@ -258,16 +258,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 := p.client.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{}
}

var zoneNames []string
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")
Expand Down Expand Up @@ -308,11 +339,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
Expand Down Expand Up @@ -437,11 +467,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

Expand Down
184 changes: 133 additions & 51 deletions provider/pdns/pdns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -666,56 +666,16 @@ 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,
DomainFilterClient = &PDNSAPIClient{
dryRun: false,
authCtx: context.WithValue(context.Background(), pgo.ContextAPIKey, pgo.APIKey{Key: "TEST-API-KEY"}),
client: pgo.NewAPIClient(pgo.NewConfiguration()),
}
)

Expand All @@ -727,7 +687,7 @@ 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) {
func (c *PDNSAPIClientStub) PartitionZones(zones []pgo.Zone, _ *endpoint.DomainFilter) ([]pgo.Zone, []pgo.Zone) {
return zones, nil
}

Expand All @@ -750,7 +710,7 @@ 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) {
func (c *PDNSAPIClientStubEmptyZones) PartitionZones(zones []pgo.Zone, _ *endpoint.DomainFilter) ([]pgo.Zone, []pgo.Zone) {
return zones, nil
}

Expand Down Expand Up @@ -833,10 +793,50 @@ func (c *PDNSAPIClientStubPartitionZones) ListZone(zoneID string) (pgo.Zone, *ht
}

// Just overwrite the ListZones method to introduce a failure
func (c *PDNSAPIClientStubPartitionZones) PartitionZones(_ []pgo.Zone) ([]pgo.Zone, []pgo.Zone) {
func (c *PDNSAPIClientStubPartitionZones) PartitionZones(_ []pgo.Zone, _ *endpoint.DomainFilter) ([]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) PartitionZones(zones []pgo.Zone, domainFilter *endpoint.DomainFilter) ([]pgo.Zone, []pgo.Zone) {
var filtered, residual []pgo.Zone
if domainFilter.IsConfigured() {
for _, zone := range zones {
if domainFilter.Match(zone.Name) {
filtered = append(filtered, zone)
} else {
residual = append(residual, zone)
}
}
} else {
filtered = zones
}
return filtered, residual
}

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
}

/******************************************************************************/

type NewPDNSProviderTestSuite struct {
Expand Down Expand Up @@ -1195,21 +1195,21 @@ func (suite *NewPDNSProviderTestSuite) TestPDNSClientPartitionZones() {
}

// Check filtered, residual zones when no domain filter specified
filteredZones, residualZones := DomainFilterEmptyClient.PartitionZones(zoneList)
filteredZones, residualZones := DomainFilterClient.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 = DomainFilterClient.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 = DomainFilterClient.PartitionZones(zoneList, DomainFilterListMultiple)
suite.Equal(partitionResultFilteredMultipleFilter, filteredZones)
suite.Equal(partitionResultResidualMultipleFilter, residualZones)

filteredZones, residualZones = RegexDomainFilterClient.PartitionZones(zoneList)
filteredZones, residualZones = DomainFilterClient.PartitionZones(zoneList, RegexDomainFilter)
suite.Equal(partitionResultFilteredSingleFilter, filteredZones)
suite.Equal(partitionResultResidualSingleFilter, residualZones)
}
Expand Down Expand Up @@ -1259,6 +1259,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))
}
Loading