diff --git a/provider/google/google.go b/provider/google/google.go index 2463257880..a9fdf931be 100644 --- a/provider/google/google.go +++ b/provider/google/google.go @@ -18,6 +18,7 @@ package google import ( "context" + "errors" "fmt" "sort" "time" @@ -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 } @@ -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) } @@ -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 { diff --git a/provider/google/google_test.go b/provider/google/google_test.go index 032ee02468..6d200af880 100644 --- a/provider/google/google_test.go +++ b/provider/google/google_test.go @@ -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 } @@ -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} } @@ -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{})