Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
59 changes: 59 additions & 0 deletions provider/google/google.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package google

import (
"context"
"errors"
"fmt"
"sort"
"time"
Expand Down Expand Up @@ -45,12 +46,17 @@ type managedZonesCreateCallInterface interface {
Do(opts ...googleapi.CallOption) (*dns.ManagedZone, error)
}

type managedZonesGetCallInterface interface {
Do(opts ...googleapi.CallOption) (*dns.ManagedZone, error)
}

type managedZonesListCallInterface interface {
Pages(ctx context.Context, f func(*dns.ManagedZonesListResponse) error) error
}

type managedZonesServiceInterface interface {
Create(project string, managedzone *dns.ManagedZone) managedZonesCreateCallInterface
Get(project string, managedZone string) managedZonesGetCallInterface
List(project string) managedZonesListCallInterface
}

Expand Down Expand Up @@ -86,6 +92,10 @@ func (m managedZonesService) Create(project string, managedzone *dns.ManagedZone
return m.service.Create(project, managedzone)
}

func (m managedZonesService) Get(project string, managedZone string) managedZonesGetCallInterface {
return m.service.Get(project, managedZone)
}

func (m managedZonesService) List(project string) managedZonesListCallInterface {
return m.service.List(project)
}
Expand Down Expand Up @@ -174,6 +184,55 @@ func newProvider(ctx context.Context, project string, domainFilter *endpoint.Dom
func (p *GoogleProvider) Zones(ctx context.Context) (map[string]*dns.ManagedZone, error) {
zones := make(map[string]*dns.ManagedZone)

// When zone ID filters are configured, attempt Get for each ID to avoid requiring
// dns.managedZones.list — a project-level permission that exposes all zone names in
// the project, enabling cross-environment enumeration in multi-tenant deployments.
//
// If Get returns 404 for a zone ID, it may be a suffix pattern (e.g. "my-zone" matching
// both "my-zone-public" and "my-zone-private" in split-horizon setups). In that case we
// fall back to List so the existing suffix-match + visibility-filter behavior is preserved.
if p.zoneIDFilter.IsConfigured() {
log.Debugf("Zone ID filters configured %v, attempting Get instead of List", p.zoneIDFilter.ZoneIDs)

needsList := false
for _, zoneID := range p.zoneIDFilter.ZoneIDs {
if zoneID == "" {
continue
}

zone, err := p.managedZonesClient.Get(p.project, zoneID).Do()
if err != nil {
var apiErr *googleapi.Error
if errors.As(err, &apiErr) && apiErr.Code == 404 {
log.Debugf("Zone %s not found via Get (may be a suffix pattern), falling back to List", zoneID)
needsList = true
break
}
return nil, provider.NewSoftErrorf("failed to get zone %s: %w", zoneID, err)
}

if zone.PeeringConfig == nil && p.domainFilter.Match(zone.DnsName) && p.zoneTypeFilter.Match(zone.Visibility) {
zones[zone.Name] = zone
log.Debugf("Matched %s (zone: %s) (visibility: %s)", zone.DnsName, zone.Name, zone.Visibility)
} else {
log.Debugf("Filtered %s (zone: %s) (visibility: %s)", zone.DnsName, zone.Name, zone.Visibility)
}
}

if !needsList {
if len(zones) == 0 {
log.Warnf("No zones in project %s matched zone ID filters %v and domain filters %v", p.project, p.zoneIDFilter.ZoneIDs, p.domainFilter)
}
for _, zone := range zones {
log.Debugf("Considering zone: %s (domain: %s)", zone.Name, zone.DnsName)
}
return zones, nil
}

// Reset and fall through to List
zones = make(map[string]*dns.ManagedZone)
}

f := func(resp *dns.ManagedZonesListResponse) error {
for _, zone := range resp.ManagedZones {
if zone.PeeringConfig == nil {
Expand Down
66 changes: 66 additions & 0 deletions provider/google/google_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,29 @@ func (m *mockManagedZonesListCall) Pages(_ context.Context, f func(*dns.ManagedZ
return f(&dns.ManagedZonesListResponse{ManagedZones: zones})
}

type mockManagedZonesGetCall struct {
project string
managedZone string
zonesErr error
}

func (m *mockManagedZonesGetCall) Do(_ ...googleapi.CallOption) (*dns.ManagedZone, error) {
if m.zonesErr != nil {
return nil, m.zonesErr
}
// Try lookup by zone name first (GCP API accepts both name and numeric ID)
if zone, ok := testZones[m.project+"/"+m.managedZone]; ok {
return zone, nil
}
// Fall back to lookup by numeric ID string (GCP API also accepts numeric IDs)
for key, zone := range testZones {
if strings.HasPrefix(key, m.project+"/") && fmt.Sprintf("%v", zone.Id) == m.managedZone {
return zone, nil
}
}
return nil, &googleapi.Error{Code: http.StatusNotFound}
}

type mockManagedZonesClient struct {
zonesErr error
}
Expand All @@ -89,6 +112,10 @@ func (m *mockManagedZonesClient) Create(project string, managedZone *dns.Managed
return &mockManagedZonesCreateCall{project: project, managedZone: managedZone}
}

func (m *mockManagedZonesClient) Get(project string, managedZone string) managedZonesGetCallInterface {
return &mockManagedZonesGetCall{project: project, managedZone: managedZone, zonesErr: m.zonesErr}
}

func (m *mockManagedZonesClient) List(project string) managedZonesListCallInterface {
return &mockManagedZonesListCall{project: project, zonesListSoftErr: m.zonesErr}
}
Expand Down Expand Up @@ -251,6 +278,45 @@ func TestGoogleZonesVisibilityFilterPrivate(t *testing.T) {
})
}

// TestGoogleZonesMultipleIDFilterGet verifies that multiple exact zone IDs are each
// resolved via Get, avoiding the need for dns.managedZones.list.
func TestGoogleZonesMultipleIDFilterGet(t *testing.T) {
provider := newGoogleProviderZoneOverlap(t, endpoint.NewDomainFilter([]string{"cluster.local."}), provider.NewZoneIDFilter([]string{"internal-2", "internal-3"}), provider.NewZoneTypeFilter(""), []*endpoint.Endpoint{})

zones, err := provider.Zones(t.Context())
require.NoError(t, err)

validateZones(t, zones, map[string]*dns.ManagedZone{
"internal-2": {Name: "internal-2", DnsName: "cluster.local.", Id: 10002, Visibility: "private"},
"internal-3": {Name: "internal-3", DnsName: "cluster.local.", Id: 10003, Visibility: "private"},
})
}

// TestGoogleZonesIDFilterGetFallbackToList verifies that a zone ID suffix pattern that
// does not resolve via Get (404) falls back to List for backward compatibility.
// "ernal-1" is a suffix of "internal-1" but not an exact zone name, so Get 404s
// and we fall back to List which applies the suffix filter as before.
func TestGoogleZonesIDFilterGetFallbackToList(t *testing.T) {
provider := newGoogleProviderZoneOverlap(t, endpoint.NewDomainFilter([]string{"cluster.local."}), provider.NewZoneIDFilter([]string{"ernal-1"}), provider.NewZoneTypeFilter(""), []*endpoint.Endpoint{})

zones, err := provider.Zones(t.Context())
require.NoError(t, err)

validateZones(t, zones, map[string]*dns.ManagedZone{
"internal-1": {Name: "internal-1", DnsName: "cluster.local.", Id: 10001, Visibility: "private"},
})
}

// TestGoogleZonesGetError verifies that a non-404 error from Get is surfaced as an error.
func TestGoogleZonesGetError(t *testing.T) {
p := newGoogleProvider(t, endpoint.NewDomainFilter([]string{"cluster.local."}), provider.NewZoneIDFilter([]string{"some-zone"}), false, []*endpoint.Endpoint{}, provider.NewSoftErrorf("failed to get zone"), nil)

zones, err := p.Zones(t.Context())
require.Error(t, err)
require.ErrorIs(t, err, provider.SoftError)
require.Empty(t, zones)
}

func TestGoogleZonesVisibilityFilterPrivatePeering(t *testing.T) {
provider := newGoogleProviderZoneOverlap(t, endpoint.NewDomainFilter([]string{"svc.local."}), provider.NewZoneIDFilter([]string{""}), provider.NewZoneTypeFilter("private"), []*endpoint.Endpoint{})

Expand Down