From 54552baec8d3b367c2ce5e40d022dab07a99dcd2 Mon Sep 17 00:00:00 2001 From: Wenying Dong Date: Thu, 14 May 2026 19:39:19 +0800 Subject: [PATCH 1/3] feat(dns): implement NSX Project DNS record service - Implement DNSRecordService for NSX ProjectDnsRecord CRUD operations - Validate hostnames against VPCNetworkConfiguration allowed DNS zones - Wrap hostname-mismatch error as DNSZoneValidationError for accurate DNSRecordReady condition reporting --- go.mod | 2 +- pkg/clean/clean.go | 11 + pkg/clean/clean_test.go | 9 +- pkg/mock/dnsrecordprovider/client.go | 127 ++ pkg/mock/dnsrecordprovider/doc.go | 3 + pkg/mock/dnsrecordsclient/client.go | 108 ++ pkg/mock/dnsrecordsclient/doc.go | 4 + pkg/mock/dnszonesclient/client.go | 108 ++ pkg/mock/dnszonesclient/doc.go | 4 + pkg/mock/realizedentitiesclient/client.go | 50 + pkg/mock/realizedentitiesclient/doc.go | 4 + pkg/mock/services_mock.go | 7 +- pkg/nsx/client.go | 8 + pkg/nsx/services/common/policy_tree.go | 22 +- pkg/nsx/services/common/types.go | 19 + pkg/nsx/services/common/wrap.go | 23 + pkg/nsx/services/dns/builder.go | 124 ++ pkg/nsx/services/dns/cleanup.go | 33 + pkg/nsx/services/dns/compare.go | 131 ++ .../services/dns/compare_contributing_test.go | 221 +++ pkg/nsx/services/dns/contributing.go | 134 ++ pkg/nsx/services/dns/errors.go | 31 + pkg/nsx/services/dns/errors_test.go | 49 + pkg/nsx/services/dns/initialize.go | 66 + pkg/nsx/services/dns/recordservice.go | 455 ++++++ pkg/nsx/services/dns/recordservice_test.go | 1329 +++++++++++++++++ pkg/nsx/services/dns/store.go | 377 +++++ pkg/nsx/services/dns/store_test.go | 186 +++ pkg/nsx/services/dns/types.go | 80 + pkg/nsx/services/dns/zones.go | 168 +++ pkg/nsx/services/dns/zones_test.go | 145 ++ pkg/third_party/externaldns/doc.go | 29 + pkg/third_party/externaldns/endpoint/doc.go | 28 + .../externaldns/endpoint/endpoint.go | 230 +++ .../externaldns/endpoint/endpoint_test.go | 286 ++++ pkg/third_party/externaldns/endpoint/utils.go | 49 + .../externaldns/endpoint/utils_test.go | 77 + pkg/third_party/externaldns/provider/doc.go | 8 + .../externaldns/provider/zonefinder.go | 76 + .../externaldns/provider/zonefinder_test.go | 124 ++ pkg/util/utils.go | 7 +- 41 files changed, 4942 insertions(+), 10 deletions(-) create mode 100644 pkg/mock/dnsrecordprovider/client.go create mode 100644 pkg/mock/dnsrecordprovider/doc.go create mode 100644 pkg/mock/dnsrecordsclient/client.go create mode 100644 pkg/mock/dnsrecordsclient/doc.go create mode 100644 pkg/mock/dnszonesclient/client.go create mode 100644 pkg/mock/dnszonesclient/doc.go create mode 100644 pkg/mock/realizedentitiesclient/client.go create mode 100644 pkg/mock/realizedentitiesclient/doc.go create mode 100644 pkg/nsx/services/dns/builder.go create mode 100644 pkg/nsx/services/dns/cleanup.go create mode 100644 pkg/nsx/services/dns/compare.go create mode 100644 pkg/nsx/services/dns/compare_contributing_test.go create mode 100644 pkg/nsx/services/dns/contributing.go create mode 100644 pkg/nsx/services/dns/errors.go create mode 100644 pkg/nsx/services/dns/errors_test.go create mode 100644 pkg/nsx/services/dns/initialize.go create mode 100644 pkg/nsx/services/dns/recordservice.go create mode 100644 pkg/nsx/services/dns/recordservice_test.go create mode 100644 pkg/nsx/services/dns/store.go create mode 100644 pkg/nsx/services/dns/store_test.go create mode 100644 pkg/nsx/services/dns/types.go create mode 100644 pkg/nsx/services/dns/zones.go create mode 100644 pkg/nsx/services/dns/zones_test.go create mode 100644 pkg/third_party/externaldns/doc.go create mode 100644 pkg/third_party/externaldns/endpoint/doc.go create mode 100644 pkg/third_party/externaldns/endpoint/endpoint.go create mode 100644 pkg/third_party/externaldns/endpoint/endpoint_test.go create mode 100644 pkg/third_party/externaldns/endpoint/utils.go create mode 100644 pkg/third_party/externaldns/endpoint/utils_test.go create mode 100644 pkg/third_party/externaldns/provider/doc.go create mode 100644 pkg/third_party/externaldns/provider/zonefinder.go create mode 100644 pkg/third_party/externaldns/provider/zonefinder_test.go diff --git a/go.mod b/go.mod index a65d8f110..851d7c638 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( go.uber.org/automaxprocs v1.6.0 go.uber.org/zap v1.27.1 golang.org/x/crypto v0.50.0 + golang.org/x/net v0.53.0 golang.org/x/sync v0.20.0 golang.org/x/time v0.14.0 gopkg.in/ini.v1 v1.67.1 @@ -108,7 +109,6 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.53.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/term v0.42.0 // indirect diff --git a/pkg/clean/clean.go b/pkg/clean/clean.go index 95816f420..45a103ae2 100644 --- a/pkg/clean/clean.go +++ b/pkg/clean/clean.go @@ -12,6 +12,7 @@ import ( "github.com/vmware-tanzu/nsx-operator/pkg/logger" "github.com/vmware-tanzu/nsx-operator/pkg/nsx" "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" + "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/dns" "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/inventory" "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/ipaddressallocation" "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/nsxserviceaccount" @@ -115,6 +116,10 @@ func InitializeCleanupService(cf *config.NSXOperatorConfig, nsxClient *nsx.Clien if err != nil { return nil, err } + dnsRecordService, err := dns.InitializeDNSRecordService(commonService, vpcService) + if err != nil { + return nil, err + } subnetPortService, err := subnetport.InitializeSubnetPort(commonService, vpcService, ipAddressAllocationService) if err != nil { return nil, err @@ -155,6 +160,11 @@ func InitializeCleanupService(cf *config.NSXOperatorConfig, nsxClient *nsx.Clien return ipAddressAllocationService, nil } } + wrapInitializeDNSRecordService := func(service common.Service) cleanupFunc { + return func() (interface{}, error) { + return dnsRecordService, nil + } + } wrapInitializeSubnetBinding := func(service common.Service) cleanupFunc { return func() (interface{}, error) { return subnetbinding.InitializeService(service) @@ -213,6 +223,7 @@ func InitializeCleanupService(cf *config.NSXOperatorConfig, nsxClient *nsx.Clien loggedAdd("StaticRoute", wrapInitializeStaticRoute(commonService)) loggedAdd("VPC", wrapInitializeVPC(commonService)) loggedAdd("IPAddressAllocation", wrapInitializeIPAddressAllocation(commonService)) + loggedAdd("DNSRecord", wrapInitializeDNSRecordService(commonService)) loggedAdd("Inventory", wrapInitializeInventory(commonService)) loggedAdd("LBInfraCleaner", wrapInitializeLBInfraCleaner(commonService)) loggedAdd("HealthCleaner", wrapInitializeHealthCleaner(commonService)) diff --git a/pkg/clean/clean_test.go b/pkg/clean/clean_test.go index 01d875ce3..68b08b2c2 100644 --- a/pkg/clean/clean_test.go +++ b/pkg/clean/clean_test.go @@ -14,6 +14,7 @@ import ( "github.com/vmware-tanzu/nsx-operator/pkg/config" "github.com/vmware-tanzu/nsx-operator/pkg/nsx" "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" + "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/dns" "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/inventory" "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/ipaddressallocation" "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/nsxserviceaccount" @@ -187,6 +188,9 @@ func TestInitializeCleanupService_Success(t *testing.T) { patches.ApplyFunc(ipaddressallocation.InitializeIPAddressAllocation, func(service common.Service, vpcService common.VPCServiceProvider, flag bool) (*ipaddressallocation.IPAddressAllocationService, error) { return &ipaddressallocation.IPAddressAllocationService{}, nil }) + patches.ApplyFunc(dns.InitializeDNSRecordService, func(service common.Service, vpcService common.VPCServiceProvider) (*dns.DNSRecordService, error) { + return &dns.DNSRecordService{}, nil + }) patches.ApplyFunc(subnetbinding.InitializeService, func(service common.Service) (*subnetbinding.BindingService, error) { return &subnetbinding.BindingService{}, nil }) @@ -216,7 +220,7 @@ func TestInitializeCleanupService_Success(t *testing.T) { // vpcPreCleaners: SubnetPort, SubnetBinding, SubnetIPReservation, Inventory, SecurityPolicy, LBInfraCleaner, NSXServiceAccount, HealthCleaner = 8 assert.Len(t, cleanupService.vpcPreCleaners, 7) assert.Len(t, cleanupService.vpcChildrenCleaners, 5) - assert.Len(t, cleanupService.infraCleaners, 2) + assert.Len(t, cleanupService.infraCleaners, 3) } func TestInitializeCleanupService_VPCError(t *testing.T) { @@ -245,6 +249,9 @@ func TestInitializeCleanupService_VPCError(t *testing.T) { patches.ApplyFunc(ipaddressallocation.InitializeIPAddressAllocation, func(service common.Service, vpcService common.VPCServiceProvider, flag bool) (*ipaddressallocation.IPAddressAllocationService, error) { return &ipaddressallocation.IPAddressAllocationService{}, nil }) + patches.ApplyFunc(dns.InitializeDNSRecordService, func(service common.Service, vpcService common.VPCServiceProvider) (*dns.DNSRecordService, error) { + return &dns.DNSRecordService{}, nil + }) patches.ApplyFunc(subnetbinding.InitializeService, func(service common.Service) (*subnetbinding.BindingService, error) { return &subnetbinding.BindingService{}, nil }) diff --git a/pkg/mock/dnsrecordprovider/client.go b/pkg/mock/dnsrecordprovider/client.go new file mode 100644 index 000000000..4f2d0c985 --- /dev/null +++ b/pkg/mock/dnsrecordprovider/client.go @@ -0,0 +1,127 @@ +// Code generated by hand to mirror MockGen output (mockgen v1.6 cannot parse generic sets.Set in interfaces). + +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + + dns "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/dns" + extdns "github.com/vmware-tanzu/nsx-operator/pkg/third_party/externaldns/endpoint" +) + +// MockDNSRecordProvider is a mock of dns.DNSRecordProvider. +type MockDNSRecordProvider struct { + ctrl *gomock.Controller + recorder *MockDNSRecordProviderMockRecorder +} + +// MockDNSRecordProviderMockRecorder is the mock recorder for MockDNSRecordProvider. +type MockDNSRecordProviderMockRecorder struct { + mock *MockDNSRecordProvider +} + +// NewMockDNSRecordProvider creates a new mock instance. +func NewMockDNSRecordProvider(ctrl *gomock.Controller) *MockDNSRecordProvider { + mock := &MockDNSRecordProvider{ctrl: ctrl} + mock.recorder = &MockDNSRecordProviderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDNSRecordProvider) EXPECT() *MockDNSRecordProviderMockRecorder { + return m.recorder +} + +// CreateOrUpdateRecords mocks base method. +func (m *MockDNSRecordProvider) CreateOrUpdateRecords(ctx context.Context, batch *dns.AggregatedDNSEndpoints) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateOrUpdateRecords", ctx, batch) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateOrUpdateRecords indicates an expected call of CreateOrUpdateRecords. +func (mr *MockDNSRecordProviderMockRecorder) CreateOrUpdateRecords(ctx, batch interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateOrUpdateRecords", reflect.TypeOf((*MockDNSRecordProvider)(nil).CreateOrUpdateRecords), ctx, batch) +} + +// DeleteRecordByOwnerNN mocks base method. +func (m *MockDNSRecordProvider) DeleteRecordByOwnerNN(ctx context.Context, kind, namespace, name string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteRecordByOwnerNN", ctx, kind, namespace, name) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteRecordByOwnerNN indicates an expected call of DeleteRecordByOwnerNN. +func (mr *MockDNSRecordProviderMockRecorder) DeleteRecordByOwnerNN(ctx, kind, namespace, name interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRecordByOwnerNN", reflect.TypeOf((*MockDNSRecordProvider)(nil).DeleteRecordByOwnerNN), ctx, kind, namespace, name) +} + +// ValidateEndpointsByZone mocks base method. +func (m *MockDNSRecordProvider) ValidateEndpointsByZone(namespace string, owner *dns.ResourceRef, eps []*extdns.Endpoint) ([]dns.EndpointRow, map[string]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ValidateEndpointsByZone", namespace, owner, eps) + ret0, _ := ret[0].([]dns.EndpointRow) + ret1, _ := ret[1].(map[string]string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ValidateEndpointsByZone indicates an expected call of ValidateEndpointsByZone. +func (mr *MockDNSRecordProviderMockRecorder) ValidateEndpointsByZone(namespace, owner, eps interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateEndpointsByZone", reflect.TypeOf((*MockDNSRecordProvider)(nil).ValidateEndpointsByZone), namespace, owner, eps) +} + +// DeleteRecordsForOwnerOutsideAllowedZones mocks base method. +func (m *MockDNSRecordProvider) DeleteRecordsForOwnerOutsideAllowedZones(ctx context.Context, kind, namespace, name string, allowedZonePaths sets.Set[string]) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteRecordsForOwnerOutsideAllowedZones", ctx, kind, namespace, name, allowedZonePaths) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteRecordsForOwnerOutsideAllowedZones indicates an expected call of DeleteRecordsForOwnerOutsideAllowedZones. +func (mr *MockDNSRecordProviderMockRecorder) DeleteRecordsForOwnerOutsideAllowedZones(ctx, kind, namespace, name, allowedZonePaths interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRecordsForOwnerOutsideAllowedZones", reflect.TypeOf((*MockDNSRecordProvider)(nil).DeleteRecordsForOwnerOutsideAllowedZones), ctx, kind, namespace, name, allowedZonePaths) +} + +// ListReferredGatewayNN mocks base method. +func (m *MockDNSRecordProvider) ListReferredGatewayNN() sets.Set[types.NamespacedName] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListReferredGatewayNN") + ret0, _ := ret[0].(sets.Set[types.NamespacedName]) + return ret0 +} + +// ListReferredGatewayNN indicates an expected call of ListReferredGatewayNN. +func (mr *MockDNSRecordProviderMockRecorder) ListReferredGatewayNN() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListReferredGatewayNN", reflect.TypeOf((*MockDNSRecordProvider)(nil).ListReferredGatewayNN)) +} + +// ListRecordOwnerResource mocks base method. +func (m *MockDNSRecordProvider) ListRecordOwnerResource() map[string]sets.Set[types.NamespacedName] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListRecordOwnerResource") + ret0, _ := ret[0].(map[string]sets.Set[types.NamespacedName]) + return ret0 +} + +// ListRecordOwnerResource indicates an expected call of ListRecordOwnerResource. +func (mr *MockDNSRecordProviderMockRecorder) ListRecordOwnerResource() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRecordOwnerResource", reflect.TypeOf((*MockDNSRecordProvider)(nil).ListRecordOwnerResource)) +} diff --git a/pkg/mock/dnsrecordprovider/doc.go b/pkg/mock/dnsrecordprovider/doc.go new file mode 100644 index 000000000..41c7b0367 --- /dev/null +++ b/pkg/mock/dnsrecordprovider/doc.go @@ -0,0 +1,3 @@ +// Package mocks provides MockDNSRecordProvider for dns.DNSRecordProvider. +// client.go is hand-written: github.com/golang/mock/mockgen@v1.6 cannot parse generic sets.Set in interface methods. +package mocks diff --git a/pkg/mock/dnsrecordsclient/client.go b/pkg/mock/dnsrecordsclient/client.go new file mode 100644 index 000000000..cadb2248c --- /dev/null +++ b/pkg/mock/dnsrecordsclient/client.go @@ -0,0 +1,108 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/vmware/vsphere-automation-sdk-go/services/nsxt/orgs/projects (interfaces: DnsRecordsClient) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "go.uber.org/mock/gomock" + model "github.com/vmware/vsphere-automation-sdk-go/services/nsxt/model" +) + +// MockDnsRecordsClient is a mock of DnsRecordsClient interface. +type MockDnsRecordsClient struct { + ctrl *gomock.Controller + recorder *MockDnsRecordsClientMockRecorder +} + +// MockDnsRecordsClientMockRecorder is the mock recorder for MockDnsRecordsClient. +type MockDnsRecordsClientMockRecorder struct { + mock *MockDnsRecordsClient +} + +// NewMockDnsRecordsClient creates a new mock instance. +func NewMockDnsRecordsClient(ctrl *gomock.Controller) *MockDnsRecordsClient { + mock := &MockDnsRecordsClient{ctrl: ctrl} + mock.recorder = &MockDnsRecordsClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDnsRecordsClient) EXPECT() *MockDnsRecordsClientMockRecorder { + return m.recorder +} + +// Delete mocks base method. +func (m *MockDnsRecordsClient) Delete(arg0, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockDnsRecordsClientMockRecorder) Delete(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockDnsRecordsClient)(nil).Delete), arg0, arg1, arg2) +} + +// Get mocks base method. +func (m *MockDnsRecordsClient) Get(arg0, arg1, arg2 string) (model.ProjectDnsRecord, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0, arg1, arg2) + ret0, _ := ret[0].(model.ProjectDnsRecord) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockDnsRecordsClientMockRecorder) Get(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDnsRecordsClient)(nil).Get), arg0, arg1, arg2) +} + +// List mocks base method. +func (m *MockDnsRecordsClient) List(arg0, arg1 string, arg2 *string, arg3 *bool, arg4 *string, arg5 *int64, arg6 *bool, arg7, arg8 *string) (model.ProjectDnsRecordListResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8) + ret0, _ := ret[0].(model.ProjectDnsRecordListResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockDnsRecordsClientMockRecorder) List(arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockDnsRecordsClient)(nil).List), arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8) +} + +// Patch mocks base method. +func (m *MockDnsRecordsClient) Patch(arg0, arg1, arg2 string, arg3 model.ProjectDnsRecord) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Patch", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// Patch indicates an expected call of Patch. +func (mr *MockDnsRecordsClientMockRecorder) Patch(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Patch", reflect.TypeOf((*MockDnsRecordsClient)(nil).Patch), arg0, arg1, arg2, arg3) +} + +// Update mocks base method. +func (m *MockDnsRecordsClient) Update(arg0, arg1, arg2 string, arg3 model.ProjectDnsRecord) (model.ProjectDnsRecord, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(model.ProjectDnsRecord) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockDnsRecordsClientMockRecorder) Update(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockDnsRecordsClient)(nil).Update), arg0, arg1, arg2, arg3) +} diff --git a/pkg/mock/dnsrecordsclient/doc.go b/pkg/mock/dnsrecordsclient/doc.go new file mode 100644 index 000000000..695d8846e --- /dev/null +++ b/pkg/mock/dnsrecordsclient/doc.go @@ -0,0 +1,4 @@ +// Package mocks contains a generated mock for projects.DnsRecordsClient. +// +//go:generate go run github.com/golang/mock/mockgen@v1.6.0 -destination=client.go -package=mocks github.com/vmware/vsphere-automation-sdk-go/services/nsxt/orgs/projects DnsRecordsClient +package mocks diff --git a/pkg/mock/dnszonesclient/client.go b/pkg/mock/dnszonesclient/client.go new file mode 100644 index 000000000..d77943c95 --- /dev/null +++ b/pkg/mock/dnszonesclient/client.go @@ -0,0 +1,108 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/vmware/vsphere-automation-sdk-go/services/nsxt/orgs/projects/dns_services (interfaces: ZonesClient) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "go.uber.org/mock/gomock" + model "github.com/vmware/vsphere-automation-sdk-go/services/nsxt/model" +) + +// MockZonesClient is a mock of ZonesClient interface. +type MockZonesClient struct { + ctrl *gomock.Controller + recorder *MockZonesClientMockRecorder +} + +// MockZonesClientMockRecorder is the mock recorder for MockZonesClient. +type MockZonesClientMockRecorder struct { + mock *MockZonesClient +} + +// NewMockZonesClient creates a new mock instance. +func NewMockZonesClient(ctrl *gomock.Controller) *MockZonesClient { + mock := &MockZonesClient{ctrl: ctrl} + mock.recorder = &MockZonesClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockZonesClient) EXPECT() *MockZonesClientMockRecorder { + return m.recorder +} + +// Delete mocks base method. +func (m *MockZonesClient) Delete(arg0, arg1, arg2, arg3 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockZonesClientMockRecorder) Delete(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockZonesClient)(nil).Delete), arg0, arg1, arg2, arg3) +} + +// Get mocks base method. +func (m *MockZonesClient) Get(arg0, arg1, arg2, arg3 string) (model.ProjectDnsZone, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(model.ProjectDnsZone) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockZonesClientMockRecorder) Get(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockZonesClient)(nil).Get), arg0, arg1, arg2, arg3) +} + +// List mocks base method. +func (m *MockZonesClient) List(arg0, arg1, arg2 string, arg3 *string, arg4 *bool, arg5 *string, arg6 *int64, arg7 *bool, arg8 *string) (model.ProjectDnsZoneListResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8) + ret0, _ := ret[0].(model.ProjectDnsZoneListResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockZonesClientMockRecorder) List(arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockZonesClient)(nil).List), arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8) +} + +// Patch mocks base method. +func (m *MockZonesClient) Patch(arg0, arg1, arg2, arg3 string, arg4 model.ProjectDnsZone) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Patch", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(error) + return ret0 +} + +// Patch indicates an expected call of Patch. +func (mr *MockZonesClientMockRecorder) Patch(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Patch", reflect.TypeOf((*MockZonesClient)(nil).Patch), arg0, arg1, arg2, arg3, arg4) +} + +// Update mocks base method. +func (m *MockZonesClient) Update(arg0, arg1, arg2, arg3 string, arg4 model.ProjectDnsZone) (model.ProjectDnsZone, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(model.ProjectDnsZone) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockZonesClientMockRecorder) Update(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockZonesClient)(nil).Update), arg0, arg1, arg2, arg3, arg4) +} diff --git a/pkg/mock/dnszonesclient/doc.go b/pkg/mock/dnszonesclient/doc.go new file mode 100644 index 000000000..b8ef1d79c --- /dev/null +++ b/pkg/mock/dnszonesclient/doc.go @@ -0,0 +1,4 @@ +// Package mocks contains a generated mock for dns_services.ZonesClient (Project DNS zones API). +// +//go:generate go run github.com/golang/mock/mockgen@v1.6.0 -destination=client.go -package=mocks github.com/vmware/vsphere-automation-sdk-go/services/nsxt/orgs/projects/dns_services ZonesClient +package mocks diff --git a/pkg/mock/realizedentitiesclient/client.go b/pkg/mock/realizedentitiesclient/client.go new file mode 100644 index 000000000..b521e403e --- /dev/null +++ b/pkg/mock/realizedentitiesclient/client.go @@ -0,0 +1,50 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/vmware/vsphere-automation-sdk-go/services/nsxt/infra/realized_state (interfaces: RealizedEntitiesClient) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "go.uber.org/mock/gomock" + model "github.com/vmware/vsphere-automation-sdk-go/services/nsxt/model" +) + +// MockRealizedEntitiesClient is a mock of RealizedEntitiesClient interface. +type MockRealizedEntitiesClient struct { + ctrl *gomock.Controller + recorder *MockRealizedEntitiesClientMockRecorder +} + +// MockRealizedEntitiesClientMockRecorder is the mock recorder for MockRealizedEntitiesClient. +type MockRealizedEntitiesClientMockRecorder struct { + mock *MockRealizedEntitiesClient +} + +// NewMockRealizedEntitiesClient creates a new mock instance. +func NewMockRealizedEntitiesClient(ctrl *gomock.Controller) *MockRealizedEntitiesClient { + mock := &MockRealizedEntitiesClient{ctrl: ctrl} + mock.recorder = &MockRealizedEntitiesClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRealizedEntitiesClient) EXPECT() *MockRealizedEntitiesClientMockRecorder { + return m.recorder +} + +// List mocks base method. +func (m *MockRealizedEntitiesClient) List(arg0 string, arg1 *string) (model.GenericPolicyRealizedResourceListResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", arg0, arg1) + ret0, _ := ret[0].(model.GenericPolicyRealizedResourceListResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockRealizedEntitiesClientMockRecorder) List(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockRealizedEntitiesClient)(nil).List), arg0, arg1) +} diff --git a/pkg/mock/realizedentitiesclient/doc.go b/pkg/mock/realizedentitiesclient/doc.go new file mode 100644 index 000000000..95b84b779 --- /dev/null +++ b/pkg/mock/realizedentitiesclient/doc.go @@ -0,0 +1,4 @@ +// Package mocks contains a generated mock for realized_state.RealizedEntitiesClient. +// +//go:generate go run github.com/golang/mock/mockgen@v1.6.0 -destination=client.go -package=mocks github.com/vmware/vsphere-automation-sdk-go/services/nsxt/infra/realized_state RealizedEntitiesClient +package mocks diff --git a/pkg/mock/services_mock.go b/pkg/mock/services_mock.go index d688dff54..3fd979209 100644 --- a/pkg/mock/services_mock.go +++ b/pkg/mock/services_mock.go @@ -38,8 +38,11 @@ func (m *MockVPCServiceProvider) ValidateNetworkConfig(nc *v1alpha1.VPCNetworkCo } func (m *MockVPCServiceProvider) GetVPCNetworkConfigByNamespace(ns string) (*v1alpha1.VPCNetworkConfiguration, error) { - m.Called() - return nil, nil + args := m.Called(ns) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*v1alpha1.VPCNetworkConfiguration), args.Error(1) } func (m *MockVPCServiceProvider) GetDefaultNetworkConfig() (*v1alpha1.VPCNetworkConfiguration, error) { diff --git a/pkg/nsx/client.go b/pkg/nsx/client.go index 17d4aecf5..46ebd9c03 100644 --- a/pkg/nsx/client.go +++ b/pkg/nsx/client.go @@ -8,6 +8,8 @@ import ( "net/http" "strings" + "github.com/vmware/vsphere-automation-sdk-go/services/nsxt/orgs/projects/dns_services" + "github.com/sirupsen/logrus" nsxt "github.com/vmware/go-vmware-nsxt" vspherelog "github.com/vmware/vsphere-automation-sdk-go/runtime/log" @@ -120,6 +122,8 @@ type Client struct { StaticIPReservationsClient subnets.StaticIpReservationsClient NsxApiClient *nsxt.APIClient VifsClient fabric.VifsClient + ProjectDnsZoneClient dns_services.ZonesClient + DnsRecordsClient projects.DnsRecordsClient NSXChecker NSXHealthChecker NSXVerChecker NSXVersionChecker @@ -238,6 +242,8 @@ func GetClient(cf *config.NSXOperatorConfig) *Client { nsxApiClient, _ := CreateNsxtApiClient(cf, cluster.client) vifsClient := fabric.NewVifsClient(connector) + projectDnsZoneClient := dns_services.NewZonesClient(connector) + dnsRecordsClient := projects.NewDnsRecordsClient(connector) nsxChecker := &NSXHealthChecker{ cluster: cluster, @@ -305,6 +311,8 @@ func GetClient(cf *config.NSXOperatorConfig) *Client { LbMonitorProfilesClient: lbMonitorProfilesClient, NsxApiClient: nsxApiClient, VifsClient: vifsClient, + ProjectDnsZoneClient: projectDnsZoneClient, + DnsRecordsClient: dnsRecordsClient, } nsxClient.Cluster.SetOnNodeVersionChanged(func(oldVer, newVer string) { nsxClient.resetNSXVersionFeatureCache() diff --git a/pkg/nsx/services/common/policy_tree.go b/pkg/nsx/services/common/policy_tree.go index 6211c8d25..cd075489e 100644 --- a/pkg/nsx/services/common/policy_tree.go +++ b/pkg/nsx/services/common/policy_tree.go @@ -24,6 +24,8 @@ type GetId[T any] func(obj T) *string func getNSXResourcePath[T any](obj T) *string { switch v := any(obj).(type) { + case *model.ProjectDnsRecord: + return v.Path case *model.VpcIpAddressAllocation: return v.Path case *model.VpcSubnet: @@ -68,6 +70,8 @@ func getNSXResourcePath[T any](obj T) *string { func getNSXResourceId[T any](obj T) *string { switch v := any(obj).(type) { + case *model.ProjectDnsRecord: + return v.Id case *model.VpcIpAddressAllocation: return v.Id case *model.VpcSubnet: @@ -112,6 +116,8 @@ func getNSXResourceId[T any](obj T) *string { func getNSXResourceName[T any](obj T) *string { switch v := any(obj).(type) { + case *model.ProjectDnsRecord: + return v.DisplayName case *model.VpcIpAddressAllocation: return v.DisplayName case *model.VpcSubnet: @@ -156,6 +162,8 @@ func getNSXResourceName[T any](obj T) *string { func leafWrapper[T any](obj T) (*data.StructValue, error) { switch v := any(obj).(type) { + case *model.ProjectDnsRecord: + return WrapProjectDnsRecord(v) case *model.VpcIpAddressAllocation: return WrapVpcIpAddressAllocation(v) case *model.VpcSubnet: @@ -270,6 +278,7 @@ var ( PolicyResourceInfraLBPool = PolicyResourceType{ModelKey: ResourceTypeLBPool, PathKey: "lb-pools"} PolicyResourceInfraLBVirtualServer = PolicyResourceType{ModelKey: ResourceTypeLBVirtualServer, PathKey: "lb-virtual-servers"} PolicyResourceVpcIPAddressAllocation = PolicyResourceType{ModelKey: ResourceTypeIPAddressAllocation, PathKey: "ip-address-allocations"} + PolicyResourceProjectDnsRecord = PolicyResourceType{ModelKey: ResourceTypeProjectDnsRecord, PathKey: PathSegmentProjectDnsRecords} PolicyResourceDomain = PolicyResourceType{ModelKey: ResourceTypeDomain, PathKey: "domains"} PolicyResourceShare = PolicyResourceType{ModelKey: ResourceTypeShare, PathKey: "shares"} PolicyResourceSharedResource = PolicyResourceType{ModelKey: ResourceTypeSharedResource, PathKey: "resources"} @@ -302,6 +311,9 @@ var ( PolicyPathInfraDomain PolicyResourcePath[*model.Domain] = []PolicyResourceType{PolicyResourceInfra, PolicyResourceDomain} PolicyPathVpcSubnetDynamicIPReservation PolicyResourcePath[*model.DynamicIpAddressReservation] = []PolicyResourceType{PolicyResourceOrg, PolicyResourceProject, PolicyResourceVpc, PolicyResourceVpcSubnet, PolicyResourceVpcDynamicIPReservation} PolicyPathVpcSubnetStaticIPReservation PolicyResourcePath[*model.StaticIpAddressReservation] = []PolicyResourceType{PolicyResourceOrg, PolicyResourceProject, PolicyResourceVpc, PolicyResourceVpcSubnet, PolicyResourceVpcStaticIPReservation} + // PolicyPathProjectDnsRecord is the OrgRoot hierarchy for *model.ProjectDnsRecord under a project. + // Path pattern: /orgs/{org}/projects/{project}/dns-records/{record-id}. + PolicyPathProjectDnsRecord PolicyResourcePath[*model.ProjectDnsRecord] = []PolicyResourceType{PolicyResourceOrg, PolicyResourceProject, PolicyResourceProjectDnsRecord} ) type hNodeKey struct { @@ -662,18 +674,18 @@ func (builder *PolicyTreeBuilder[T]) PagingUpdateResources(ctx context.Context, log.Info("Batch deletion interrupted by context", "resourceType", builder.leafType, "processedBatches", currentBatch-1, "totalBatches", totalBatches, "successCount", successCount, "failedCount", failedCount) return errors.Join(util.TimeoutFailed, ctx.Err()) default: - delErr := builder.UpdateMultipleResourcesOnNSX(partialObjs, nsxClient) - if delErr == nil { + updateErr := builder.UpdateMultipleResourcesOnNSX(partialObjs, nsxClient) + if updateErr == nil { successCount += len(partialObjs) - log.Info("Batch deletion succeeded", "resourceType", builder.leafType, "batch", fmt.Sprintf("%d/%d", currentBatch, totalBatches), "batchResourceCount", len(partialObjs), "cumulativeSuccess", successCount) + log.Info("Batch update succeeded", "resourceType", builder.leafType, "batch", fmt.Sprintf("%d/%d", currentBatch, totalBatches), "batchResourceCount", len(partialObjs), "cumulativeSuccess", successCount) if updateObjectsFromStoreFn != nil { updateObjectsFromStoreFn(partialObjs) } continue } failedCount += len(partialObjs) - log.Error(delErr, "Batch deletion failed", "resourceType", builder.leafType, "batch", fmt.Sprintf("%d/%d", currentBatch, totalBatches), "batchResourceCount", len(partialObjs), "cumulativeFailed", failedCount) - nsxErr = delErr + log.Error(updateErr, "Batch update failed", "resourceType", builder.leafType, "batch", fmt.Sprintf("%d/%d", currentBatch, totalBatches), "batchResourceCount", len(partialObjs), "cumulativeFailed", failedCount) + nsxErr = updateErr } } diff --git a/pkg/nsx/services/common/types.go b/pkg/nsx/services/common/types.go index d157374ff..e153d3ca8 100644 --- a/pkg/nsx/services/common/types.go +++ b/pkg/nsx/services/common/types.go @@ -106,6 +106,19 @@ const ( TagScopePodUID string = "nsx-op/pod_uid" TagScopeStatefulSetName string = "nsx-op/sts_name" TagScopeStatefulSetUID string = "nsx-op/sts_uid" + + // Tags and annotations for DNS record use case. + TagScopeDNSRecordFor string = "nsx-op/dns_for" // value: gateway, service, xxroutes + TagScopeDNSRecordGatewayIndexList string = "nsx-op/dns_gateway_index_list" + TagScopeDNSRecordOwnerNamespace string = "nsx-op/dns_owner_namespace" + TagScopeDNSRecordOwnerName string = "nsx-op/dns_owner_name" + TagScopeDNSRecordContributingOwners string = "nsx-op/dns_contributing_owners" + TagValueDNSRecordForGateway string = "gateway" + TagValueDNSRecordForHTTPRoute string = "httproute" + TagValueDNSRecordForGRPCRoute string = "grpcroute" + TagValueDNSRecordForTLSRoute string = "tlsroute" + TagValueDNSRecordForService string = "service" + // TagScopePodIndex is the NSX tag scope for Pod label apps.kubernetes.io/pod-index when synced onto the port (not set in BuildBasicTags). TagScopePodIndex string = "apps.kubernetes.io/pod-index" ValueMajorVersion string = "1" @@ -155,6 +168,10 @@ const ( GatewayInterfaceId = "gateway-interface" VPCKey = "/orgs/%s/projects/%s/vpcs/%s" + + // PathSegmentProjectDnsRecords is the NSX Policy URL path segment for project-scoped DNS records + // (full path: /orgs/{org}/projects/{project}/dns-records/{id}). Must match PolicyResourceProjectDnsRecord.PathKey. + PathSegmentProjectDnsRecords = "dns-records" ) var ( @@ -196,6 +213,7 @@ var ( ResourceTypeChildSubnetConnectionBindingMap = "ChildSubnetConnectionBindingMap" ResourceTypeChildVpcAttachment = "ChildVpcAttachment" ResourceTypeChildVpcIPAddressAllocation = "ChildVpcIpAddressAllocation" + ResourceTypeChildProjectDnsRecord = "ChildProjectDnsRecord" ResourceTypeChildVpcSubnet = "ChildVpcSubnet" ResourceTypeChildVpcSubnetPort = "ChildVpcSubnetPort" ResourceTypeChildDynamicIpAddressReservation = "ChildDynamicIpAddressReservation" @@ -214,6 +232,7 @@ var ( ResourceTypeSubnetConnectionBindingMap = "SubnetConnectionBindingMap" ResourceTypeDynamicIpAddressReservation = "DynamicIpAddressReservation" ResourceTypeStaticIpAddressReservation = "StaticIpAddressReservation" + ResourceTypeProjectDnsRecord = "ProjectDnsRecord" // ResourceTypeClusterControlPlane is used by NSXServiceAccountController ResourceTypeClusterControlPlane = "clustercontrolplane" diff --git a/pkg/nsx/services/common/wrap.go b/pkg/nsx/services/common/wrap.go index 866f7f2b2..2579543de 100644 --- a/pkg/nsx/services/common/wrap.go +++ b/pkg/nsx/services/common/wrap.go @@ -1,6 +1,8 @@ package common import ( + "fmt" + "github.com/vmware/vsphere-automation-sdk-go/runtime/data" "github.com/vmware/vsphere-automation-sdk-go/services/nsxt/model" ) @@ -83,6 +85,27 @@ func (service *Service) WrapAttachment(attachment *model.VpcAttachment) ([]*data return []*data.StructValue{dataValue.(*data.StructValue)}, nil } +func WrapProjectDnsRecord(rec *model.ProjectDnsRecord) (*data.StructValue, error) { + if rec == nil { + return nil, fmt.Errorf("nil ProjectDnsRecord") + } + // Fqdn may be server-populated / read-only on PATCH; send payload without it (operator may set it on in-memory copies for indexing). + send := *rec + send.Fqdn = nil + send.ResourceType = &ResourceTypeProjectDnsRecord + child := model.ChildProjectDnsRecord{ + Id: send.Id, + MarkedForDelete: send.MarkedForDelete, + ResourceType: ResourceTypeChildProjectDnsRecord, + ProjectDnsRecord: &send, + } + dataValue, errors := NewConverter().ConvertToVapi(child, child.GetType__()) + if len(errors) > 0 { + return nil, errors[0] + } + return dataValue.(*data.StructValue), nil +} + func WrapVpcIpAddressAllocation(allocation *model.VpcIpAddressAllocation) (*data.StructValue, error) { allocation.ResourceType = &ResourceTypeIPAddressAllocation childAddressAllocation := model.ChildVpcIpAddressAllocation{ diff --git a/pkg/nsx/services/dns/builder.go b/pkg/nsx/services/dns/builder.go new file mode 100644 index 000000000..420b77736 --- /dev/null +++ b/pkg/nsx/services/dns/builder.go @@ -0,0 +1,124 @@ +package dns + +import ( + "fmt" + "slices" + "strings" + + "github.com/vmware/vsphere-automation-sdk-go/services/nsxt/model" + + "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" + extdns "github.com/vmware-tanzu/nsx-operator/pkg/third_party/externaldns/endpoint" + "github.com/vmware-tanzu/nsx-operator/pkg/util" +) + +const ( + DefaultRecordTtL = 300 +) + +// BuildProjectDnsRecord builds one *model.ProjectDnsRecord for row using batchOwner or row.effectiveOwner. +func (s *DNSRecordService) BuildProjectDnsRecord(batchOwner *ResourceRef, row EndpointRow) *model.ProjectDnsRecord { + owner := batchOwner + if row.effectiveOwner != nil { + owner = row.effectiveOwner + } + tags := s.tagsForOwner(owner) + return row.buildDNSRecord(tags) +} + +// tagsForOwner returns NSX model tags (dns_for + owner ns/name + cluster tags). +func (s *DNSRecordService) tagsForOwner(owner *ResourceRef) []model.Tag { + basicTags := append(util.BuildClusterTags(getCluster(s)), + modelTag(common.TagScopeNamespaceUID, string(s.Service.GetNamespaceUID(owner.GetNamespace())))) + createdFor := resourceKindToCreatedFor(owner.Kind) + tags := append(basicTags, modelTag(common.TagScopeDNSRecordFor, createdFor)) + tags = append(tags, + modelTag(common.TagScopeDNSRecordOwnerNamespace, owner.GetNamespace()), + modelTag(common.TagScopeDNSRecordOwnerName, owner.GetName()), + ) + return tags +} + +// getRecordIDAndPathAndType returns the desired ProjectDnsRecord's Id, Path, and RecordType +func getRecordIDAndPathAndType(recordName, endpointRecordType, zonePath string) (string, string, string) { + nsxRecordType := getNSXDnsRecordType(endpointRecordType) + recID := strings.ReplaceAll(recordName, ".", "_") + // Ignore the errors returned in `parseProjectDNSZonePath`, as it was validated in previous steps when + // preparing the DNS zone maps in the service. + orgID, projectID, _, zoneID, _ := parseProjectDNSZonePath(zonePath) + recID = recID + "_" + zoneID + if strings.TrimSpace(nsxRecordType) != "" { + recID = recID + "_" + strings.ToLower(strings.TrimSpace(nsxRecordType)) + } + recordPath := fmt.Sprintf("/orgs/%s/projects/%s/%s/%s", orgID, projectID, DNSRecordPathSegment, recID) + return recID, recordPath, nsxRecordType +} + +func (r *EndpointRow) buildDNSRecord(basicTags []model.Tag) *model.ProjectDnsRecord { + // Append the tags according to the Endpoint labels, e.g., the parent gateway settings for a Route. + tags := r.appendRowOwnershipTags(basicTags) + recID, path, rt := getRecordIDAndPathAndType(r.nsxRecordName, r.RecordType, r.zonePath) + ttl := int64(DefaultRecordTtL) + if r.Endpoint.RecordTTL.IsConfigured() { + ttl = int64(r.Endpoint.RecordTTL) + } + display := r.nsxRecordName + rec := &model.ProjectDnsRecord{ + Id: common.String(recID), + Path: common.String(path), + RecordName: common.String(r.nsxRecordName), + DisplayName: common.String(display), + Tags: tags, + RecordType: common.String(rt), + RecordValues: append([]string(nil), r.Targets...), + ZonePath: common.String(r.zonePath), + Ttl: common.Int64(ttl), + // Mirror logical FQDN for store indexing / conflict detection; stripped before Policy PATCH (see WrapProjectDnsRecord). + Fqdn: common.String(strings.ToLower(r.Endpoint.DNSName)), + } + return rec +} + +func getNSXDnsRecordType(recType string) string { + switch recType { + case extdns.RecordTypeA: + return model.ProjectDnsRecord_RECORD_TYPE_A + case extdns.RecordTypeAAAA: + return model.ProjectDnsRecord_RECORD_TYPE_AAAA + case extdns.RecordTypeCNAME: + return model.ProjectDnsRecord_RECORD_TYPE_CNAME + case extdns.RecordTypeNS: + return model.ProjectDnsRecord_RECORD_TYPE_NS + case extdns.RecordTypePTR: + return model.ProjectDnsRecord_RECORD_TYPE_PTR + default: + return "" + } +} + +func (r *EndpointRow) appendRowOwnershipTags(basicTags []model.Tag) []model.Tag { + gwKey := "" + if r.Endpoint != nil && r.Endpoint.Labels != nil { + gwKey = strings.TrimSpace(r.Endpoint.Labels[EndpointLabelParentGateway]) + } + tags := append([]model.Tag{}, basicTags...) + return appendRecordOwnershipTags(tags, gwKey, r.contributingOwnerKeys) +} + +func modelTag(scope, value string) model.Tag { + return model.Tag{Scope: common.String(scope), Tag: common.String(value)} +} + +func mergeDNSRecordForUpdate(desired, existing *model.ProjectDnsRecord) *model.ProjectDnsRecord { + out := *desired + out.Id = existing.Id + out.DisplayName = existing.DisplayName + out.Path = existing.Path + return &out +} + +func sortedCopyStrings(in []string) []string { + out := append([]string(nil), in...) + slices.Sort(out) + return out +} diff --git a/pkg/nsx/services/dns/cleanup.go b/pkg/nsx/services/dns/cleanup.go new file mode 100644 index 000000000..12bd07889 --- /dev/null +++ b/pkg/nsx/services/dns/cleanup.go @@ -0,0 +1,33 @@ +/* Copyright © 2026 Broadcom, Inc. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 */ + +package dns + +import ( + "context" + + "github.com/vmware/vsphere-automation-sdk-go/services/nsxt/model" + + "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" +) + +// CleanupInfraResources deletes all cached ProjectDnsRecord objects on NSX (Hierarchy / OrgRoot patch) and removes them from the local store. +func (s *DNSRecordService) CleanupInfraResources(ctx context.Context) error { + objs := s.DNSRecordStore.ListDNSRecords() + if len(objs) == 0 { + return nil + } + toDelete := make([]*model.ProjectDnsRecord, 0, len(objs)) + for _, rec := range objs { + if rec == nil { + continue + } + cp := *rec + cp.MarkedForDelete = common.Bool(true) + toDelete = append(toDelete, &cp) + } + log.Info("Cleaning up ProjectDnsRecord resources on NSX", "count", len(toDelete)) + return s.ProjectDnsRecordBuilder.PagingUpdateResources(ctx, toDelete, common.DefaultHAPIChildrenCount, s.NSXClient, func(deletedObjs []*model.ProjectDnsRecord) { + s.DNSRecordStore.DeleteMultipleObjects(deletedObjs) + }) +} diff --git a/pkg/nsx/services/dns/compare.go b/pkg/nsx/services/dns/compare.go new file mode 100644 index 000000000..2f34b8b40 --- /dev/null +++ b/pkg/nsx/services/dns/compare.go @@ -0,0 +1,131 @@ +/* Copyright © 2026 Broadcom, Inc. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 */ + +package dns + +import ( + "slices" + "strings" + + "github.com/vmware/vsphere-automation-sdk-go/runtime/data" + "github.com/vmware/vsphere-automation-sdk-go/services/nsxt/model" + + "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" +) + +// dnsRecordComparable adapts model.ProjectDnsRecord for common.CompareResource(s) (see ipaddressallocation/compare.go). +type dnsRecordComparable model.ProjectDnsRecord + +func (d *dnsRecordComparable) Key() string { + if d == nil { + return "" + } + rec := (*model.ProjectDnsRecord)(d) + if rec.Path == nil { + return "" + } + return *rec.Path +} + +func (d *dnsRecordComparable) Value() data.DataValue { + if d == nil { + return nil + } + rec := (*model.ProjectDnsRecord)(d) + s := &model.ProjectDnsRecord{ + RecordName: rec.RecordName, + RecordType: rec.RecordType, + ZonePath: rec.ZonePath, + Ttl: rec.Ttl, + RecordValues: sortedCopyStrings(rec.RecordValues), + Tags: sortedNormalizedTagsForCompare(rec.Tags), + } + dataValue, _ := s.GetDataValue__() + return dataValue +} + +// sortedNormalizedTagsForCompare returns a copy of tags sorted by (scope, value) with the +// contributing-owners tag value normalised to its canonical comma-separated sorted form. +// This ensures Value() comparisons are order-independent regardless of tag insertion order. +func sortedNormalizedTagsForCompare(tags []model.Tag) []model.Tag { + out := append([]model.Tag{}, tags...) + slices.SortFunc(out, func(a, b model.Tag) int { + as, bs := "", "" + if a.Scope != nil { + as = *a.Scope + } + if b.Scope != nil { + bs = *b.Scope + } + if c := strings.Compare(as, bs); c != 0 { + return c + } + av, bv := "", "" + if a.Tag != nil { + av = *a.Tag + } + if b.Tag != nil { + bv = *b.Tag + } + return strings.Compare(av, bv) + }) + return out +} + +func comparableToProjectDnsRecord(c common.Comparable) *model.ProjectDnsRecord { + if c == nil { + return nil + } + dc, ok := c.(*dnsRecordComparable) + if !ok { + return nil + } + out := model.ProjectDnsRecord(*dc) + return &out +} + +// compareRecords returns (toUpsert, toRemove) for reconcile; caller marks toRemove copies deleted. +func compareRecords(desired, existing []*model.ProjectDnsRecord) (toUpsert []*model.ProjectDnsRecord, toRemove []*model.ProjectDnsRecord) { + existingByPath := make(map[string]*model.ProjectDnsRecord) + existingComp := make([]common.Comparable, 0, len(existing)) + for _, e := range existing { + if e == nil || e.Path == nil { + continue + } + existingByPath[*e.Path] = e + extRecord := dnsRecordComparable(*e) + existingComp = append(existingComp, &extRecord) + } + + desiredComp := make([]common.Comparable, 0, len(desired)) + for _, d := range desired { + if d == nil || d.Path == nil { + continue + } + desiredRecord := dnsRecordComparable(*d) + desiredComp = append(desiredComp, &desiredRecord) + } + + changed, stale := common.CompareResources(existingComp, desiredComp) + + toUpsert = make([]*model.ProjectDnsRecord, 0, len(changed)) + for _, ch := range changed { + d := comparableToProjectDnsRecord(ch) + if d == nil || d.Path == nil { + continue + } + if ex, ok := existingByPath[*d.Path]; ok { + toUpsert = append(toUpsert, mergeDNSRecordForUpdate(d, ex)) + } else { + toUpsert = append(toUpsert, d) + } + } + + toRemove = make([]*model.ProjectDnsRecord, 0, len(stale)) + for _, st := range stale { + if rec := comparableToProjectDnsRecord(st); rec != nil { + toRemove = append(toRemove, rec) + } + } + return toUpsert, toRemove +} diff --git a/pkg/nsx/services/dns/compare_contributing_test.go b/pkg/nsx/services/dns/compare_contributing_test.go new file mode 100644 index 000000000..7e2c311b1 --- /dev/null +++ b/pkg/nsx/services/dns/compare_contributing_test.go @@ -0,0 +1,221 @@ +/* Copyright © 2026 Broadcom, Inc. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 */ + +package dns + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/vmware/vsphere-automation-sdk-go/runtime/data" + "github.com/vmware/vsphere-automation-sdk-go/services/nsxt/model" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + servicecommon "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" +) + +// wrongComparable implements common.Comparable but is not *dnsRecordComparable. +type wrongComparable struct{} + +func (w *wrongComparable) Key() string { return "" } +func (w *wrongComparable) Value() data.DataValue { return nil } + +func TestDNSRecordComparable_Key_table(t *testing.T) { + path := "/orgs/o/projects/p/dns-records/r1" + tests := []struct { + name string + d *dnsRecordComparable + want string + }{ + {"nil receiver", nil, ""}, + {"nil Path field", (*dnsRecordComparable)(&model.ProjectDnsRecord{}), ""}, + {"valid path", (*dnsRecordComparable)(&model.ProjectDnsRecord{Path: &path}), path}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, tc.d.Key()) + }) + } +} + +func TestDNSRecordComparable_Value_nil_receiver(t *testing.T) { + var d *dnsRecordComparable + require.Nil(t, d.Value()) +} + +func TestSortedNormalizedTagsForCompare_nilFields(t *testing.T) { + // Tags with nil Scope and nil Tag value should sort without panic (treated as ""). + nilScope := model.Tag{Scope: nil, Tag: servicecommon.String("v")} + nilTag := model.Tag{Scope: servicecommon.String("z"), Tag: nil} + normal := model.Tag{Scope: servicecommon.String("a"), Tag: servicecommon.String("x")} + + got := sortedNormalizedTagsForCompare([]model.Tag{nilScope, nilTag, normal}) + require.Len(t, got, 3) + // nil Scope sorts as "" → comes before "a" and "z". + require.Nil(t, got[0].Scope) +} + +func TestComparableToProjectDnsRecord_table(t *testing.T) { + t.Run("nil interface returns nil", func(t *testing.T) { + require.Nil(t, comparableToProjectDnsRecord(nil)) + }) + t.Run("wrong type returns nil", func(t *testing.T) { + require.Nil(t, comparableToProjectDnsRecord(&wrongComparable{})) + }) +} + +// --- contributing.go tests --- + +func TestResourceRefFromDNSRecord_table(t *testing.T) { + validTags := func(kind, ns, name string) []model.Tag { + return []model.Tag{ + modelTag(servicecommon.TagScopeDNSRecordFor, resourceKindToCreatedFor(kind)), + modelTag(servicecommon.TagScopeDNSRecordOwnerNamespace, ns), + modelTag(servicecommon.TagScopeDNSRecordOwnerName, name), + } + } + + tests := []struct { + name string + rec *model.ProjectDnsRecord + wantOk bool + wantKind string + wantNS string + wantName string + }{ + { + name: "nil record", + rec: nil, + wantOk: false, + }, + { + name: "no owner tags", + rec: &model.ProjectDnsRecord{Tags: []model.Tag{}}, + wantOk: false, + }, + { + name: "missing name tag", + rec: &model.ProjectDnsRecord{Tags: []model.Tag{ + modelTag(servicecommon.TagScopeDNSRecordFor, servicecommon.TagValueDNSRecordForService), + modelTag(servicecommon.TagScopeDNSRecordOwnerNamespace, "ns"), + // TagScopeDNSRecordOwnerName is missing + }}, + wantOk: false, + }, + { + name: "unknown dns_for kind", + rec: &model.ProjectDnsRecord{Tags: []model.Tag{ + modelTag(servicecommon.TagScopeDNSRecordFor, "custom_unknown_kind"), + modelTag(servicecommon.TagScopeDNSRecordOwnerNamespace, "ns"), + modelTag(servicecommon.TagScopeDNSRecordOwnerName, "svc"), + }}, + wantOk: false, + }, + { + name: "valid Service record", + rec: &model.ProjectDnsRecord{Tags: validTags(ResourceKindService, "ns1", "svcA")}, + wantOk: true, + wantKind: ResourceKindService, + wantNS: "ns1", + wantName: "svcA", + }, + { + name: "valid HTTPRoute record", + rec: &model.ProjectDnsRecord{Tags: validTags(ResourceKindHTTPRoute, "app", "route1")}, + wantOk: true, + wantKind: ResourceKindHTTPRoute, + wantNS: "app", + wantName: "route1", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ref, ok := resourceRefFromDNSRecord(tc.rec) + require.Equal(t, tc.wantOk, ok) + if !tc.wantOk { + require.Nil(t, ref) + return + } + require.NotNil(t, ref) + require.Equal(t, tc.wantKind, ref.Kind) + require.Equal(t, tc.wantNS, ref.GetNamespace()) + require.Equal(t, tc.wantName, ref.GetName()) + }) + } +} + +func TestPrimaryOwnerNNIndexKeyFromRecord_table(t *testing.T) { + tests := []struct { + name string + rec *model.ProjectDnsRecord + wantKey string + }{ + { + name: "record with no owner tags returns empty", + rec: &model.ProjectDnsRecord{Tags: []model.Tag{}}, + wantKey: "", + }, + { + name: "record with valid owner tags returns index key", + rec: &model.ProjectDnsRecord{Tags: []model.Tag{ + modelTag(servicecommon.TagScopeDNSRecordFor, servicecommon.TagValueDNSRecordForService), + modelTag(servicecommon.TagScopeDNSRecordOwnerNamespace, "ns"), + modelTag(servicecommon.TagScopeDNSRecordOwnerName, "svc"), + }}, + wantKey: "service/ns/svc", + }, + { + name: "HTTPRoute owner returns correct key", + rec: &model.ProjectDnsRecord{Tags: []model.Tag{ + modelTag(servicecommon.TagScopeDNSRecordFor, servicecommon.TagValueDNSRecordForHTTPRoute), + modelTag(servicecommon.TagScopeDNSRecordOwnerNamespace, "app"), + modelTag(servicecommon.TagScopeDNSRecordOwnerName, "route1"), + }}, + wantKey: "httproute/app/route1", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.wantKey, primaryOwnerNNIndexKeyFromRecord(tc.rec)) + }) + } +} + +func TestOwnerNNIndexKeyForResourceRef_table(t *testing.T) { + tests := []struct { + name string + owner *ResourceRef + want string + }{ + { + name: "nil owner returns empty", + owner: nil, + want: "", + }, + { + name: "unknown kind returns empty", + owner: &ResourceRef{Kind: "UnknownKind", Object: &metav1.ObjectMeta{Namespace: "ns", Name: "obj"}}, + want: "", + }, + { + name: "Service kind returns key", + owner: &ResourceRef{Kind: ResourceKindService, Object: &metav1.ObjectMeta{Namespace: "ns", Name: "svc"}}, + want: "service/ns/svc", + }, + { + name: "GRPCRoute kind returns key", + owner: &ResourceRef{Kind: ResourceKindGRPCRoute, Object: &metav1.ObjectMeta{Namespace: "app", Name: "gr1"}}, + want: "grpcroute/app/gr1", + }, + { + name: "TLSRoute kind returns key", + owner: &ResourceRef{Kind: ResourceKindTLSRoute, Object: &metav1.ObjectMeta{Namespace: "app", Name: "tls1"}}, + want: "tlsroute/app/tls1", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, ownerNNIndexKeyForResourceRef(tc.owner)) + }) + } +} diff --git a/pkg/nsx/services/dns/contributing.go b/pkg/nsx/services/dns/contributing.go new file mode 100644 index 000000000..98a22b71a --- /dev/null +++ b/pkg/nsx/services/dns/contributing.go @@ -0,0 +1,134 @@ +/* Copyright © 2026 Broadcom, Inc. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 */ + +package dns + +import ( + "slices" + "strings" + + "github.com/vmware/vsphere-automation-sdk-go/services/nsxt/model" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" + + "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" +) + +func parseContributingOwnersFromRecord(rec *model.ProjectDnsRecord) []string { + return parseContributingOwnersTag(firstTagValue(rec.Tags, common.TagScopeDNSRecordContributingOwners)) +} + +func parseContributingOwnersTag(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + seen := sets.New[string]() + for _, p := range strings.Split(raw, ",") { + k := strings.TrimSpace(p) + if k != "" { + seen.Insert(k) + } + } + out := seen.UnsortedList() + slices.Sort(out) + return out +} + +func formatContributingOwnersTag(keys []string) string { + if len(keys) == 0 { + return "" + } + cp := sortedCopyStrings(keys) + return strings.Join(cp, ",") +} + +// mergeContributingOwnerKeys returns sorted unique contributing keys (excludes primaryNNKey). +func mergeContributingOwnerKeys(existing string, add string, primaryNNKey string) string { + raw := strings.Join([]string{existing, add}, ",") + seen := sets.New[string]() + for _, p := range strings.Split(raw, ",") { + k := strings.TrimSpace(p) + if k == "" || k == primaryNNKey { + continue + } + if k != "" { + seen.Insert(k) + } + } + return formatContributingOwnersTag(seen.UnsortedList()) +} + +func resourceRefFromDNSRecord(rec *model.ProjectDnsRecord) (*ResourceRef, bool) { + if rec == nil { + return nil, false + } + createdFor, ns, name, ok := ownerCreatedForAndNNFromDNSRecord(rec) + if !ok || ns == "" || name == "" { + return nil, false + } + kind := resourceKindFromCreatedForTag(createdFor) + if kind == "" { + return nil, false + } + meta := metav1.ObjectMeta{Namespace: ns, Name: name} + return &ResourceRef{Kind: kind, Object: &meta}, true +} + +// parseOwnerNNIndexKey parses "createdFor/ns/name" owner index keys. +func parseOwnerNNIndexKey(key string) (createdFor, ns, name string, ok bool) { + key = strings.TrimSpace(key) + if key == "" { + return "", "", "", false + } + parts := strings.SplitN(key, "/", 3) + if len(parts) != 3 { + return "", "", "", false + } + return parts[0], parts[1], parts[2], true +} + +func ownerNNIndexKeyForResourceRef(owner *ResourceRef) string { + if owner == nil { + return "" + } + createdFor := resourceKindToCreatedFor(owner.Kind) + if createdFor == "" { + return "" + } + return dnsRecordOwnerKey(createdFor, dnsRecordOwnerNamespacedNameKey(owner.GetNamespace(), owner.GetName())) +} + +func primaryOwnerNNIndexKeyFromRecord(rec *model.ProjectDnsRecord) string { + return getDNSRecordOwnerNamespacedName(rec) +} + +// appendRecordOwnershipTags appends the optional GatewayIndexList and ContributingOwners tags +// onto tags when their values are non-empty, returning the extended slice. The caller is +// responsible for passing a slice it owns so this function may append to it directly. +func appendRecordOwnershipTags(tags []model.Tag, gwKey string, ctag string) []model.Tag { + if gwKey = strings.TrimSpace(gwKey); gwKey != "" { + tags = append(tags, modelTag(common.TagScopeDNSRecordGatewayIndexList, gwKey)) + } + if ctag != "" { + tags = append(tags, modelTag(common.TagScopeDNSRecordContributingOwners, ctag)) + } + return tags +} + +func replaceContributingOwnersInTags(tags []model.Tag, newContribKeys []string) []model.Tag { + out := make([]model.Tag, 0) + for _, t := range tags { + if t.Scope == nil { + continue + } + if *t.Scope != common.TagScopeDNSRecordContributingOwners { + out = append(out, t) + continue + } + if len(newContribKeys) > 0 { + out = append(out, modelTag(common.TagScopeDNSRecordContributingOwners, formatContributingOwnersTag(newContribKeys))) + } + } + return out +} diff --git a/pkg/nsx/services/dns/errors.go b/pkg/nsx/services/dns/errors.go new file mode 100644 index 000000000..4e840c47c --- /dev/null +++ b/pkg/nsx/services/dns/errors.go @@ -0,0 +1,31 @@ +/* Copyright © 2026 Broadcom, Inc. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 */ + +package dns + +import "fmt" + +// DNSZoneValidationError is returned when DNS zone policy validation fails (e.g. no allowed zones, +// FQDN conflict in a zone). Use errors.As on the returned error with the wrapped Cause when present. +type DNSZoneValidationError struct { + Msg string + Cause error +} + +func (e *DNSZoneValidationError) Error() string { + if e == nil { + return "" + } + if e.Cause != nil { + return fmt.Sprintf("%s: %v", e.Msg, e.Cause) + } + return e.Msg +} + +// Unwrap implements errors.Unwrap. +func (e *DNSZoneValidationError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} diff --git a/pkg/nsx/services/dns/errors_test.go b/pkg/nsx/services/dns/errors_test.go new file mode 100644 index 000000000..af6696e06 --- /dev/null +++ b/pkg/nsx/services/dns/errors_test.go @@ -0,0 +1,49 @@ +/* Copyright © 2026 Broadcom, Inc. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 */ + +package dns + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDNSZoneValidationError_Unwrap(t *testing.T) { + inner := fmt.Errorf("inner") + err := &DNSZoneValidationError{Msg: "outer", Cause: inner} + var d *DNSZoneValidationError + require.ErrorAs(t, err, &d) + require.ErrorIs(t, err, inner) +} + +func TestDNSZoneValidationError_nilReceiver(t *testing.T) { + var e *DNSZoneValidationError + require.Equal(t, "", e.Error()) + require.Nil(t, e.Unwrap()) +} + +func TestDNSZoneValidationError_Error_table(t *testing.T) { + tests := []struct { + name string + err *DNSZoneValidationError + want string + }{ + { + name: "message only (no cause)", + err: &DNSZoneValidationError{Msg: "zone not found"}, + want: "zone not found", + }, + { + name: "message with cause", + err: &DNSZoneValidationError{Msg: "outer", Cause: fmt.Errorf("inner cause")}, + want: "outer: inner cause", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, tc.err.Error()) + }) + } +} diff --git a/pkg/nsx/services/dns/initialize.go b/pkg/nsx/services/dns/initialize.go new file mode 100644 index 000000000..9b96c60a0 --- /dev/null +++ b/pkg/nsx/services/dns/initialize.go @@ -0,0 +1,66 @@ +/* Copyright © 2026 Broadcom, Inc. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 */ + +package dns + +import ( + "fmt" + "strings" + "sync" + + "github.com/vmware/vsphere-automation-sdk-go/services/nsxt/model" + + "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" +) + +// InitializeDNSRecordService constructs a DNSRecordService and hydrates DNSRecordStore from NSX Policy search. +func InitializeDNSRecordService(commonService common.Service, vpcService common.VPCServiceProvider) (*DNSRecordService, error) { + builder, err := common.PolicyPathProjectDnsRecord.NewPolicyTreeBuilder() + if err != nil { + return nil, fmt.Errorf("creating DNS record tree builder: %w", err) + } + s := &DNSRecordService{ + Service: commonService, + DNSRecordStore: BuildDNSRecordStore(), + VPCService: vpcService, + DNSZoneMap: newDNSZoneCache(), + ProjectDnsRecordBuilder: builder, + } + + cluster := commonService.NSXConfig.CoeConfig.Cluster + tags := []model.Tag{{Scope: common.String(common.TagScopeCluster), Tag: common.String(cluster)}} + + wg := sync.WaitGroup{} + wgDone := make(chan bool) + fatalErrors := make(chan error, 1) + + wg.Add(1) + go commonService.InitializeResourceStore(&wg, fatalErrors, common.ResourceTypeProjectDnsRecord, tags, s.DNSRecordStore) + + go func() { + wg.Wait() + close(wgDone) + }() + select { + case <-wgDone: + case err := <-fatalErrors: + return s, err + } + + // Warm up DNSZoneMap from zone paths used by records already in the store. + for zonePath := range s.DNSRecordStore.ListZonePaths() { + if _, found := s.DNSZoneMap.get(zonePath); found { + continue + } + zone, err := s.getDNSZoneFromNSX(zonePath) + if err != nil { + log.Error(err, "failed to warm up DNS zone cache during initialization", "path", zonePath) + continue // non-fatal: will be re-fetched on demand by SyncDNSZonesByVpcNetworkConfig + } + if zone.DnsDomainName != nil { + s.DNSZoneMap.set(zonePath, strings.TrimSpace(*zone.DnsDomainName)) + } + } + + return s, nil +} diff --git a/pkg/nsx/services/dns/recordservice.go b/pkg/nsx/services/dns/recordservice.go new file mode 100644 index 000000000..bb77f653a --- /dev/null +++ b/pkg/nsx/services/dns/recordservice.go @@ -0,0 +1,455 @@ +/* Copyright © 2026 Broadcom, Inc. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 */ + +package dns + +import ( + "context" + "errors" + "fmt" + "regexp" + "slices" + "strings" + "sync" + + "github.com/vmware/vsphere-automation-sdk-go/services/nsxt/model" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + + "github.com/vmware-tanzu/nsx-operator/pkg/logger" + "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" + "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/realizestate" + nsxutil "github.com/vmware-tanzu/nsx-operator/pkg/nsx/util" + extdns "github.com/vmware-tanzu/nsx-operator/pkg/third_party/externaldns/endpoint" + "github.com/vmware-tanzu/nsx-operator/pkg/util" +) + +var ( + log = logger.Log + _ DNSRecordProvider = (*DNSRecordService)(nil) +) + +// NSX Policy path: /orgs/{org}/projects/{project}/dns-records/{record-id} +var projectDNSRecordPathRe = regexp.MustCompile(`^/orgs/([^/]+)/projects/([^/]+)/dns-records/([^/]+)$`) + +// dnsZoneCache is a thread-safe zone path → DNS domain name cache. +type dnsZoneCache struct { + mu sync.RWMutex + m map[string]string +} + +func newDNSZoneCache() *dnsZoneCache { + return &dnsZoneCache{m: make(map[string]string)} +} + +// NewDNSZoneCacheFromMap creates a dnsZoneCache pre-populated from m. +func NewDNSZoneCacheFromMap(m map[string]string) *dnsZoneCache { + c := newDNSZoneCache() + for k, v := range m { + c.m[k] = v + } + return c +} + +func (c *dnsZoneCache) get(key string) (string, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + v, ok := c.m[key] + return v, ok +} + +func (c *dnsZoneCache) set(key, value string) { + c.mu.Lock() + defer c.mu.Unlock() + c.m[key] = value +} + +// DNSRecordService reconciles DNS rows against NSX (store + future HAPI); optional VPCService and DNSZoneMap for zone validation. +type DNSRecordService struct { + common.Service + VPCService common.VPCServiceProvider + DNSRecordStore *RecordStore + DNSZoneMap *dnsZoneCache + ProjectDnsRecordBuilder *common.PolicyTreeBuilder[*model.ProjectDnsRecord] +} + +// CreateOrUpdateRecords upserts batch rows into the store and NSX placeholder. Returns (storeMutated, err). +func (s *DNSRecordService) CreateOrUpdateRecords(ctx context.Context, batch *AggregatedDNSEndpoints) (bool, error) { + toUpsert, toRemove, err := s.applyDNSUpsertRows(batch) + if err != nil { + return false, err + } + if len(toUpsert) == 0 && len(toRemove) == 0 { + return false, nil + } + toApply, syncErr := s.syncProjectDnsRecordsInNSX(ctx, toUpsert, toRemove) + // Apply whichever records were successfully processed to keep the local store in sync even on + // partial realization failures; this prevents re-sending already-realized records next reconcile. + if len(toApply) > 0 { + if applyErr := s.DNSRecordStore.Apply(toApply); applyErr != nil { + return false, applyErr + } + } + return len(toApply) > 0, syncErr +} + +// syncProjectDnsRecordsInNSX sends upserts then deletes via OrgRoot hierarchy patch. For each upsert it checks +// realization on the patched record path (like Subnet), then GETs the record from NSX for the store; on realization +// failure it deletes the record via Policy API. +func (s *DNSRecordService) syncProjectDnsRecordsInNSX(ctx context.Context, toUpsert, toRemove []*model.ProjectDnsRecord) ([]*model.ProjectDnsRecord, error) { + removeOps := make([]*model.ProjectDnsRecord, 0, len(toRemove)) + for _, rec := range toRemove { + cp := *rec + cp.MarkedForDelete = common.Bool(true) + removeOps = append(removeOps, &cp) + } + + batch := append(append([]*model.ProjectDnsRecord(nil), toUpsert...), removeOps...) + + if len(batch) == 0 { + return nil, nil + } + log.Info("Patching ProjectDnsRecord batch on NSX", "upsert", len(toUpsert), "remove", len(toRemove)) + if err := s.ProjectDnsRecordBuilder.PagingUpdateResources(ctx, batch, common.DefaultHAPIChildrenCount, s.NSXClient, nil); err != nil { + return nil, err + } + if len(toUpsert) == 0 { + return removeOps, nil + } + // Check the realization state of each upserted record and refresh it from NSX so that the local + // store stays consistent with what NSX persisted. Failures are collected and returned together + // after the full loop so that successfully realized records are still cached locally — this avoids + // re-sending them on the next reconcile pass. + realizeService := realizestate.InitializeRealizeState(s.Service) + refreshed := make([]*model.ProjectDnsRecord, 0, len(toUpsert)) + var realizeErrs []error + for _, rec := range toUpsert { + orgID, projectID, recordID, perr := parseProjectDNSRecordPolicyPath(*rec.Path) + if perr != nil { + log.Error(perr, "Failed to parse ProjectDnsRecord path, skipping record", "path", *rec.Path) + realizeErrs = append(realizeErrs, perr) + continue + } + log.Debug("Checking realization state for ProjectDnsRecord", "Id", recordID) + if err := realizeService.CheckRealizeState(util.NSXTRealizeRetry, *rec.Path, []string(nil)); err != nil { + log.Error(err, "Failed to check ProjectDnsRecord realization state", "Id", recordID) + if delErr := s.deleteProjectDnsRecordOnNSX(rec); delErr != nil { + log.Error(delErr, "Failed to delete ProjectDnsRecord after realization check failure", "Id", recordID) + realizeErrs = append(realizeErrs, fmt.Errorf("realization check failed for %s: %w; deletion also failed: %v", recordID, err, delErr)) + } else { + log.Info("Cleaned up unrealized ProjectDnsRecord from NSX", "Id", recordID) + realizeErrs = append(realizeErrs, fmt.Errorf("realization check failed for %s: %w", recordID, err)) + } + continue + } + live, gerr := s.NSXClient.DnsRecordsClient.Get(orgID, projectID, recordID) + gerr = nsxutil.TransNSXApiError(gerr) + if gerr != nil { + log.Error(gerr, "Failed to get realized ProjectDnsRecord from NSX", "Id", recordID) + realizeErrs = append(realizeErrs, fmt.Errorf("failed to get record %s from NSX after realization: %w", recordID, gerr)) + continue + } + log.Debug("ProjectDnsRecord realized and refreshed", "Id", recordID) + lc := live + refreshed = append(refreshed, &lc) + } + toApply := append(append([]*model.ProjectDnsRecord(nil), refreshed...), removeOps...) + if len(realizeErrs) > 0 { + log.Error(errors.Join(realizeErrs...), "Some ProjectDnsRecords failed realization", + "failed", len(realizeErrs), "succeeded", len(refreshed)) + return toApply, errors.Join(realizeErrs...) + } + return toApply, nil +} + +// validateEndpointRowConflict returns a row for ep in zone, or an error on FQDN conflict. +func (s *DNSRecordService) validateEndpointRowConflict(zonePath string, ep *extdns.Endpoint, recordName string, owner *ResourceRef) (*EndpointRow, error) { + createdFor := resourceKindToCreatedFor(owner.Kind) + if createdFor == "" { + return nil, fmt.Errorf("unsupported resource kind %q for DNS record", owner.Kind) + } + fqdn := strings.ToLower(ep.DNSName) + recTypeForIdx := strings.ToLower(strings.TrimSpace(ep.RecordType)) + idxKey := dnsRecordZonePathFQDNIndexKey(zonePath, fqdn, recTypeForIdx) + recs := s.DNSRecordStore.GetByIndex(indexKeyDNSRecordZonePathFQDN, idxKey) + log.Debug("Checking DNS record conflict", "fqdn", fqdn, "zone", zonePath, "type", recTypeForIdx, "existingCount", len(recs)) + currentNNKey := ownerNNIndexKeyForResourceRef(owner) + for _, rec := range recs { + if getDNSRecordOwnerNamespacedName(rec) == currentNNKey { + return NewEndpointRow(ep, zonePath, recordName), nil + } + if *rec.RecordType != ep.RecordType { + continue + } + extRecValues := sortedCopyStrings(rec.RecordValues) + newRecValues := sortedCopyStrings(ep.Targets) + if !slices.Equal(extRecValues, newRecValues) { + err := fmt.Errorf("FQDN %s is configured with different values in DNS zone %s", fqdn, zonePath) + log.Error(err, "FQDN targets conflict with existing record", "resource", getDNSRecordOwnerNamespacedName(rec)) + return nil, err + } + effectiveOwner, ok := resourceRefFromDNSRecord(rec) + if !ok { + err := fmt.Errorf("FQDN %s has an existing DNS record with incomplete owner metadata in DNS zone %s", fqdn, zonePath) + log.Error(err, "cannot adopt shared DNS record") + return nil, err + } + log.Info("Adopting shared DNS record", "fqdn", fqdn, "zone", zonePath, + "effectiveOwner", getDNSRecordOwnerNamespacedName(rec), "currentOwner", currentNNKey) + row := NewEndpointRow(ep, zonePath, recordName) + primaryNN := primaryOwnerNNIndexKeyFromRecord(rec) + row.effectiveOwner = effectiveOwner + existing := firstTagValue(rec.Tags, common.TagScopeDNSRecordContributingOwners) + row.contributingOwnerKeys = mergeContributingOwnerKeys(existing, currentNNKey, primaryNN) + return row, nil + } + return NewEndpointRow(ep, zonePath, recordName), nil +} + +// classifyOwnerRemoval handles the three-way decision for a record that references deletedOwnerKey +// (either as primary or contributing owner) and appends to toDelete or toUpdate accordingly: +// - primary owner, no contributors → mark for delete +// - primary owner, has contributors → promote first contributor, append promoted record to toUpdate +// - contributing owner → drop key from contributing tag, append updated record to toUpdate +func (s *DNSRecordService) classifyOwnerRemoval(rec *model.ProjectDnsRecord, deletedOwnerKey string, toDelete, toUpdate *[]*model.ProjectDnsRecord) error { + contribs := parseContributingOwnersFromRecord(rec) // already sorted + if getDNSRecordOwnerNamespacedName(rec) == deletedOwnerKey { + if len(contribs) == 0 { + cp := *rec + cp.MarkedForDelete = common.Bool(true) + *toDelete = append(*toDelete, &cp) + return nil + } + upd, err := s.recordAfterPrimaryDeletePromotion(rec, contribs) + if err != nil { + return err + } + *toUpdate = append(*toUpdate, upd) + return nil + } + if upd, ok := recordAfterContributingRemoval(rec, deletedOwnerKey); ok { + *toUpdate = append(*toUpdate, upd) + } + return nil +} + +// applyDNSUpsertRows computes upserts and removals for a reconcile batch: +// - desired rows: build the target record, compare with store, enqueue update if changed. +// - stale rows: records once owned by batch.Owner that are no longer in the desired set +// are either deleted (sole owner) or re-tagged (promote/remove-contributor). +func (s *DNSRecordService) applyDNSUpsertRows(batch *AggregatedDNSEndpoints) ([]*model.ProjectDnsRecord, []*model.ProjectDnsRecord, error) { + if batch.Owner == nil { + if len(batch.Rows) > 0 { + return nil, nil, fmt.Errorf("aggregated DNS batch has rows but Owner is nil") + } + return nil, nil, nil + } + log.Debug("Computing DNS record diff", "kind", batch.Owner.Kind, + "namespace", batch.Owner.GetNamespace(), "name", batch.Owner.GetName(), "rows", len(batch.Rows)) + + desiredRecs := make([]*model.ProjectDnsRecord, 0, len(batch.Rows)) + for _, row := range batch.Rows { + if rec := s.BuildProjectDnsRecord(batch.Owner, row); rec != nil { + desiredRecs = append(desiredRecs, rec) + } + } + + // Collect all records where batch.Owner is primary or contributing. + owner := batch.Owner + ownerNNKey, ownedRecs := s.collectRecordsByOwner(owner.Kind, owner.GetNamespace(), owner.GetName()) + + // compareRecords: new/content-changed records go to toUpsert; stale go to staleRecs. + toUpsert, staleRecs := compareRecords(desiredRecs, ownedRecs) + + var toRemove []*model.ProjectDnsRecord + for _, rec := range staleRecs { + if err := s.classifyOwnerRemoval(rec, ownerNNKey, &toRemove, &toUpsert); err != nil { + return nil, nil, err + } + } + log.Debug("DNS record diff ready", "owner", ownerNNKey, "toUpsert", len(toUpsert), "toRemove", len(toRemove)) + return toUpsert, toRemove, nil +} + +func (s *DNSRecordService) collectRecordsByOwner(ownerKind, ownerNamespace, ownerName string) (string, []*model.ProjectDnsRecord) { + createdFor := resourceKindToCreatedFor(ownerKind) + if createdFor == "" { + return "", nil + } + ownerNNKey := dnsRecordOwnerKey(createdFor, dnsRecordOwnerNamespacedNameKey(ownerNamespace, ownerName)) + primRecs := s.DNSRecordStore.GetByOwnerResourceNamespacedName(ownerKind, ownerNamespace, ownerName) + contribRecs := s.DNSRecordStore.ListRecordsReferencingContributingOwner(ownerNNKey) + return ownerNNKey, dedupeRecordsByPath(slices.Concat(primRecs, contribRecs)) +} + +// DeleteRecordByOwnerNN deletes or retags rows for kind/ns/name. Returns (storeMutated, err). +func (s *DNSRecordService) DeleteRecordByOwnerNN(ctx context.Context, kind, namespace, name string) (bool, error) { + deletedNNKey, all := s.collectRecordsByOwner(kind, namespace, name) + if deletedNNKey == "" || len(all) == 0 { + log.Debug("No owned DNS records found, skipping delete", "kind", kind, "namespace", namespace, "name", name) + return false, nil + } + log.Info("Deleting DNS records for owner", "kind", kind, "namespace", namespace, "name", name, "count", len(all)) + + var toUpdate, toDelete []*model.ProjectDnsRecord + for _, rec := range all { + if err := s.classifyOwnerRemoval(rec, deletedNNKey, &toDelete, &toUpdate); err != nil { + return false, err + } + } + log.Debug("Classified DNS record removals", "kind", kind, "namespace", namespace, "name", name, + "toDelete", len(toDelete), "toUpdate", len(toUpdate)) + + cacheChanged := len(toDelete) > 0 || len(toUpdate) > 0 + if !cacheChanged { + return false, nil + } + toApply, syncErr := s.syncProjectDnsRecordsInNSX(ctx, toUpdate, toDelete) + if len(toApply) > 0 { + if applyErr := s.DNSRecordStore.Apply(toApply); applyErr != nil { + return false, applyErr + } + } + return len(toApply) > 0, syncErr +} + +func gatewayIndexTagFromRecord(rec *model.ProjectDnsRecord) string { + return firstTagValue(rec.Tags, common.TagScopeDNSRecordGatewayIndexList) +} + +// recordAfterPrimaryDeletePromotion returns the updated record when the primary owner is removed but contributors remain. +func (s *DNSRecordService) recordAfterPrimaryDeletePromotion(rec *model.ProjectDnsRecord, sortedContribNNKeys []string) (*model.ProjectDnsRecord, error) { + promotedNN := sortedContribNNKeys[0] + remaining := append([]string(nil), sortedContribNNKeys[1:]...) + createdFor, ns, name, ok := parseOwnerNNIndexKey(promotedNN) + if !ok { + return nil, fmt.Errorf("invalid contributing owner key %q", promotedNN) + } + kind := resourceKindFromCreatedForTag(createdFor) + if kind == "" { + return nil, fmt.Errorf("unknown created-for tag in contributing owner key %q", promotedNN) + } + newOwner := &ResourceRef{Kind: kind, Object: &metav1.ObjectMeta{Namespace: ns, Name: name}} + out := *rec + out.Tags = appendRecordOwnershipTags(s.tagsForOwner(newOwner), gatewayIndexTagFromRecord(rec), formatContributingOwnersTag(remaining)) + out.MarkedForDelete = nil + return &out, nil +} + +// recordAfterContributingRemoval removes deletedNNKey from the contributing tag; returns (updatedRecord, changed). +func recordAfterContributingRemoval(rec *model.ProjectDnsRecord, deletedNNKey string) (*model.ProjectDnsRecord, bool) { + keys := parseContributingOwnersFromRecord(rec) + if !slices.Contains(keys, deletedNNKey) { + return nil, false + } + newContribKeys := slices.DeleteFunc(keys, func(k string) bool { return k == deletedNNKey }) + out := *rec + out.Tags = replaceContributingOwnersInTags(rec.Tags, newContribKeys) + out.MarkedForDelete = nil + return &out, true +} + +func dedupeRecordsByPath(recs []*model.ProjectDnsRecord) []*model.ProjectDnsRecord { + seen := make(map[string]*model.ProjectDnsRecord) + for _, r := range recs { + if r == nil || r.Path == nil { + continue + } + p := strings.TrimSpace(*r.Path) + if p == "" { + continue + } + seen[p] = r + } + out := make([]*model.ProjectDnsRecord, 0, len(seen)) + for _, r := range seen { + out = append(out, r) + } + return out +} + +// ListRecordOwnerResource returns owner namespaced names grouped by ResourceRef.Kind from the in-memory DNS record store. +func (s *DNSRecordService) ListRecordOwnerResource() map[string]sets.Set[types.NamespacedName] { + return s.DNSRecordStore.GroupRecordsByResourceKind() +} + +// ListReferredGatewayNN returns Gateway NNs referenced by store index. +func (s *DNSRecordService) ListReferredGatewayNN() sets.Set[types.NamespacedName] { + gatewaySet := sets.New[types.NamespacedName]() + for elem := range s.DNSRecordStore.ListIndexFuncValues(indexKeyDNSRecordGatewayNN) { + gwConfig := strings.Split(elem, "/") + if len(gwConfig) < 2 { + continue + } + gwNamespace, gwName := gwConfig[0], gwConfig[1] + gatewaySet.Insert(types.NamespacedName{Namespace: gwNamespace, Name: gwName}) + } + return gatewaySet +} + +// DeleteRecordsForOwnerOutsideAllowedZones deletes primary-owner DNS records whose zone_path is not in allowedZonePaths. +func (s *DNSRecordService) DeleteRecordsForOwnerOutsideAllowedZones(ctx context.Context, kind, namespace, name string, allowedZonePaths sets.Set[string]) (bool, error) { + if allowedZonePaths == nil { + allowedZonePaths = sets.New[string]() + } + // Only delete the ProjectDnsRecord which is owned by the kind/namespace/name; the contributed records are + // deleted in the reconciliation by the record owner. + owned := s.DNSRecordStore.GetByOwnerResourceNamespacedName(kind, namespace, name) + var toDelete []*model.ProjectDnsRecord + for _, rec := range owned { + // rec.ZonePath is not nil which is guarded when creating the ProjectDnsRecord. + zp := strings.TrimSpace(*rec.ZonePath) + if allowedZonePaths.Has(zp) { + continue + } + cp := *rec + cp.MarkedForDelete = common.Bool(true) + toDelete = append(toDelete, &cp) + } + if len(toDelete) == 0 { + log.Debug("No out-of-zone DNS records to delete", "kind", kind, "namespace", namespace, "name", name) + return false, nil + } + log.Info("Deleting DNS records outside allowed zones", "kind", kind, "namespace", namespace, "name", name, + "toDelete", len(toDelete), "allowedZones", allowedZonePaths.Len()) + + toApply, syncErr := s.syncProjectDnsRecordsInNSX(ctx, nil, toDelete) + if len(toApply) > 0 { + if applyErr := s.DNSRecordStore.Apply(toApply); applyErr != nil { + return false, applyErr + } + } + return len(toApply) > 0, syncErr +} + +func getCluster(s *DNSRecordService) string { + return s.NSXConfig.Cluster +} + +// parseProjectDNSRecordPolicyPath splits a Policy ProjectDnsRecord path into org, project, and record id. +func parseProjectDNSRecordPolicyPath(path string) (orgID, projectID, recordID string, err error) { + p := strings.TrimSpace(path) + if p == "" { + return "", "", "", fmt.Errorf("empty ProjectDnsRecord path") + } + matches := projectDNSRecordPathRe.FindStringSubmatch(p) + if len(matches) != 4 { + return "", "", "", fmt.Errorf("invalid ProjectDnsRecord path %q: expected /orgs/{org}/projects/{project}/dns-records/{record-id}", path) + } + orgID, projectID, recordID = matches[1], matches[2], matches[3] + if strings.TrimSpace(recordID) == "" { + return "", "", "", fmt.Errorf("empty record id in ProjectDnsRecord path %q", path) + } + return orgID, projectID, recordID, nil +} + +func (s *DNSRecordService) deleteProjectDnsRecordOnNSX(live *model.ProjectDnsRecord) error { + orgID, projectID, recordID, err := parseProjectDNSRecordPolicyPath(*live.Path) + if err != nil { + return err + } + log.Info("Deleting ProjectDnsRecord from NSX", "Id", recordID) + err = s.NSXClient.DnsRecordsClient.Delete(orgID, projectID, recordID) + return nsxutil.TransNSXApiError(err) +} diff --git a/pkg/nsx/services/dns/recordservice_test.go b/pkg/nsx/services/dns/recordservice_test.go new file mode 100644 index 000000000..980f7d513 --- /dev/null +++ b/pkg/nsx/services/dns/recordservice_test.go @@ -0,0 +1,1329 @@ +/* Copyright © 2026 Broadcom, Inc. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 */ + +package dns + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/vmware/vsphere-automation-sdk-go/runtime/data" + "github.com/vmware/vsphere-automation-sdk-go/services/nsxt/model" + "go.uber.org/mock/gomock" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/vmware-tanzu/nsx-operator/pkg/apis/vpc/v1alpha1" + "github.com/vmware-tanzu/nsx-operator/pkg/config" + pkgmock "github.com/vmware-tanzu/nsx-operator/pkg/mock" + dnsrecmocks "github.com/vmware-tanzu/nsx-operator/pkg/mock/dnsrecordsclient" + dnszonemocks "github.com/vmware-tanzu/nsx-operator/pkg/mock/dnszonesclient" + orgrootmocks "github.com/vmware-tanzu/nsx-operator/pkg/mock/orgrootclient" + realizedmocks "github.com/vmware-tanzu/nsx-operator/pkg/mock/realizedentitiesclient" + searchmocks "github.com/vmware-tanzu/nsx-operator/pkg/mock/searchclient" + "github.com/vmware-tanzu/nsx-operator/pkg/nsx" + servicecommon "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" + extdns "github.com/vmware-tanzu/nsx-operator/pkg/third_party/externaldns/endpoint" +) + +var _ servicecommon.VPCServiceProvider = (*pkgmock.MockVPCServiceProvider)(nil) + +// Policy DNS zone paths must match projectDNSZonePathRe in zones.go: +const ( + testDNSZonePathT = "/orgs/org1/projects/proj1/dns-services/ds1/zones/zone-t" + testDNSZonePathGw = "/orgs/org1/projects/proj1/dns-services/ds1/zones/zone-gw" + testDNSZonePathZ = "/orgs/org1/projects/proj1/dns-services/ds1/zones/zone-z" + testDNSZonePathShared = "/orgs/org1/projects/proj1/dns-services/ds1/zones/zone-shared" + testDNSZonePathDemo = "/orgs/org1/projects/proj1/dns-services/ds1/zones/zone-demo" + testDNSZonePathOld = "/orgs/org1/projects/proj1/dns-services/ds1/zones/zone-old" + testDNSZonePathKeep = "/orgs/org1/projects/proj1/dns-services/ds1/zones/zone-keep" + testDNSZonePathOther = "/orgs/org1/projects/proj1/dns-services/ds1/zones/zone-other" +) + +func testVPCNetworkConfiguration() *v1alpha1.VPCNetworkConfiguration { + return &v1alpha1.VPCNetworkConfiguration{ + Spec: v1alpha1.VPCNetworkConfigurationSpec{ + DNSZones: []string{testDNSZonePathT}, + }, + Status: v1alpha1.VPCNetworkConfigurationStatus{ + VPCs: []v1alpha1.VPCInfo{{ + VPCPath: "/orgs/org1/projects/proj1/vpcs/vpc1", + }}, + }, + } +} + +// testDNSZoneMapForVPCFixture maps Spec.DNSZones paths to delegated DNS domain names (avoids live NSX Get in tests). +func testDNSZoneMapForVPCFixture() map[string]string { + return map[string]string{ + testDNSZonePathT: "example.com", + testDNSZonePathGw: "example.com", + testDNSZonePathZ: "example.com", + testDNSZonePathShared: "example.com", + testDNSZonePathDemo: "demo.example.com", + testDNSZonePathOld: "example.com", + testDNSZonePathKeep: "example.com", + testDNSZonePathOther: "example.com", + } +} + +// testDNSSvc bundles a DNSRecordService with the NSX mock's body-registry so each test +// is hermetic: no package-level state is shared between test cases. +type testDNSSvc struct { + *DNSRecordService + bodies map[string]*model.ProjectDnsRecord +} + +// registerBodiesForBatch pre-populates the mock Get registry with the records that +// would be returned by NSX after a successful PATCH for each row in batch. +func registerBodiesForBatch(t *testing.T, env *testDNSSvc, batch *AggregatedDNSEndpoints) { + t.Helper() + if batch == nil || batch.Owner == nil { + return + } + for _, row := range batch.Rows { + rec := env.BuildProjectDnsRecord(batch.Owner, row) + if rec == nil || rec.Id == nil { + continue + } + cp := *rec + env.bodies[*rec.Id] = &cp + } +} + +func recordStoreKey(zonePath, recordName, recordType string) string { + _, path, _ := getRecordIDAndPathAndType(recordName, recordType, zonePath) + return path +} + +func newTestNSXClient(t *testing.T, bodies map[string]*model.ProjectDnsRecord) *nsx.Client { + t.Helper() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + orgRoot := orgrootmocks.NewMockOrgRootClient(ctrl) + orgRoot.EXPECT().Patch(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + realized := realizedmocks.NewMockRealizedEntitiesClient(ctrl) + st := model.GenericPolicyRealizedResource_STATE_REALIZED + realized.EXPECT().List(gomock.Any(), gomock.Any()).Return(model.GenericPolicyRealizedResourceListResult{ + Results: []model.GenericPolicyRealizedResource{{State: &st}}, + }, nil).AnyTimes() + + dnsRec := dnsrecmocks.NewMockDnsRecordsClient(ctrl) + dnsRec.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(orgID, projectID, recordID string) (model.ProjectDnsRecord, error) { + p := fmt.Sprintf("/orgs/%s/projects/%s/%s/%s", orgID, projectID, DNSRecordPathSegment, recordID) + if d := bodies[recordID]; d != nil { + out := *d + out.Id = &recordID + out.Path = &p + return out, nil + } + rid := recordID + return model.ProjectDnsRecord{Id: &rid, Path: &p}, nil + }).AnyTimes() + dnsRec.EXPECT().Delete(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + return &nsx.Client{ + OrgRootClient: orgRoot, + RealizedEntitiesClient: realized, + DnsRecordsClient: dnsRec, + } +} + +func newTestDNSRecordService(t *testing.T, store *RecordStore) *testDNSSvc { + t.Helper() + bodies := make(map[string]*model.ProjectDnsRecord) + fc := fake.NewClientBuilder().WithScheme(clientgoscheme.Scheme).Build() + builder, err := servicecommon.PolicyPathProjectDnsRecord.NewPolicyTreeBuilder() + require.NoError(t, err) + svc := &DNSRecordService{ + Service: servicecommon.Service{ + Client: fc, + NSXConfig: &config.NSXOperatorConfig{CoeConfig: &config.CoeConfig{Cluster: "unit-test"}}, + NSXClient: newTestNSXClient(t, bodies), + }, + DNSRecordStore: store, + ProjectDnsRecordBuilder: builder, + } + return &testDNSSvc{DNSRecordService: svc, bodies: bodies} +} + +func requireNoErrCreateDNS(ctx context.Context, t *testing.T, env *testDNSSvc, batch *AggregatedDNSEndpoints) { + t.Helper() + registerBodiesForBatch(t, env, batch) + _, err := env.CreateOrUpdateRecords(ctx, batch) + require.NoError(t, err) +} + +func requireNoErrDeleteDNS(ctx context.Context, t *testing.T, svc *DNSRecordService, kind, ns, name string) { + t.Helper() + _, err := svc.DeleteRecordByOwnerNN(ctx, kind, ns, name) + require.NoError(t, err) +} + +func TestCreateOrUpdateRecords_ownerScopedEmptyRowsPrunes(t *testing.T) { + ctx := context.Background() + store := BuildDNSRecordStore() + env := newTestDNSRecordService(t, store) + owner := &ResourceRef{ + Kind: ResourceKindGateway, + Object: &metav1.ObjectMeta{Namespace: "ns", Name: "gw", UID: types.UID("gwuid")}, + } + ep := extdns.NewEndpoint("gw.example.com", extdns.RecordTypeA, "192.0.2.1") + ep.WithLabel(EndpointLabelParentGateway, "ns/gw") + row := EndpointRow{Endpoint: ep, zonePath: testDNSZonePathGw, nsxRecordName: "gw"} + + requireNoErrCreateDNS(ctx, t, env, NewOwnerScopedAggregatedRouteDNS(owner, []EndpointRow{row})) + key := recordStoreKey(row.zonePath, row.nsxRecordName, row.Endpoint.RecordType) + require.NotNil(t, store.GetByKey(key)) + + requireNoErrCreateDNS(ctx, t, env, &AggregatedDNSEndpoints{ + Namespace: "ns", + Owner: owner, + Rows: nil, + }) + require.Nil(t, store.GetByKey(key)) +} + +func TestValidateEndpointsByZone_table(t *testing.T) { + intPtr := func(n int) *int { return &n } + ep := extdns.NewEndpoint("svc.example.com", extdns.RecordTypeA, "10.0.0.1") + ep.WithLabel(EndpointLabelParentGateway, "ns/gw") + tableOwner := func(ns string) *ResourceRef { + return &ResourceRef{ + Kind: ResourceKindGateway, + Object: &metav1.ObjectMeta{Namespace: ns, Name: "gw-table", UID: types.UID("zone-table")}, + } + } + + baseSvc := func(vpc servicecommon.VPCServiceProvider) *DNSRecordService { + return &DNSRecordService{ + Service: servicecommon.Service{ + NSXConfig: &config.NSXOperatorConfig{CoeConfig: &config.CoeConfig{Cluster: "unit-test"}}, + }, + VPCService: vpc, + DNSZoneMap: NewDNSZoneCacheFromMap(testDNSZoneMapForVPCFixture()), + DNSRecordStore: BuildDNSRecordStore(), + } + } + buildVPC := func(t *testing.T, nc *v1alpha1.VPCNetworkConfiguration, retErr error) servicecommon.VPCServiceProvider { + m := &pkgmock.MockVPCServiceProvider{} + m.On("GetVPCNetworkConfigByNamespace", mock.AnythingOfType("string")).Return(nc, retErr).Once() + t.Cleanup(func() { m.AssertExpectations(t) }) + return m + } + + ncWithoutDNSZones := testVPCNetworkConfiguration() + ncWithoutDNSZones.Spec.DNSZones = nil + + tests := []struct { + name string + nc *v1alpha1.VPCNetworkConfiguration + vpcErr error + buildOwner func(ns string) *ResourceRef // nil → tableOwner(ns) + ns string + eps []*extdns.Endpoint + wantZone string + errSub string + wantZoneValErr bool // error must be *DNSZoneValidationError + wantAllowedOnErr map[string]string // non-nil: allowedZones must equal this on error + wantN *int // when non-nil, assert len(rows)==*wantN instead of len(eps) + }{ + { + name: "happy_path", + nc: testVPCNetworkConfiguration(), + ns: "tenant", eps: []*extdns.Endpoint{ep}, wantZone: testDNSZonePathT, + wantN: intPtr(1), + }, + { + name: "no_DNS_zones_in_VPC_spec", + nc: ncWithoutDNSZones, + ns: "n", eps: []*extdns.Endpoint{ep}, errSub: "no DNS zones are permitted for the namespace", + wantZoneValErr: true, + }, + { + name: "GetVPCNetworkConfigByNamespace_error", + nc: nil, + vpcErr: errors.New("vpc down"), + ns: "n", eps: []*extdns.Endpoint{ep}, errSub: "failed to find VPCNetworkConfiguration", + }, + { + name: "wildcard_dns_name_skipped", + nc: testVPCNetworkConfiguration(), + ns: "tenant", + eps: []*extdns.Endpoint{extdns.NewEndpoint("*.apps.example.com", extdns.RecordTypeA, "10.0.0.1")}, + wantN: intPtr(0), + }, + { + name: "wildcard_skipped_concrete_hostname_kept", + nc: testVPCNetworkConfiguration(), + ns: "tenant", + eps: []*extdns.Endpoint{ + extdns.NewEndpoint("*.apps.example.com", extdns.RecordTypeA, "10.0.0.2"), + ep, + }, + wantZone: testDNSZonePathT, + wantN: intPtr(1), + }, + { + name: "unsupported_owner_kind_returns_validation_error", + nc: testVPCNetworkConfiguration(), + buildOwner: func(ns string) *ResourceRef { + return &ResourceRef{Kind: "UnknownKind", Object: &metav1.ObjectMeta{Namespace: ns, Name: "x"}} + }, + ns: "tenant", + eps: []*extdns.Endpoint{ep}, + errSub: "unsupported resource kind", + wantZoneValErr: true, + wantAllowedOnErr: map[string]string{testDNSZonePathT: "example.com"}, + wantN: intPtr(0), + }, + { + // hostname does not lie under any allowed zone → must be *DNSZoneValidationError + // and allowedZones must still be returned so the controller can clean up stale records. + name: "hostname_not_in_zone_is_DNSZoneValidationError_with_allowedZones", + nc: testVPCNetworkConfiguration(), // zone = example.com + ns: "tenant", + eps: []*extdns.Endpoint{extdns.NewEndpoint("svc.other.example", extdns.RecordTypeA, "10.0.0.1")}, + errSub: "does not match any allowed DNS domain", + wantZoneValErr: true, + wantAllowedOnErr: map[string]string{testDNSZonePathT: "example.com"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := baseSvc(buildVPC(t, tt.nc, tt.vpcErr)) + owner := tableOwner(tt.ns) + if tt.buildOwner != nil { + owner = tt.buildOwner(tt.ns) + } + rows, allowed, err := svc.ValidateEndpointsByZone(tt.ns, owner, tt.eps) + if tt.errSub != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errSub) + if tt.wantZoneValErr { + var zve *DNSZoneValidationError + require.ErrorAs(t, err, &zve, "expected *DNSZoneValidationError") + } + if tt.wantAllowedOnErr != nil { + require.Equal(t, tt.wantAllowedOnErr, allowed, "allowedZones must be returned even on error") + } + return + } + require.NoError(t, err) + require.Equal(t, map[string]string{testDNSZonePathT: "example.com"}, allowed) + if tt.wantN != nil { + require.Len(t, rows, *tt.wantN) + if *tt.wantN > 0 { + require.Equal(t, tt.wantZone, rows[0].zonePath) + require.Equal(t, "svc.example.com", rows[0].DNSName) + } + return + } + require.Len(t, rows, len(tt.eps)) + require.Equal(t, tt.wantZone, rows[0].zonePath) + }) + } +} + +func TestDNSRecordService_deletesAndQueries_table(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + run func(t *testing.T, env *testDNSSvc) + }{ + { + name: "DeleteRecordByOwnerNN_gateway_removes_row_and_gateway_index", + run: func(t *testing.T, env *testDNSSvc) { + owner := &ResourceRef{ + Kind: ResourceKindGateway, + Object: &metav1.ObjectMeta{Namespace: "app", Name: "gw1", UID: types.UID("g1")}, + } + z := testDNSZonePathZ + ep := extdns.NewEndpoint("gw.example.com", extdns.RecordTypeA, "10.0.0.1") + ep.WithLabel(EndpointLabelParentGateway, "app/gw1") + row := EndpointRow{Endpoint: ep, zonePath: z, nsxRecordName: "gw.example.com"} + require.NotNil(t, env.BuildProjectDnsRecord(owner, row)) + requireNoErrCreateDNS(ctx, t, env, NewOwnerScopedAggregatedRouteDNS(owner, []EndpointRow{row})) + key := recordStoreKey(row.zonePath, row.nsxRecordName, row.Endpoint.RecordType) + require.NotNil(t, env.DNSRecordStore.GetByKey(key)) + requireNoErrDeleteDNS(ctx, t, env.DNSRecordService, ResourceKindGateway, "app", "gw1") + require.Nil(t, env.DNSRecordStore.GetByKey(key)) + }, + }, + { + name: "DeleteRecordByOwnerNN_gateway_does_not_remove_HTTPRoute_rows", + run: func(t *testing.T, env *testDNSSvc) { + gwOwner := &ResourceRef{ + Kind: ResourceKindGateway, + Object: &metav1.ObjectMeta{Namespace: "app", Name: "gw1", UID: types.UID("g1")}, + } + z := testDNSZonePathZ + epG := extdns.NewEndpoint("gw.example.com", extdns.RecordTypeA, "10.0.0.1") + epG.WithLabel(EndpointLabelParentGateway, "app/gw1") + rowG := EndpointRow{Endpoint: epG, zonePath: z, nsxRecordName: "gw.example.com"} + require.NotNil(t, env.BuildProjectDnsRecord(gwOwner, rowG)) + requireNoErrCreateDNS(ctx, t, env, NewOwnerScopedAggregatedRouteDNS(gwOwner, []EndpointRow{rowG})) + hrEp := extdns.NewEndpoint("r.example.com", extdns.RecordTypeA, "10.0.0.2") + hrEp.WithLabel(EndpointLabelParentGateway, "app/gw1") + hrRow := NewEndpointRow(hrEp, z, "r.example.com") + hrOwner := &ResourceRef{ + Kind: ResourceKindHTTPRoute, + Object: &metav1.ObjectMeta{Namespace: "app", Name: "hr1", UID: types.UID("u-hr")}, + } + require.NotNil(t, env.BuildProjectDnsRecord(hrOwner, *hrRow)) + requireNoErrCreateDNS(ctx, t, env, NewOwnerScopedAggregatedRouteDNS(hrOwner, []EndpointRow{*hrRow})) + requireNoErrDeleteDNS(ctx, t, env.DNSRecordService, ResourceKindGateway, "app", "gw1") + require.Nil(t, env.DNSRecordStore.GetByKey(recordStoreKey(rowG.zonePath, rowG.nsxRecordName, rowG.Endpoint.RecordType))) + require.NotNil(t, env.DNSRecordStore.GetByKey(recordStoreKey(hrRow.zonePath, hrRow.nsxRecordName, hrRow.Endpoint.RecordType))) + }, + }, + { + name: "ListRecordOwnerResource_maps_HTTPRoute_owner", + run: func(t *testing.T, env *testDNSSvc) { + ns := "demo" + demoEp := extdns.NewEndpoint("svc.demo.example.com", extdns.RecordTypeA, "192.0.2.10") + demoEp.WithLabel(EndpointLabelParentGateway, "demo/gw1") + row := NewEndpointRow(demoEp, testDNSZonePathDemo, "svc.demo.example.com") + hrOwner := &ResourceRef{ + Kind: ResourceKindHTTPRoute, + Object: &metav1.ObjectMeta{Namespace: ns, Name: "hr1", UID: types.UID("uid-hr1")}, + } + require.NotNil(t, env.BuildProjectDnsRecord(hrOwner, *row)) + requireNoErrCreateDNS(ctx, t, env, NewOwnerScopedAggregatedRouteDNS(hrOwner, []EndpointRow{*row})) + require.True(t, env.ListReferredGatewayNN().Has(types.NamespacedName{Namespace: "demo", Name: "gw1"})) + groups := env.ListRecordOwnerResource() + nn := types.NamespacedName{Namespace: ns, Name: "hr1"} + require.True(t, groups[ResourceKindHTTPRoute].Has(nn), "got map %#v", groups) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := newTestDNSRecordService(t, BuildDNSRecordStore()) + tt.run(t, env) + }) + } +} + +// dnsRecordStructValue converts rec to a *data.StructValue for use in mock SearchResponse.Results. +func dnsRecordStructValue(t *testing.T, rec model.ProjectDnsRecord) *data.StructValue { + t.Helper() + dv, errs := servicecommon.NewConverter().ConvertToVapi(rec, model.ProjectDnsRecordBindingType()) + require.Empty(t, errs, "ConvertToVapi failed") + sv, ok := dv.(*data.StructValue) + require.True(t, ok, "expected *data.StructValue from ConvertToVapi") + return sv +} + +func TestInitializeDNSRecordService_table(t *testing.T) { + cfg := &config.NSXOperatorConfig{CoeConfig: &config.CoeConfig{Cluster: "unit-test"}} + fc := fake.NewClientBuilder().WithScheme(clientgoscheme.Scheme).Build() + vpcM := &pkgmock.MockVPCServiceProvider{} + + // A minimal record whose ZonePath falls into testDNSZonePathT. + recWithZone := model.ProjectDnsRecord{ + Id: servicecommon.String("rec-warmup"), + Path: servicecommon.String("/orgs/org1/projects/proj1/dns-records/rec-warmup"), + ZonePath: servicecommon.String(testDNSZonePathT), + } + + tests := []struct { + name string + setupMock func(t *testing.T, ctrl *gomock.Controller) *nsx.Client + wantErr bool + checkSvc func(t *testing.T, svc *DNSRecordService) + }{ + { + name: "store_search_error_propagates", + setupMock: func(t *testing.T, ctrl *gomock.Controller) *nsx.Client { + qc := searchmocks.NewMockQueryClient(ctrl) + qc.EXPECT().List(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(model.SearchResponse{}, errors.New("nsx search down")).Times(1) + return &nsx.Client{QueryClient: qc, NsxConfig: cfg} + }, + wantErr: true, + }, + { + name: "empty_store_no_zone_warmup", + setupMock: func(t *testing.T, ctrl *gomock.Controller) *nsx.Client { + qc := searchmocks.NewMockQueryClient(ctrl) + rc := int64(0) + qc.EXPECT().List(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(model.SearchResponse{Results: nil, Cursor: nil, ResultCount: &rc}, nil).Times(1) + // ProjectDnsZoneClient.Get must NOT be called: no records, no warm-up. + zc := dnszonemocks.NewMockZonesClient(ctrl) + return &nsx.Client{QueryClient: qc, ProjectDnsZoneClient: zc, NsxConfig: cfg} + }, + checkSvc: func(t *testing.T, svc *DNSRecordService) { + require.NotNil(t, svc.DNSRecordStore) + require.NotNil(t, svc.VPCService) + require.NotNil(t, svc.ProjectDnsRecordBuilder) + require.NotNil(t, svc.DNSZoneMap) + _, found := svc.DNSZoneMap.get(testDNSZonePathT) + require.False(t, found, "zone map must be empty when store is empty") + }, + }, + { + name: "warmup_zone_domain_populated", + setupMock: func(t *testing.T, ctrl *gomock.Controller) *nsx.Client { + sv := dnsRecordStructValue(t, recWithZone) + rc := int64(1) + qc := searchmocks.NewMockQueryClient(ctrl) + qc.EXPECT().List(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(model.SearchResponse{Results: []*data.StructValue{sv}, Cursor: nil, ResultCount: &rc}, nil).Times(1) + domain := "example.com" + zc := dnszonemocks.NewMockZonesClient(ctrl) + zc.EXPECT().Get("org1", "proj1", "ds1", "zone-t").Return(model.ProjectDnsZone{DnsDomainName: &domain}, nil).Times(1) + return &nsx.Client{QueryClient: qc, ProjectDnsZoneClient: zc, NsxConfig: cfg} + }, + checkSvc: func(t *testing.T, svc *DNSRecordService) { + got, found := svc.DNSZoneMap.get(testDNSZonePathT) + require.True(t, found, "zone path must be in DNSZoneMap after successful warm-up") + require.Equal(t, "example.com", got) + }, + }, + { + name: "warmup_zone_fetch_error_nonfatal", + setupMock: func(t *testing.T, ctrl *gomock.Controller) *nsx.Client { + sv := dnsRecordStructValue(t, recWithZone) + rc := int64(1) + qc := searchmocks.NewMockQueryClient(ctrl) + qc.EXPECT().List(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(model.SearchResponse{Results: []*data.StructValue{sv}, Cursor: nil, ResultCount: &rc}, nil).Times(1) + zc := dnszonemocks.NewMockZonesClient(ctrl) + zc.EXPECT().Get("org1", "proj1", "ds1", "zone-t").Return(model.ProjectDnsZone{}, errors.New("nsx zone unavailable")).Times(1) + return &nsx.Client{QueryClient: qc, ProjectDnsZoneClient: zc, NsxConfig: cfg} + }, + checkSvc: func(t *testing.T, svc *DNSRecordService) { + _, found := svc.DNSZoneMap.get(testDNSZonePathT) + require.False(t, found, "zone map must not be populated when zone fetch errors") + }, + }, + { + name: "warmup_nil_domain_name_not_cached", + setupMock: func(t *testing.T, ctrl *gomock.Controller) *nsx.Client { + sv := dnsRecordStructValue(t, recWithZone) + rc := int64(1) + qc := searchmocks.NewMockQueryClient(ctrl) + qc.EXPECT().List(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(model.SearchResponse{Results: []*data.StructValue{sv}, Cursor: nil, ResultCount: &rc}, nil).Times(1) + zc := dnszonemocks.NewMockZonesClient(ctrl) + zc.EXPECT().Get("org1", "proj1", "ds1", "zone-t").Return(model.ProjectDnsZone{DnsDomainName: nil}, nil).Times(1) + return &nsx.Client{QueryClient: qc, ProjectDnsZoneClient: zc, NsxConfig: cfg} + }, + checkSvc: func(t *testing.T, svc *DNSRecordService) { + _, found := svc.DNSZoneMap.get(testDNSZonePathT) + require.False(t, found, "zone map must not be populated when DnsDomainName is nil") + }, + }, + { + name: "warmup_dedup_same_zone_path_get_once", + setupMock: func(t *testing.T, ctrl *gomock.Controller) *nsx.Client { + rec2 := model.ProjectDnsRecord{ + Id: servicecommon.String("rec-warmup-2"), + Path: servicecommon.String("/orgs/org1/projects/proj1/dns-records/rec-warmup-2"), + ZonePath: servicecommon.String(testDNSZonePathT), + } + sv1 := dnsRecordStructValue(t, recWithZone) + sv2 := dnsRecordStructValue(t, rec2) + rc := int64(2) + qc := searchmocks.NewMockQueryClient(ctrl) + qc.EXPECT().List(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(model.SearchResponse{Results: []*data.StructValue{sv1, sv2}, Cursor: nil, ResultCount: &rc}, nil).Times(1) + domain := "example.com" + // Even though two records share the same zone path, Get must be called exactly once. + zc := dnszonemocks.NewMockZonesClient(ctrl) + zc.EXPECT().Get("org1", "proj1", "ds1", "zone-t").Return(model.ProjectDnsZone{DnsDomainName: &domain}, nil).Times(1) + return &nsx.Client{QueryClient: qc, ProjectDnsZoneClient: zc, NsxConfig: cfg} + }, + checkSvc: func(t *testing.T, svc *DNSRecordService) { + got, found := svc.DNSZoneMap.get(testDNSZonePathT) + require.True(t, found) + require.Equal(t, "example.com", got) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + svc, err := InitializeDNSRecordService(servicecommon.Service{ + Client: fc, + NSXConfig: cfg, + NSXClient: tt.setupMock(t, ctrl), + }, vpcM) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, svc) + if tt.checkSvc != nil { + tt.checkSvc(t, svc) + } + }) + } +} + +func TestCreateOrUpdateRecords_ownerScoped_mergeUpdatesTargets(t *testing.T) { + ctx := context.Background() + store := BuildDNSRecordStore() + env := newTestDNSRecordService(t, store) + owner := &ResourceRef{ + Kind: ResourceKindGateway, + Object: &metav1.ObjectMeta{Namespace: "ns", Name: "gw", UID: types.UID("gwuid")}, + } + z := testDNSZonePathZ + recordName := "x.example.com" + ep0 := extdns.NewEndpoint("x.example.com", extdns.RecordTypeA, "10.0.0.1") + ep0.WithLabel(EndpointLabelParentGateway, "ns/gw") + require.NotNil(t, env.BuildProjectDnsRecord(owner, EndpointRow{Endpoint: ep0, zonePath: z, nsxRecordName: recordName})) + for _, targets := range [][]string{{"10.0.0.1"}, {"10.0.0.2"}} { + ep := extdns.NewEndpoint("x.example.com", extdns.RecordTypeA, targets...) + ep.WithLabel(EndpointLabelParentGateway, "ns/gw") + row := EndpointRow{Endpoint: ep, zonePath: z, nsxRecordName: recordName} + requireNoErrCreateDNS(ctx, t, env, NewOwnerScopedAggregatedRouteDNS(owner, []EndpointRow{row})) + } + rec := store.GetByKey(recordStoreKey(testDNSZonePathZ, recordName, string(extdns.RecordTypeA))) + require.NotNil(t, rec) + vals := append([]string(nil), rec.RecordValues...) + require.Equal(t, []string{"10.0.0.2"}, vals) +} + +func Test_CreateOrUpdateRecords_Service_DeleteByOwnerNN(t *testing.T) { + ctx := context.Background() + store := BuildDNSRecordStore() + env := newTestDNSRecordService(t, store) + meta := metav1.ObjectMeta{Name: "lbsvc", Namespace: "ns1", UID: types.UID("svc-uid-1")} + owner := &ResourceRef{Kind: ResourceKindService, Object: &meta} + z := testDNSZonePathZ + ep := extdns.NewEndpoint("x.example.com", extdns.RecordTypeA, "10.0.0.1") + ep.WithLabel(EndpointLabelParentGateway, "ns1/lbsvc") + row := EndpointRow{Endpoint: ep, zonePath: z, nsxRecordName: "x.example.com"} + require.NotNil(t, env.BuildProjectDnsRecord(owner, row)) + requireNoErrCreateDNS(ctx, t, env, NewOwnerScopedAggregatedRouteDNS(owner, []EndpointRow{row})) + require.Len(t, env.DNSRecordStore.GetByOwnerResourceNamespacedName(ResourceKindService, "ns1", "lbsvc"), 1) + requireNoErrDeleteDNS(ctx, t, env.DNSRecordService, ResourceKindService, "ns1", "lbsvc") + assert.Empty(t, env.DNSRecordStore.GetByOwnerResourceNamespacedName(ResourceKindService, "ns1", "lbsvc")) +} + +func TestDeleteRecordsForOwnerOutsideAllowedZones(t *testing.T) { + ctx := context.Background() + env := newTestDNSRecordService(t, BuildDNSRecordStore()) + owner := &ResourceRef{Kind: ResourceKindGateway, Object: &metav1.ObjectMeta{Namespace: "ns", Name: "gw", UID: types.UID("u1")}} + zOld := testDNSZonePathOld + zKeep := testDNSZonePathKeep + ep1 := extdns.NewEndpoint("a.example.com", extdns.RecordTypeA, "10.0.0.1") + ep1.WithLabel(EndpointLabelParentGateway, "ns/gw") + rowOut := EndpointRow{Endpoint: ep1, zonePath: zOld, nsxRecordName: "a"} + requireNoErrCreateDNS(ctx, t, env, NewOwnerScopedAggregatedRouteDNS(owner, []EndpointRow{rowOut})) + + allowed := sets.New(zKeep) + mut, err := env.DeleteRecordsForOwnerOutsideAllowedZones(ctx, ResourceKindGateway, "ns", "gw", allowed) + require.NoError(t, err) + require.True(t, mut) + assert.Empty(t, env.DNSRecordStore.GetByOwnerResourceNamespacedName(ResourceKindGateway, "ns", "gw")) + + // Add a row in the allowed zone; deleting with a disjoint allow-list should not remove it. + ep2 := extdns.NewEndpoint("b.example.com", extdns.RecordTypeA, "10.0.0.2") + ep2.WithLabel(EndpointLabelParentGateway, "ns/gw") + rowKeep := EndpointRow{Endpoint: ep2, zonePath: zKeep, nsxRecordName: "b"} + requireNoErrCreateDNS(ctx, t, env, NewOwnerScopedAggregatedRouteDNS(owner, []EndpointRow{rowKeep})) + mut2, err2 := env.DeleteRecordsForOwnerOutsideAllowedZones(ctx, ResourceKindGateway, "ns", "gw", sets.New(testDNSZonePathOther)) + require.NoError(t, err2) + require.True(t, mut2) + require.Len(t, env.DNSRecordStore.GetByOwnerResourceNamespacedName(ResourceKindGateway, "ns", "gw"), 0) +} + +func TestParseProjectDNSRecordPolicyPath_table(t *testing.T) { + tests := []struct { + name string + path string + wantOrg string + wantPrj string + wantID string + errSub string + }{ + { + name: "valid", + path: "/orgs/acme/projects/p1/dns-records/rec-a", + wantOrg: "acme", + wantPrj: "p1", + wantID: "rec-a", + }, + { + name: "missing_segment", + path: "/orgs/acme/projects/p1/projects/rec-a", + errSub: "invalid ProjectDnsRecord path", + }, + { + name: "empty_path", + path: "", + errSub: "empty ProjectDnsRecord path", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + org, prj, id, err := parseProjectDNSRecordPolicyPath(tt.path) + if tt.errSub != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errSub) + return + } + require.NoError(t, err) + require.Equal(t, tt.wantOrg, org) + require.Equal(t, tt.wantPrj, prj) + require.Equal(t, tt.wantID, id) + }) + } +} + +func TestDedupeRecordsByPath(t *testing.T) { + p := "/orgs/o/projects/p/dns-records/a" + a := &model.ProjectDnsRecord{Path: servicecommon.String(p), Id: servicecommon.String("a")} + b := &model.ProjectDnsRecord{Path: servicecommon.String(p), Id: servicecommon.String("b")} + out := dedupeRecordsByPath([]*model.ProjectDnsRecord{a, b}) + require.Len(t, out, 1) +} + +func TestDeleteProjectDnsRecordOnNSX(t *testing.T) { + env := newTestDNSRecordService(t, BuildDNSRecordStore()) + p := "/orgs/org1/projects/proj1/dns-records/rec1" + err := env.deleteProjectDnsRecordOnNSX(&model.ProjectDnsRecord{Path: &p}) + require.NoError(t, err) +} + +func TestContributingHelpers_table(t *testing.T) { + t.Run("parseContributingOwnersTag", func(t *testing.T) { + require.Nil(t, parseContributingOwnersTag("")) + got := parseContributingOwnersTag(" b/a/x , a/b/y ") + require.Equal(t, []string{"a/b/y", "b/a/x"}, got) + }) + t.Run("mergeContributingOwnerKeys", func(t *testing.T) { + primary := "p" + got := mergeContributingOwnerKeys(fmt.Sprintf("x,%s,y", primary), "z", primary) + require.Equal(t, "x,y,z", got) + }) + t.Run("parseOwnerNNIndexKey", func(t *testing.T) { + cf, ns, n, ok := parseOwnerNNIndexKey("httproute/ns1/r1") + require.True(t, ok) + require.Equal(t, servicecommon.TagValueDNSRecordForHTTPRoute, cf) + require.Equal(t, "ns1", ns) + require.Equal(t, "r1", n) + }) + t.Run("replaceContributingOwnersInTags", func(t *testing.T) { + tags := []model.Tag{ + modelTag(servicecommon.TagScopeDNSRecordContributingOwners, "old"), + modelTag(servicecommon.TagScopeCluster, "c"), + } + out := replaceContributingOwnersInTags(tags, []string{"k1", "k2"}) + require.Len(t, out, 2) + require.ElementsMatch(t, []model.Tag{ + modelTag(servicecommon.TagScopeCluster, "c"), + modelTag(servicecommon.TagScopeDNSRecordContributingOwners, "k1,k2"), + }, out) + }) +} + +func TestAppendRecordOwnershipTags_table(t *testing.T) { + clusterTag := modelTag(servicecommon.TagScopeCluster, "cls") + baseTags := []model.Tag{clusterTag} + + tests := []struct { + name string + gwKey string + contributingKeys string + wantScopes []string + }{ + { + name: "no gateway no contributing", + wantScopes: []string{servicecommon.TagScopeCluster}, + }, + { + name: "gateway only", + gwKey: "ns1/gw1", + wantScopes: []string{servicecommon.TagScopeCluster, servicecommon.TagScopeDNSRecordGatewayIndexList}, + }, + { + name: "contributing only", + contributingKeys: "http_route/ns/r1", + wantScopes: []string{servicecommon.TagScopeCluster, servicecommon.TagScopeDNSRecordContributingOwners}, + }, + { + name: "gateway and contributing", + gwKey: "ns1/gw1", + contributingKeys: "http_route/ns/r1,http_route/ns/r2", + wantScopes: []string{servicecommon.TagScopeCluster, servicecommon.TagScopeDNSRecordGatewayIndexList, servicecommon.TagScopeDNSRecordContributingOwners}, + }, + { + name: "whitespace-only gateway is skipped", + gwKey: " ", + wantScopes: []string{servicecommon.TagScopeCluster}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // pass a copy so we can verify the original is not mutated + in := append([]model.Tag{}, baseTags...) + got := appendRecordOwnershipTags(in, tc.gwKey, tc.contributingKeys) + gotScopes := make([]string, 0, len(got)) + for _, tg := range got { + if tg.Scope != nil { + gotScopes = append(gotScopes, *tg.Scope) + } + } + require.Equal(t, tc.wantScopes, gotScopes) + // original baseTags slice must not be mutated + require.Len(t, baseTags, 1) + }) + } +} + +func TestGetNSXDnsRecordType_table(t *testing.T) { + tests := []struct { + input string + want string + }{ + {extdns.RecordTypeA, model.ProjectDnsRecord_RECORD_TYPE_A}, + {extdns.RecordTypeAAAA, model.ProjectDnsRecord_RECORD_TYPE_AAAA}, + {extdns.RecordTypeCNAME, model.ProjectDnsRecord_RECORD_TYPE_CNAME}, + {extdns.RecordTypeNS, model.ProjectDnsRecord_RECORD_TYPE_NS}, + {extdns.RecordTypePTR, model.ProjectDnsRecord_RECORD_TYPE_PTR}, + {"UNKNOWN", ""}, + {"", ""}, + } + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + require.Equal(t, tc.want, getNSXDnsRecordType(tc.input)) + }) + } +} + +func TestResourceKindFromCreatedForTag_table(t *testing.T) { + tests := []struct { + tag string + want string + }{ + {servicecommon.TagValueDNSRecordForHTTPRoute, ResourceKindHTTPRoute}, + {servicecommon.TagValueDNSRecordForGRPCRoute, ResourceKindGRPCRoute}, + {servicecommon.TagValueDNSRecordForTLSRoute, ResourceKindTLSRoute}, + {servicecommon.TagValueDNSRecordForGateway, ResourceKindGateway}, + {servicecommon.TagValueDNSRecordForService, ResourceKindService}, + {"unknown_kind", ""}, + {"", ""}, + } + for _, tc := range tests { + t.Run(tc.tag, func(t *testing.T) { + require.Equal(t, tc.want, resourceKindFromCreatedForTag(tc.tag)) + }) + } +} + +func TestSortedNormalizedTagsForCompare_table(t *testing.T) { + contrib := servicecommon.TagScopeDNSRecordContributingOwners + cluster := servicecommon.TagScopeCluster + gw := servicecommon.TagScopeDNSRecordGatewayIndexList + + tests := []struct { + name string + tags []model.Tag + wantScopes []string + wantContribVal string + }{ + { + name: "nil tags returns empty", + tags: nil, + wantScopes: []string{}, + }, + { + name: "already sorted is unchanged", + tags: []model.Tag{modelTag(cluster, "c"), modelTag(gw, "v")}, + wantScopes: []string{cluster, gw}, + }, + { + name: "unsorted tags are sorted by scope", + tags: []model.Tag{modelTag(gw, "v"), modelTag(cluster, "c"), modelTag(contrib, "a/y,b/x")}, + wantScopes: []string{cluster, contrib, gw}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := sortedNormalizedTagsForCompare(tc.tags) + scopes := make([]string, 0, len(got)) + for _, tg := range got { + if tg.Scope != nil { + scopes = append(scopes, *tg.Scope) + } + } + require.Equal(t, tc.wantScopes, scopes) + }) + } +} + +func TestRecordAfterPrimaryDeletePromotion_table(t *testing.T) { + makeOwnerRec := func(kind, ns, name, path string) *model.ProjectDnsRecord { + return &model.ProjectDnsRecord{ + Path: servicecommon.String(path), + Tags: []model.Tag{ + modelTag(servicecommon.TagScopeDNSRecordFor, resourceKindToCreatedFor(kind)), + modelTag(servicecommon.TagScopeDNSRecordOwnerNamespace, ns), + modelTag(servicecommon.TagScopeDNSRecordOwnerName, name), + }, + } + } + + keyA := dnsRecordOwnerKey(servicecommon.TagValueDNSRecordForHTTPRoute, dnsRecordOwnerNamespacedNameKey("ns", "a")) + keyB := dnsRecordOwnerKey(servicecommon.TagValueDNSRecordForHTTPRoute, dnsRecordOwnerNamespacedNameKey("ns", "b")) + storeRecA := makeOwnerRec(ResourceKindHTTPRoute, "ns", "a", "/orgs/org1/projects/proj1/dns-records/for_a") + sharedPath := servicecommon.String("/orgs/org1/projects/proj1/dns-records/shared") + + tests := []struct { + name string + sortedContribs []string + rec *model.ProjectDnsRecord + storeRecords []*model.ProjectDnsRecord + wantErr bool + wantOwnerName string + wantGateway string + wantContribs []string + }{ + { + name: "invalid contrib key format returns error", + sortedContribs: []string{"badformat"}, + rec: &model.ProjectDnsRecord{}, + wantErr: true, + }, + { + name: "unknown kind in contrib key returns error", + sortedContribs: []string{"unknown_kind/ns/name"}, + rec: &model.ProjectDnsRecord{}, + wantErr: true, + }, + { + name: "sole contributor promoted, no remaining contribs", + sortedContribs: []string{keyA}, + rec: &model.ProjectDnsRecord{Path: sharedPath}, + storeRecords: []*model.ProjectDnsRecord{storeRecA}, + wantOwnerName: "a", + wantContribs: nil, + }, + { + name: "first of two contributors promoted, second remains", + sortedContribs: []string{keyA, keyB}, + rec: &model.ProjectDnsRecord{Path: sharedPath}, + storeRecords: []*model.ProjectDnsRecord{storeRecA}, + wantOwnerName: "a", + wantContribs: []string{keyB}, + }, + { + name: "gateway tag from original record is preserved", + sortedContribs: []string{keyA}, + rec: &model.ProjectDnsRecord{ + Path: sharedPath, + Tags: []model.Tag{modelTag(servicecommon.TagScopeDNSRecordGatewayIndexList, "ns/gw1")}, + }, + storeRecords: []*model.ProjectDnsRecord{storeRecA}, + wantOwnerName: "a", + wantGateway: "ns/gw1", + wantContribs: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + store := BuildDNSRecordStore() + for _, r := range tc.storeRecords { + require.NoError(t, store.Add(r)) + } + env := newTestDNSRecordService(t, store) + + out, err := env.recordAfterPrimaryDeletePromotion(tc.rec, tc.sortedContribs) + if tc.wantErr { + require.Error(t, err) + require.Nil(t, out) + return + } + require.NoError(t, err) + require.NotNil(t, out) + require.Nil(t, out.MarkedForDelete) + require.Equal(t, tc.wantOwnerName, firstTagValue(out.Tags, servicecommon.TagScopeDNSRecordOwnerName)) + require.Equal(t, tc.wantGateway, firstTagValue(out.Tags, servicecommon.TagScopeDNSRecordGatewayIndexList)) + require.Equal(t, tc.wantContribs, parseContributingOwnersFromRecord(out)) + }) + } +} + +func TestClassifyOwnerRemoval_table(t *testing.T) { + keyA := dnsRecordOwnerKey(servicecommon.TagValueDNSRecordForHTTPRoute, dnsRecordOwnerNamespacedNameKey("ns", "a")) + keyB := dnsRecordOwnerKey(servicecommon.TagValueDNSRecordForHTTPRoute, dnsRecordOwnerNamespacedNameKey("ns", "b")) + + makeRec := func(ownerKey string, contribs []string, path string) *model.ProjectDnsRecord { + createdFor, ns, name, _ := parseOwnerNNIndexKey(ownerKey) + tags := []model.Tag{ + modelTag(servicecommon.TagScopeDNSRecordFor, createdFor), + modelTag(servicecommon.TagScopeDNSRecordOwnerNamespace, ns), + modelTag(servicecommon.TagScopeDNSRecordOwnerName, name), + } + if len(contribs) > 0 { + tags = append(tags, modelTag(servicecommon.TagScopeDNSRecordContributingOwners, formatContributingOwnersTag(contribs))) + } + return &model.ProjectDnsRecord{Path: servicecommon.String(path), Tags: tags} + } + + // Store record for the promoted owner (needed when primary is deleted with contribs) + storeRecA := &model.ProjectDnsRecord{ + Path: servicecommon.String("/orgs/org1/projects/proj1/dns-records/for_a"), + Tags: []model.Tag{ + modelTag(servicecommon.TagScopeDNSRecordFor, servicecommon.TagValueDNSRecordForHTTPRoute), + modelTag(servicecommon.TagScopeDNSRecordOwnerNamespace, "ns"), + modelTag(servicecommon.TagScopeDNSRecordOwnerName, "a"), + }, + } + + tests := []struct { + name string + rec *model.ProjectDnsRecord + deletedKey string + storeRecords []*model.ProjectDnsRecord + wantDeleteLen int + wantUpdateLen int + wantMarked bool + wantContribs []string + }{ + { + name: "primary owner, no contribs: marked for delete", + rec: makeRec(keyB, nil, "/orgs/org1/projects/proj1/dns-records/sole"), + deletedKey: keyB, + wantDeleteLen: 1, + wantMarked: true, + }, + { + name: "primary owner, has contribs: first contrib promoted", + rec: makeRec(keyB, []string{keyA}, "/orgs/org1/projects/proj1/dns-records/shared"), + deletedKey: keyB, + storeRecords: []*model.ProjectDnsRecord{storeRecA}, + wantUpdateLen: 1, + wantContribs: nil, + }, + { + name: "contributing owner: removed from contrib list", + rec: makeRec(keyB, []string{keyA}, "/orgs/org1/projects/proj1/dns-records/shared"), + deletedKey: keyA, + wantUpdateLen: 1, + wantContribs: nil, + }, + { + name: "unrelated owner: no-op", + rec: makeRec(keyB, nil, "/orgs/org1/projects/proj1/dns-records/other"), + deletedKey: keyA, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + store := BuildDNSRecordStore() + for _, r := range tc.storeRecords { + require.NoError(t, store.Add(r)) + } + env := newTestDNSRecordService(t, store) + + var toDelete, toUpdate []*model.ProjectDnsRecord + err := env.classifyOwnerRemoval(tc.rec, tc.deletedKey, &toDelete, &toUpdate) + require.NoError(t, err) + require.Len(t, toDelete, tc.wantDeleteLen) + require.Len(t, toUpdate, tc.wantUpdateLen) + if tc.wantMarked && len(toDelete) > 0 { + require.NotNil(t, toDelete[0].MarkedForDelete) + require.True(t, *toDelete[0].MarkedForDelete) + } + if len(toUpdate) > 0 { + require.Equal(t, tc.wantContribs, parseContributingOwnersFromRecord(toUpdate[0])) + } + }) + } +} + +func TestCompareRecords_toUpsertAndRemove(t *testing.T) { + z := testDNSZonePathZ + id1 := "id1" + id2 := "id2" + d1 := &model.ProjectDnsRecord{ + Id: &id1, + Path: servicecommon.String("/orgs/o/projects/p/dns-records/" + id1), + RecordName: servicecommon.String("n1"), + RecordType: servicecommon.String(model.ProjectDnsRecord_RECORD_TYPE_A), + ZonePath: &z, + RecordValues: []string{"1.1.1.1"}, + } + e1 := &model.ProjectDnsRecord{ + Id: &id1, + Path: servicecommon.String("/orgs/o/projects/p/dns-records/" + id1), + RecordName: servicecommon.String("n1"), + RecordType: servicecommon.String(model.ProjectDnsRecord_RECORD_TYPE_A), + ZonePath: &z, + RecordValues: []string{"9.9.9.9"}, + } + stale := &model.ProjectDnsRecord{ + Id: &id2, + Path: servicecommon.String("/orgs/o/projects/p/dns-records/" + id2), + RecordName: servicecommon.String("n2"), + RecordType: servicecommon.String(model.ProjectDnsRecord_RECORD_TYPE_A), + ZonePath: &z, + RecordValues: []string{"2.2.2.2"}, + } + up, rm := compareRecords([]*model.ProjectDnsRecord{d1}, []*model.ProjectDnsRecord{e1, stale}) + require.Len(t, rm, 1) + require.Equal(t, id2, *rm[0].Id) + require.Len(t, up, 1) + require.Equal(t, id1, *up[0].Id) +} + +func TestCleanupInfraResources(t *testing.T) { + ctx := context.Background() + store := BuildDNSRecordStore() + env := newTestDNSRecordService(t, store) + owner := &ResourceRef{ + Kind: ResourceKindGateway, + Object: &metav1.ObjectMeta{Namespace: "ns", Name: "gw", UID: types.UID("gwuid")}, + } + z := testDNSZonePathZ + ep := extdns.NewEndpoint("cl.example.com", extdns.RecordTypeA, "192.0.2.1") + ep.WithLabel(EndpointLabelParentGateway, "ns/gw") + row := EndpointRow{Endpoint: ep, zonePath: z, nsxRecordName: "cl.example.com"} + require.NotNil(t, env.BuildProjectDnsRecord(owner, row)) + requireNoErrCreateDNS(ctx, t, env, NewOwnerScopedAggregatedRouteDNS(owner, []EndpointRow{row})) + require.NotEmpty(t, store.ListDNSRecords()) + + err := env.CleanupInfraResources(ctx) + require.NoError(t, err) + require.Empty(t, store.ListDNSRecords()) +} + +func TestValidateEndpointRowConflict_table(t *testing.T) { + const fqdn = "svc.example.com" + zonePath := testDNSZonePathT + + ownerA := &ResourceRef{Kind: ResourceKindService, Object: &metav1.ObjectMeta{Namespace: "ns", Name: "svcA"}} + ownerB := &ResourceRef{Kind: ResourceKindService, Object: &metav1.ObjectMeta{Namespace: "ns", Name: "svcB"}} + epA := extdns.NewEndpoint(fqdn, extdns.RecordTypeA, "10.0.0.1") + + makeStoreRec := func(owner *ResourceRef, path string, values []string) *model.ProjectDnsRecord { + createdFor := resourceKindToCreatedFor(owner.Kind) + return &model.ProjectDnsRecord{ + Path: servicecommon.String(path), + ZonePath: servicecommon.String(zonePath), + RecordType: servicecommon.String(model.ProjectDnsRecord_RECORD_TYPE_A), + Fqdn: servicecommon.String(fqdn), + RecordValues: values, + Tags: []model.Tag{ + modelTag(servicecommon.TagScopeDNSRecordFor, createdFor), + modelTag(servicecommon.TagScopeDNSRecordOwnerNamespace, owner.GetNamespace()), + modelTag(servicecommon.TagScopeDNSRecordOwnerName, owner.GetName()), + }, + } + } + + tests := []struct { + name string + storeRecs []*model.ProjectDnsRecord + owner *ResourceRef + wantErr bool + errSub string + wantShared bool + }{ + { + name: "no existing record: new row returned", + owner: ownerA, + }, + { + name: "own record in store: returns row directly", + storeRecs: []*model.ProjectDnsRecord{makeStoreRec(ownerA, "/orgs/org1/projects/proj1/dns-records/r-svcA", []string{"10.0.0.1"})}, + owner: ownerA, + }, + { + name: "FQDN conflict: different target values for different owner", + storeRecs: []*model.ProjectDnsRecord{makeStoreRec(ownerB, "/orgs/org1/projects/proj1/dns-records/r-svcB", []string{"10.0.0.99"})}, + owner: ownerA, + wantErr: true, + errSub: "configured with different values", + }, + { + name: "adoption: same values from different owner", + storeRecs: []*model.ProjectDnsRecord{makeStoreRec(ownerB, "/orgs/org1/projects/proj1/dns-records/r-svcB", []string{"10.0.0.1"})}, + owner: ownerA, + wantShared: true, + }, + { + name: "adoption fails: same values but incomplete owner metadata", + storeRecs: []*model.ProjectDnsRecord{{ + Path: servicecommon.String("/orgs/org1/projects/proj1/dns-records/r-noowner"), + ZonePath: servicecommon.String(zonePath), + RecordType: servicecommon.String(model.ProjectDnsRecord_RECORD_TYPE_A), + Fqdn: servicecommon.String(fqdn), + RecordValues: []string{"10.0.0.1"}, + Tags: []model.Tag{}, + }}, + owner: ownerA, + wantErr: true, + errSub: "incomplete owner metadata", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + env := newTestDNSRecordService(t, BuildDNSRecordStore()) + for _, r := range tc.storeRecs { + require.NoError(t, env.DNSRecordStore.Add(r)) + } + row, err := env.validateEndpointRowConflict(zonePath, epA, "svc", tc.owner) + if tc.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errSub) + return + } + require.NoError(t, err) + require.NotNil(t, row) + if tc.wantShared { + require.NotNil(t, row.effectiveOwner, "adoption: effectiveOwner should be set") + require.NotEmpty(t, row.contributingOwnerKeys) + } + }) + } +} + +func TestCreateOrUpdateRecords_errorPaths_table(t *testing.T) { + ctx := context.Background() + + t.Run("applyDNSUpsertRows error propagates", func(t *testing.T) { + env := newTestDNSRecordService(t, BuildDNSRecordStore()) + // nil Owner with non-empty Rows triggers an error from applyDNSUpsertRows. + batch := &AggregatedDNSEndpoints{ + Rows: []EndpointRow{{Endpoint: extdns.NewEndpoint("x.example.com", extdns.RecordTypeA, "1.1.1.1")}}, + } + _, err := env.CreateOrUpdateRecords(ctx, batch) + require.Error(t, err) + }) + + t.Run("no rows and no existing records: no-op returns false", func(t *testing.T) { + env := newTestDNSRecordService(t, BuildDNSRecordStore()) + owner := &ResourceRef{Kind: ResourceKindService, Object: &metav1.ObjectMeta{Namespace: "ns", Name: "svc"}} + mut, err := env.CreateOrUpdateRecords(ctx, NewOwnerScopedAggregatedRouteDNS(owner, nil)) + require.NoError(t, err) + require.False(t, mut) + }) +} + +func TestDeleteRecordByOwnerNN_unknownKind_noOp(t *testing.T) { + ctx := context.Background() + env := newTestDNSRecordService(t, BuildDNSRecordStore()) + mut, err := env.DeleteRecordByOwnerNN(ctx, "UnknownKind", "ns", "name") + require.NoError(t, err) + require.False(t, mut) +} + +func TestDeleteRecordByOwnerNN_noRecords_noOp(t *testing.T) { + ctx := context.Background() + env := newTestDNSRecordService(t, BuildDNSRecordStore()) + mut, err := env.DeleteRecordByOwnerNN(ctx, ResourceKindService, "ns", "svc") + require.NoError(t, err) + require.False(t, mut) +} + +func TestDeleteRecordsForOwnerOutsideAllowedZones_nilAllowedZones(t *testing.T) { + ctx := context.Background() + env := newTestDNSRecordService(t, BuildDNSRecordStore()) + owner := &ResourceRef{Kind: ResourceKindGateway, Object: &metav1.ObjectMeta{Namespace: "ns", Name: "gw", UID: types.UID("u1")}} + ep := extdns.NewEndpoint("a.example.com", extdns.RecordTypeA, "10.0.0.1") + ep.WithLabel(EndpointLabelParentGateway, "ns/gw") + row := EndpointRow{Endpoint: ep, zonePath: testDNSZonePathOld, nsxRecordName: "a"} + requireNoErrCreateDNS(ctx, t, env, NewOwnerScopedAggregatedRouteDNS(owner, []EndpointRow{row})) + + // nil allowedZones treated as empty set → all owned records are outside and deleted. + mut, err := env.DeleteRecordsForOwnerOutsideAllowedZones(ctx, ResourceKindGateway, "ns", "gw", nil) + require.NoError(t, err) + require.True(t, mut) + require.Empty(t, env.DNSRecordStore.GetByOwnerResourceNamespacedName(ResourceKindGateway, "ns", "gw")) +} + +func TestDeleteRecordsForOwnerOutsideAllowedZones_noOwnedRecords(t *testing.T) { + ctx := context.Background() + env := newTestDNSRecordService(t, BuildDNSRecordStore()) + mut, err := env.DeleteRecordsForOwnerOutsideAllowedZones(ctx, ResourceKindGateway, "ns", "gw", nil) + require.NoError(t, err) + require.False(t, mut) +} + +func TestApplyDNSUpsertRows_unsupportedOwnerKind(t *testing.T) { + env := newTestDNSRecordService(t, BuildDNSRecordStore()) + owner := &ResourceRef{Kind: "UnknownKind", Object: &metav1.ObjectMeta{Namespace: "ns", Name: "obj"}} + ep := extdns.NewEndpoint("a.example.com", extdns.RecordTypeA, "1.1.1.1") + batch := NewOwnerScopedAggregatedRouteDNS(owner, []EndpointRow{ + {Endpoint: ep, zonePath: testDNSZonePathT, nsxRecordName: "a"}, + }) + // collectRecordsByOwner returns ("", nil) for unknown kind, so ownerNNKey="" → toUpsert is non-empty + // but syncProjectDnsRecordsInNSX will be called. The important thing is no panic. + _, _, err := env.applyDNSUpsertRows(batch) + require.NoError(t, err) +} + +func TestSyncDNSZonesByVpcNetworkConfig_table(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(func() { ctrl.Finish() }) + zc := dnszonemocks.NewMockZonesClient(ctrl) + + builder, err := servicecommon.PolicyPathProjectDnsRecord.NewPolicyTreeBuilder() + require.NoError(t, err) + cfg := &config.NSXOperatorConfig{CoeConfig: &config.CoeConfig{Cluster: "unit-test"}} + svc := &DNSRecordService{ + Service: servicecommon.Service{ + Client: fake.NewClientBuilder().WithScheme(clientgoscheme.Scheme).Build(), + NSXConfig: cfg, + NSXClient: &nsx.Client{ + NsxConfig: cfg, + ProjectDnsZoneClient: zc, + }, + }, + DNSZoneMap: newDNSZoneCache(), + ProjectDnsRecordBuilder: builder, + } + + // No error returns when VPCNetworkConfiguration does not set DNS zones. + m, err := svc.SyncDNSZonesByVpcNetworkConfig(&v1alpha1.VPCNetworkConfiguration{}) + require.NoError(t, err) + require.Nil(t, m) + + domain := "fetched.example" + zp := testDNSZonePathZ + zc.EXPECT().Get("org1", "proj1", "ds1", "zone-z").Return(model.ProjectDnsZone{ + DnsDomainName: &domain, + }, nil).Times(1) + + vpc := &v1alpha1.VPCNetworkConfiguration{ + Spec: v1alpha1.VPCNetworkConfigurationSpec{ + DNSZones: []string{zp}, + }, + } + m, err = svc.SyncDNSZonesByVpcNetworkConfig(vpc) + require.NoError(t, err) + require.Equal(t, map[string]string{zp: domain}, m) + got, ok := svc.DNSZoneMap.get(zp) + require.True(t, ok) + require.Equal(t, domain, got) + + // No NSX call for the second sync + m, err = svc.SyncDNSZonesByVpcNetworkConfig(vpc) + require.NoError(t, err) + require.Equal(t, map[string]string{zp: domain}, m) +} diff --git a/pkg/nsx/services/dns/store.go b/pkg/nsx/services/dns/store.go new file mode 100644 index 000000000..90483dc76 --- /dev/null +++ b/pkg/nsx/services/dns/store.go @@ -0,0 +1,377 @@ +/* Copyright © 2026 Broadcom, Inc. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 */ + +package dns + +import ( + "errors" + "fmt" + "strings" + + "github.com/vmware/vsphere-automation-sdk-go/services/nsxt/model" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/client-go/tools/cache" + + "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" +) + +// RecordStore holds in-memory NSX DNS Record rows and indexes. +type RecordStore struct { + common.ResourceStore +} + +func dnsRecordKeyFunc(obj interface{}) (string, error) { + switch v := obj.(type) { + case *model.ProjectDnsRecord: + if v.Path != nil { + return *v.Path, nil + } + return "", errors.New("DNS record has nil path") + default: + return "", errors.New("dnsRecordKeyFunc doesn't support unknown type") + } +} + +func filterTagBy(v []model.Tag, tagScope string) []string { + var res []string + for _, tag := range v { + if tag.Scope != nil && *tag.Scope == tagScope && tag.Tag != nil { + res = append(res, *tag.Tag) + } + } + return res +} + +// dnsRecordCreatedForValue returns the dns_for tag value on rec, or "". +func dnsRecordCreatedForValue(rec *model.ProjectDnsRecord) string { + return firstTagValue(rec.Tags, common.TagScopeDNSRecordFor) +} + +func indexDNSRecordByOwnerTypeNN(obj interface{}) ([]string, error) { + switch v := obj.(type) { + case *model.ProjectDnsRecord: + ownerKey := getDNSRecordOwnerNamespacedName(v) + if ownerKey == "" { + return []string{}, nil + } + return []string{ownerKey}, nil + default: + return nil, errors.New("indexDNSRecordByOwnerTypeNN doesn't support unknown type") + } +} + +// ownerCreatedForAndNNFromDNSRecord returns (dns_for, owner ns, owner name, ok) from rec tags. +func ownerCreatedForAndNNFromDNSRecord(record *model.ProjectDnsRecord) (createdFor, ns, name string, ok bool) { + createdFor = dnsRecordCreatedForValue(record) + if createdFor == "" { + return "", "", "", false + } + ns = firstTagValue(record.Tags, common.TagScopeDNSRecordOwnerNamespace) + name = firstTagValue(record.Tags, common.TagScopeDNSRecordOwnerName) + if ns == "" || name == "" { + return "", "", "", false + } + return createdFor, ns, name, true +} + +// GroupRecordsByResourceKind groups owner NNs by ResourceRef.Kind (skips unknown dns_for). +func (s *RecordStore) GroupRecordsByResourceKind() map[string]sets.Set[types.NamespacedName] { + ownerKindKeys := s.ListIndexFuncValues(indexKeyDNSRecordOwnerTypeNN) + ownersByKind := make(map[string]sets.Set[types.NamespacedName]) + for key := range ownerKindKeys { + p := strings.Split(key, "/") + if len(p) < 3 { + continue // bad index key shape + } + createdForTag, ns, name := p[0], p[1], p[2] + resourceKind := resourceKindFromCreatedForTag(createdForTag) + if resourceKind == "" { + continue + } + nn := types.NamespacedName{Namespace: ns, Name: name} + if ownersByKind[resourceKind] == nil { + ownersByKind[resourceKind] = sets.New[types.NamespacedName]() + } + ownersByKind[resourceKind].Insert(nn) + } + return ownersByKind +} + +// getDNSRecordOwnerNamespacedName returns owner index key "createdFor/ns/name", or "" if tags incomplete. +func getDNSRecordOwnerNamespacedName(record *model.ProjectDnsRecord) string { + createdFor, ns, name, ok := ownerCreatedForAndNNFromDNSRecord(record) + if !ok { + return "" + } + return dnsRecordOwnerKey(createdFor, dnsRecordOwnerNamespacedNameKey(ns, name)) +} + +func firstTagValue(tags []model.Tag, scope string) string { + v := filterTagBy(tags, scope) + if len(v) == 0 { + return "" + } + return v[0] +} + +// gatewayIndexKeysFromDNSRecord returns parent-gateway label keys for Route rows (comma-separated in tag). +func gatewayIndexKeysFromDNSRecord(v *model.ProjectDnsRecord) []string { + raw := gatewayIndexTagFromRecord(v) + if raw == "" { + return nil + } + seen := sets.New[string]() + for _, part := range strings.Split(raw, ",") { + k := strings.TrimSpace(part) + if k != "" { + seen.Insert(k) + } + } + if seen.Len() == 0 { + return nil + } + return sortedCopyStrings(seen.UnsortedList()) +} + +func dnsRecordZonePathFQDNIndexKey(zonePath, fqdnLower, recordType string) string { + zp := strings.TrimSpace(zonePath) + fq := strings.TrimSpace(strings.ToLower(fqdnLower)) + rt := strings.ToLower(strings.TrimSpace(recordType)) + return zp + "|" + fq + "|" + rt +} + +func dnsRecordFQDNLower(rec *model.ProjectDnsRecord) string { + if rec == nil || rec.Fqdn == nil { + return "" + } + return strings.TrimSpace(strings.ToLower(*rec.Fqdn)) +} + +func indexDNSRecordByZonePathFQDN(obj interface{}) ([]string, error) { + switch v := obj.(type) { + case *model.ProjectDnsRecord: + dnsZone := v.ZonePath + if dnsZone == nil || *dnsZone == "" { + return []string{}, nil // no zone path; skip this index. This is for security purpose, should not happen in the runtime. + } + zp := *dnsZone + if v.RecordType == nil { + return []string{}, nil + } + fq := dnsRecordFQDNLower(v) + rt := strings.TrimSpace(*v.RecordType) + if fq == "" || rt == "" { // This is for security purpose, should not happen in the runtime + return []string{}, nil + } + return []string{dnsRecordZonePathFQDNIndexKey(zp, fq, rt)}, nil + default: + return nil, errors.New("indexDNSRecordByZonePathFQDN doesn't support unknown type") + } +} + +func indexDNSRecordByGatewayNamespacedName(obj interface{}) ([]string, error) { + switch v := obj.(type) { + case *model.ProjectDnsRecord: + createdFor := firstTagValue(v.Tags, common.TagScopeDNSRecordFor) + if createdFor == "" { + return []string{}, nil // record has no dns_for tag; not indexed by gateway + } + switch createdFor { + case common.TagValueDNSRecordForHTTPRoute, + common.TagValueDNSRecordForGRPCRoute, + common.TagValueDNSRecordForTLSRoute: + keys := gatewayIndexKeysFromDNSRecord(v) + if len(keys) == 0 { + return []string{}, nil + } + return keys, nil // route kinds only + default: + return []string{}, nil // no gateway index for this kind + } + default: + return nil, errors.New("indexDNSRecordByGatewayNamespacedName doesn't support unknown type") + } +} + +// dnsRecordOwnerKey is "createdFor/namespace/name" for owner/contributing indexes. +func dnsRecordOwnerKey(createdFor, id string) string { + return fmt.Sprintf("%s/%s", createdFor, id) +} + +func dnsRecordOwnerNamespacedNameKey(namespace, name string) string { + return fmt.Sprintf("%s/%s", namespace, name) +} + +const ( + indexKeyDNSRecordOwnerTypeNN = "ownerNamespacedName" + indexKeyDNSRecordGatewayNN = "gatewayNamespacedName" + indexKeyDNSRecordZonePathFQDN = "dnsZonePathFqdn" + indexKeyDNSRecordZonePath = "dnsZonePath" + indexKeyDNSRecordContributingOwner = "contributingOwner" +) + +func (s *RecordStore) Apply(i interface{}) error { + records := i.([]*model.ProjectDnsRecord) + for _, rec := range records { + if rec.MarkedForDelete != nil && *rec.MarkedForDelete { + if err := s.Delete(rec); err != nil { + return err + } + } else { + if err := s.Add(rec); err != nil { + return err + } + } + } + + return nil +} + +func (s *RecordStore) GetByKey(key string) *model.ProjectDnsRecord { + obj := s.ResourceStore.GetByKey(key) + r, ok := obj.(*model.ProjectDnsRecord) + if !ok { + return nil + } + return r +} + +func (s *RecordStore) GetByIndex(index string, value string) []*model.ProjectDnsRecord { + objs := s.ResourceStore.GetByIndex(index, value) + out := make([]*model.ProjectDnsRecord, 0, len(objs)) + for _, o := range objs { + out = append(out, o.(*model.ProjectDnsRecord)) + } + return out +} + +// resourceKindFromCreatedForTag maps dns_for tag value to ResourceRef.Kind; "" if unknown. +func resourceKindFromCreatedForTag(createdFor string) string { + switch createdFor { + case common.TagValueDNSRecordForHTTPRoute: + return ResourceKindHTTPRoute + case common.TagValueDNSRecordForGRPCRoute: + return ResourceKindGRPCRoute + case common.TagValueDNSRecordForTLSRoute: + return ResourceKindTLSRoute + case common.TagValueDNSRecordForGateway: + return ResourceKindGateway + case common.TagValueDNSRecordForService: + return ResourceKindService + default: + return "" + } +} + +// resourceKindToCreatedFor maps ResourceRef.Kind to dns_for tag value; "" if unsupported. +func resourceKindToCreatedFor(kind string) string { + switch kind { + case ResourceKindGateway: + return common.TagValueDNSRecordForGateway + case ResourceKindHTTPRoute: + return common.TagValueDNSRecordForHTTPRoute + case ResourceKindGRPCRoute: + return common.TagValueDNSRecordForGRPCRoute + case ResourceKindTLSRoute: + return common.TagValueDNSRecordForTLSRoute + case ResourceKindService: + return common.TagValueDNSRecordForService + default: + return "" + } +} + +// DeleteMultipleObjects removes records from the in-memory store (used after NSX deletion succeeds). +func (s *RecordStore) DeleteMultipleObjects(records []*model.ProjectDnsRecord) { + for _, rec := range records { + if rec == nil { + continue + } + if err := s.Delete(rec); err != nil { + log.Error(err, "failed to delete DNS record from store", "path", rec.Path) + } + } +} + +// GetByOwnerResourceNamespacedName returns all DNS records whose primary owner matches kind/namespace/name. +func (s *RecordStore) GetByOwnerResourceNamespacedName(kind, namespace, name string) []*model.ProjectDnsRecord { + createdFor := resourceKindToCreatedFor(kind) + if createdFor == "" { + return nil + } + key := dnsRecordOwnerKey(createdFor, dnsRecordOwnerNamespacedNameKey(namespace, name)) + return s.GetByIndex(indexKeyDNSRecordOwnerTypeNN, key) +} + +// ListZonePaths returns all distinct zone paths present across stored DNS records. +func (s *RecordStore) ListZonePaths() sets.Set[string] { + vals := s.ListIndexFuncValues(indexKeyDNSRecordZonePath) + result := sets.New[string]() + for k := range vals { + if k != "" { + result.Insert(k) + } + } + return result +} + +func (s *RecordStore) ListDNSRecords() []*model.ProjectDnsRecord { + objs := s.List() + out := make([]*model.ProjectDnsRecord, 0, len(objs)) + for _, o := range objs { + if r, ok := o.(*model.ProjectDnsRecord); ok && r != nil { + out = append(out, r) + } + } + return out +} + +// ListRecordsReferencingContributingOwner returns rows whose contributing-owners tag contains contribNNKey. +// It uses the contributingOwner index for O(1) lookup instead of a full table scan. +func (s *RecordStore) ListRecordsReferencingContributingOwner(contribNNKey string) []*model.ProjectDnsRecord { + return s.GetByIndex(indexKeyDNSRecordContributingOwner, contribNNKey) +} + +func indexDNSRecordByZonePath(obj interface{}) ([]string, error) { + switch v := obj.(type) { + case *model.ProjectDnsRecord: + if v.ZonePath == nil || *v.ZonePath == "" { + return []string{}, nil + } + return []string{*v.ZonePath}, nil + default: + return nil, errors.New("indexDNSRecordByZonePath doesn't support unknown type") + } +} + +// indexDNSRecordByContributingOwner expands the comma-separated TagScopeDNSRecordContributingOwners +// tag into one index entry per contributing-owner key, enabling O(1) reverse lookup. +func indexDNSRecordByContributingOwner(obj interface{}) ([]string, error) { + switch v := obj.(type) { + case *model.ProjectDnsRecord: + keys := parseContributingOwnersFromRecord(v) + if len(keys) == 0 { + return []string{}, nil + } + return keys, nil + default: + return nil, errors.New("indexDNSRecordByContributingOwner doesn't support unknown type") + } +} + +// BuildDNSRecordStore returns a new RecordStore with default indexers. +func BuildDNSRecordStore() *RecordStore { + return &RecordStore{ + ResourceStore: common.ResourceStore{ + Indexer: cache.NewIndexer(dnsRecordKeyFunc, cache.Indexers{ + indexKeyDNSRecordOwnerTypeNN: indexDNSRecordByOwnerTypeNN, + indexKeyDNSRecordGatewayNN: indexDNSRecordByGatewayNamespacedName, + indexKeyDNSRecordZonePathFQDN: indexDNSRecordByZonePathFQDN, + indexKeyDNSRecordZonePath: indexDNSRecordByZonePath, + indexKeyDNSRecordContributingOwner: indexDNSRecordByContributingOwner, + }), + BindingType: model.ProjectDnsRecordBindingType(), + }, + } +} diff --git a/pkg/nsx/services/dns/store_test.go b/pkg/nsx/services/dns/store_test.go new file mode 100644 index 000000000..0f1d5c027 --- /dev/null +++ b/pkg/nsx/services/dns/store_test.go @@ -0,0 +1,186 @@ +/* Copyright © 2026 Broadcom, Inc. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 */ + +package dns + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/vmware/vsphere-automation-sdk-go/services/nsxt/model" + + servicecommon "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" +) + +func TestDNSRecordKeyFunc_table(t *testing.T) { + path := "/orgs/o/projects/p/dns-records/r1" + tests := []struct { + name string + obj interface{} + want string + wantErr bool + }{ + { + name: "valid path", + obj: &model.ProjectDnsRecord{Path: &path}, + want: path, + }, + { + name: "nil path returns error", + obj: &model.ProjectDnsRecord{}, + wantErr: true, + }, + { + name: "unknown type returns error", + obj: "not-a-record", + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := dnsRecordKeyFunc(tc.obj) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +func TestResourceKindToCreatedFor_table(t *testing.T) { + tests := []struct { + kind string + want string + }{ + {ResourceKindGateway, servicecommon.TagValueDNSRecordForGateway}, + {ResourceKindHTTPRoute, servicecommon.TagValueDNSRecordForHTTPRoute}, + {ResourceKindGRPCRoute, servicecommon.TagValueDNSRecordForGRPCRoute}, + {ResourceKindTLSRoute, servicecommon.TagValueDNSRecordForTLSRoute}, + {ResourceKindService, servicecommon.TagValueDNSRecordForService}, + {"UnknownKind", ""}, + {"", ""}, + } + for _, tc := range tests { + t.Run(tc.kind+"->"+tc.want, func(t *testing.T) { + require.Equal(t, tc.want, resourceKindToCreatedFor(tc.kind)) + }) + } +} + +func TestDNSRecordFQDNLower_table(t *testing.T) { + fqdn := "A.EXAMPLE.COM" + tests := []struct { + name string + rec *model.ProjectDnsRecord + want string + }{ + {"nil record", nil, ""}, + {"nil fqdn field", &model.ProjectDnsRecord{}, ""}, + {"valid fqdn lowercased", &model.ProjectDnsRecord{Fqdn: &fqdn}, "a.example.com"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, dnsRecordFQDNLower(tc.rec)) + }) + } +} + +func TestDeleteMultipleObjects_table(t *testing.T) { + t.Run("nil records skipped without panic", func(t *testing.T) { + store := BuildDNSRecordStore() + store.DeleteMultipleObjects([]*model.ProjectDnsRecord{nil, nil}) + }) + + t.Run("valid record removed from store", func(t *testing.T) { + store := BuildDNSRecordStore() + path := "/orgs/o/projects/p/dns-records/r1" + rec := &model.ProjectDnsRecord{Path: servicecommon.String(path)} + require.NoError(t, store.Add(rec)) + require.NotNil(t, store.GetByKey(path)) + + store.DeleteMultipleObjects([]*model.ProjectDnsRecord{nil, rec}) + require.Nil(t, store.GetByKey(path)) + }) +} + +func TestApply_addAndDeleteBranches(t *testing.T) { + store := BuildDNSRecordStore() + path := "/orgs/o/projects/p/dns-records/r2" + rec := &model.ProjectDnsRecord{Path: servicecommon.String(path)} + + require.NoError(t, store.Apply([]*model.ProjectDnsRecord{rec})) + require.NotNil(t, store.GetByKey(path)) + + cp := *rec + cp.MarkedForDelete = servicecommon.Bool(true) + require.NoError(t, store.Apply([]*model.ProjectDnsRecord{&cp})) + require.Nil(t, store.GetByKey(path)) +} + +func TestIndexFunctions_unsupported_type(t *testing.T) { + unsupported := "not-a-record" + + tests := []struct { + name string + fn func(interface{}) ([]string, error) + }{ + {"indexDNSRecordByOwnerTypeNN", indexDNSRecordByOwnerTypeNN}, + {"indexDNSRecordByGatewayNamespacedName", indexDNSRecordByGatewayNamespacedName}, + {"indexDNSRecordByZonePathFQDN", indexDNSRecordByZonePathFQDN}, + {"indexDNSRecordByZonePath", indexDNSRecordByZonePath}, + {"indexDNSRecordByContributingOwner", indexDNSRecordByContributingOwner}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := tc.fn(unsupported) + require.Error(t, err) + }) + } +} + +func TestIndexDNSRecordByZonePathFQDN_earlyReturn_table(t *testing.T) { + rt := model.ProjectDnsRecord_RECORD_TYPE_A + fqdn := "a.example.com" + zp := "/z1" + tests := []struct { + name string + rec *model.ProjectDnsRecord + wantLen int + }{ + { + name: "nil ZonePath returns empty", + rec: &model.ProjectDnsRecord{RecordType: &rt, Fqdn: &fqdn}, + wantLen: 0, + }, + { + name: "nil RecordType returns empty", + rec: &model.ProjectDnsRecord{ZonePath: &zp, Fqdn: &fqdn}, + wantLen: 0, + }, + { + name: "empty fqdn returns empty", + rec: &model.ProjectDnsRecord{ZonePath: &zp, RecordType: &rt}, + wantLen: 0, + }, + { + name: "valid record returns one index key", + rec: &model.ProjectDnsRecord{ZonePath: &zp, RecordType: &rt, Fqdn: &fqdn}, + wantLen: 1, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + keys, err := indexDNSRecordByZonePathFQDN(tc.rec) + require.NoError(t, err) + require.Len(t, keys, tc.wantLen) + }) + } +} + +func TestGetByOwnerResourceNamespacedName_unknownKind(t *testing.T) { + store := BuildDNSRecordStore() + result := store.GetByOwnerResourceNamespacedName("UnknownKind", "ns", "name") + require.Nil(t, result) +} diff --git a/pkg/nsx/services/dns/types.go b/pkg/nsx/services/dns/types.go new file mode 100644 index 000000000..e8c924a76 --- /dev/null +++ b/pkg/nsx/services/dns/types.go @@ -0,0 +1,80 @@ +/* Copyright © 2026 Broadcom, Inc. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 */ + +package dns + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + + "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" + extdns "github.com/vmware-tanzu/nsx-operator/pkg/third_party/externaldns/endpoint" +) + +const ( + ResourceKindGateway = "Gateway" + ResourceKindHTTPRoute = "HTTPRoute" + ResourceKindGRPCRoute = "GRPCRoute" + ResourceKindTLSRoute = "TLSRoute" + ResourceKindService = "Service" + // DNSRecordPathSegment is the NSX Policy path segment for project-scoped ProjectDnsRecord (same as common.PathSegmentProjectDnsRecords). + DNSRecordPathSegment = common.PathSegmentProjectDnsRecords +) + +// EndpointLabelParentGateway is the ExternalDNS Endpoint label for parent Gateway ns/name (comma-separated if merged). +const EndpointLabelParentGateway = "nsx.vmware.com/parent-gateway" + +// ResourceRef is a DNS owner: Kind plus embedded ObjectMeta (namespace, name, uid). +type ResourceRef struct { + metav1.Object + Kind string +} + +// AggregatedDNSEndpoints is one reconcile batch: optional Owner plus Endpoint rows (Owner required when Rows non-empty). +type AggregatedDNSEndpoints struct { + Namespace string + Rows []EndpointRow + Owner *ResourceRef +} + +type EndpointRow struct { + *extdns.Endpoint + zonePath string + // nsxRecordName is the host prefix of the FQDN, i.e. Endpoint.DNSName with the DNS zone's domain + // suffix (and the separating dot) stripped. For example, if DNSName is "foo.example.com" and + // the zone domain is "example.com", nsxRecordName is "foo". + nsxRecordName string + effectiveOwner *ResourceRef // primary for shared FQDN row when adopting + contributingOwnerKeys string // sorted, comma-separated contributing owner index keys; matches TagScopeDNSRecordContributingOwners tag value +} + +func NewEndpointRow(ep *extdns.Endpoint, zonePath string, recordName string) *EndpointRow { + return &EndpointRow{ + Endpoint: ep, + zonePath: zonePath, + nsxRecordName: recordName, + } +} + +// NewOwnerScopedAggregatedRouteDNS returns a batch for scopeOwner and rows, or nil if scopeOwner is nil. +func NewOwnerScopedAggregatedRouteDNS(scopeOwner *ResourceRef, rows []EndpointRow) *AggregatedDNSEndpoints { + return &AggregatedDNSEndpoints{ + Namespace: scopeOwner.GetNamespace(), + Owner: scopeOwner, + Rows: rows, + } +} + +// DNSRecordProvider is the DNS record API for Gateway Route and LoadBalancer Service DNS; *DNSRecordService implements it. +type DNSRecordProvider interface { + CreateOrUpdateRecords(ctx context.Context, batch *AggregatedDNSEndpoints) (bool, error) + DeleteRecordByOwnerNN(ctx context.Context, kind, namespace, name string) (bool, error) + ValidateEndpointsByZone(namespace string, owner *ResourceRef, eps []*extdns.Endpoint) ([]EndpointRow, map[string]string, error) + // DeleteRecordsForOwnerOutsideAllowedZones deletes the DNS records whose zone_path is not in allowedZonePaths (NSX + store). + DeleteRecordsForOwnerOutsideAllowedZones(ctx context.Context, kind, namespace, name string, allowedZonePaths sets.Set[string]) (bool, error) + ListReferredGatewayNN() sets.Set[types.NamespacedName] + ListRecordOwnerResource() map[string]sets.Set[types.NamespacedName] +} diff --git a/pkg/nsx/services/dns/zones.go b/pkg/nsx/services/dns/zones.go new file mode 100644 index 000000000..88efd9586 --- /dev/null +++ b/pkg/nsx/services/dns/zones.go @@ -0,0 +1,168 @@ +/* Copyright © 2026 Broadcom, Inc. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 */ + +package dns + +import ( + "fmt" + "regexp" + "strings" + + "github.com/vmware/vsphere-automation-sdk-go/services/nsxt/model" + + "github.com/vmware-tanzu/nsx-operator/pkg/apis/vpc/v1alpha1" + nsxutil "github.com/vmware-tanzu/nsx-operator/pkg/nsx/util" + extdns "github.com/vmware-tanzu/nsx-operator/pkg/third_party/externaldns/endpoint" + extprovider "github.com/vmware-tanzu/nsx-operator/pkg/third_party/externaldns/provider" +) + +// NSX Policy path: /orgs/{org}/projects/{project}/dns-services/{dnsService}/zones/{zoneId} +var projectDNSZonePathRe = regexp.MustCompile(`^/orgs/([^/]+)/projects/([^/]+)/dns-services/([^/]+)/zones/([^/]+)$`) + +// parseProjectDNSZonePath splits a Policy zone path into org, project, DNS service, and zone ID. +func parseProjectDNSZonePath(zonePath string) (orgID, projectID, dnsServiceID, zoneID string, err error) { + p := strings.TrimSpace(zonePath) + if p == "" { + return "", "", "", "", fmt.Errorf("empty DNS zone path") + } + matches := projectDNSZonePathRe.FindStringSubmatch(p) + if len(matches) != 5 { + return "", "", "", "", fmt.Errorf("invalid DNS zone path %q: expected /orgs/{org}/projects/{project}/dns-services/{dns-service}/zones/{zone}", zonePath) + } + return matches[1], matches[2], matches[3], matches[4], nil +} + +// endpointDNSNameIsWildcard reports whether dnsName requests a wildcard apex record (e.g. "*.example.com"). +// NSX DNS policy does not publish such names; ValidateEndpointsByZone skips them without error. +func endpointDNSNameIsWildcard(dnsName string) bool { + h := strings.TrimSpace(dnsName) + return strings.HasPrefix(strings.ToLower(h), "*.") +} + +// dnsNameForZoneMatch normalizes hostname for FindZone (trim, strip trailing dot, strip leading "*." only). +func dnsNameForZoneMatch(hostname string) string { + h := strings.TrimSpace(hostname) + h = strings.TrimSuffix(h, ".") + if h == "" { + return "" + } + if strings.HasPrefix(strings.ToLower(h), "*.") && len(h) >= 2 { + return h[2:] + } + return h +} + +// getZonePathForHostname returns (relativeRecordName, zonePath, err) for hostname against z. +func (s *DNSRecordService) getZonePathForHostname(z extprovider.ZoneIDName, hostname string) (string, string, error) { + name := dnsNameForZoneMatch(hostname) + if name == "" { + return "", "", fmt.Errorf("empty hostname") + } + + zonePath, matchedDomain, normalizedFQDN := z.FindZone(name) + if matchedDomain == "" { + return "", "", fmt.Errorf("hostname %q does not match any allowed DNS domain in the namespace", hostname) + } + if normalizedFQDN == matchedDomain { + return "", "", fmt.Errorf("hostname %q must not equal to the allowed DNS domain %q", hostname, matchedDomain) + } + suffix := "." + matchedDomain + if !strings.HasSuffix(normalizedFQDN, suffix) || normalizedFQDN == suffix { + return "", "", fmt.Errorf("hostname %q does not lie under matched zone %q", hostname, matchedDomain) + } + recordName := strings.TrimSuffix(normalizedFQDN, suffix) + return recordName, zonePath, nil +} + +// ValidateEndpointsByZone maps each endpoint to a zone and row; returns validated rows, path→domain map for permitted zones (from sync), and err. owner must be non-nil. +func (s *DNSRecordService) ValidateEndpointsByZone(namespace string, owner *ResourceRef, eps []*extdns.Endpoint) ([]EndpointRow, map[string]string, error) { + log.Info("Validating DNS endpoints by zone", "namespace", namespace, + "owner", owner.GetName(), "endpoints", len(eps)) + vpcConfig, err := s.VPCService.GetVPCNetworkConfigByNamespace(namespace) + if err != nil { + return nil, nil, fmt.Errorf("failed to find VPCNetworkConfiguration for the Namespace %s", namespace) + } + + allowedZones, err := s.SyncDNSZonesByVpcNetworkConfig(vpcConfig) + if err != nil { + return nil, nil, err + } + if len(allowedZones) == 0 { + return nil, nil, &DNSZoneValidationError{Msg: fmt.Sprintf("no DNS zones are permitted for the namespace %s", namespace)} + } + z := generateZoneIdFromMap(allowedZones) + + var rows []EndpointRow + for i := range eps { + ep := eps[i] + if endpointDNSNameIsWildcard(ep.DNSName) { + log.Info("Skipping DNS endpoint: wildcard DNS names are not supported for NSX DNS records", "dnsName", ep.DNSName) + continue + } + recName, zonePath, parseErr := s.getZonePathForHostname(z, ep.DNSName) + if parseErr != nil { + return nil, allowedZones, &DNSZoneValidationError{Msg: parseErr.Error()} + } + log.Debug("Mapped DNS endpoint to zone", "dnsName", ep.DNSName, "zonePath", zonePath, "recordName", recName) + row, validErr := s.validateEndpointRowConflict(zonePath, ep, recName, owner) + if validErr != nil { + return nil, allowedZones, &DNSZoneValidationError{Msg: "DNS endpoint validation failed for DNS zone policy", Cause: validErr} + } + rows = append(rows, *row) + } + return rows, allowedZones, nil +} + +// SyncDNSZonesByVpcNetworkConfig ensures DNSZoneMap entries for vpcConfig.Spec.DNSZones; returns path→domain map or err. +func (s *DNSRecordService) SyncDNSZonesByVpcNetworkConfig(vpcConfig *v1alpha1.VPCNetworkConfiguration) (map[string]string, error) { + dnsZonePaths := vpcConfig.Spec.DNSZones + if len(dnsZonePaths) == 0 { + return nil, nil + } + log.Info("Syncing DNS zones for VPCNetworkConfig", "zones", len(dnsZonePaths)) + + dnsZoneDomainMapping := make(map[string]string) + for _, p := range dnsZonePaths { + domain, found := s.DNSZoneMap.get(p) + if found { + log.Debug("DNS zone domain resolved from cache", "path", p, "domain", domain) + dnsZoneDomainMapping[p] = domain + continue + } + log.Info("Fetching DNS zone domain from NSX", "path", p) + zone, err := s.getDNSZoneFromNSX(p) + if err != nil { + log.Error(err, "failed to retrieve DNS zone from NSX", "path", p) + return nil, err + } + if zone.DnsDomainName == nil { + return nil, fmt.Errorf("DNS zone %s returned from NSX with no domain name", p) + } + domain = strings.TrimSpace(*zone.DnsDomainName) + log.Info("DNS zone domain fetched from NSX and cached", "path", p, "domain", domain) + s.DNSZoneMap.set(p, domain) + dnsZoneDomainMapping[p] = domain + } + return dnsZoneDomainMapping, nil +} + +func (s *DNSRecordService) getDNSZoneFromNSX(zonePath string) (*model.ProjectDnsZone, error) { + orgID, projectID, dnsServiceID, zoneID, err := parseProjectDNSZonePath(zonePath) + if err != nil { + return nil, err + } + z, err := s.NSXClient.ProjectDnsZoneClient.Get(orgID, projectID, dnsServiceID, zoneID) + if err != nil { + return nil, nsxutil.TransNSXApiError(err) + } + out := z + return &out, nil +} + +func generateZoneIdFromMap(allowedZones map[string]string) extprovider.ZoneIDName { + z := make(extprovider.ZoneIDName) + for zonePath, domainName := range allowedZones { + z.Add(zonePath, domainName) + } + return z +} diff --git a/pkg/nsx/services/dns/zones_test.go b/pkg/nsx/services/dns/zones_test.go new file mode 100644 index 000000000..066af8b5a --- /dev/null +++ b/pkg/nsx/services/dns/zones_test.go @@ -0,0 +1,145 @@ +/* Copyright © 2026 Broadcom, Inc. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 */ + +package dns + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestZonePathForHostnameFromMap_table(t *testing.T) { + tests := []struct { + name string + zones map[string]string // zonePath → domainName + hostname string + wantRecord string + wantPath string + errSub string // non-empty => require error containing substring + }{ + { + name: "nested_host_longest_suffix_zone", + zones: map[string]string{ + "/z1": "example.com", + "/z2": "foo.example.com", + }, + hostname: "a.foo.example.com", + wantRecord: "a", + wantPath: "/z2", + }, + { + name: "shorter_zone_when_longer_suffix_not_configured", + zones: map[string]string{ + "/z1": "example.com", + "/z2": "foo.example.com", + }, + hostname: "bar.example.com", + wantRecord: "bar", + wantPath: "/z1", + }, + { + name: "wildcard_prefix_stripped_before_FindZone", + zones: map[string]string{"/z": "example.com"}, + hostname: "*.apps.example.com", + wantRecord: "apps", + wantPath: "/z", + }, + { + name: "apex_hostname_rejected_even_with_trailing_dot_and_mixed_case", + zones: map[string]string{"/z": "example.com"}, + hostname: "EXAMPLE.COM.", + errSub: "must not equal to the allowed DNS domain", + }, + { + name: "leading_dot_only_zone_suffix_rejected_normalized_equals_suffix", + zones: map[string]string{"/z": "example.com"}, + hostname: ".example.com", + errSub: "does not lie under matched zone", + }, + { + name: "hostname_not_under_any_zone", + zones: map[string]string{"/z": "example.com"}, + hostname: "svc.other.example", + errSub: "does not match any allowed DNS domain", + }, + { + name: "no_zones_configured", + zones: map[string]string{}, + hostname: "a.example.com", + errSub: "does not match any allowed DNS domain", + }, + { + name: "empty_hostname_after_trim", + zones: map[string]string{"/z": "example.com"}, + hostname: " ", + errSub: "empty hostname", + }, + { + name: "punycode_hostname_matches_unicode_zone_longest_suffix", + zones: map[string]string{ + "/z1": "example.com", + "/z2": "xn--testcass-e1ae.fr", + "/z3": "app.xn--testcass-e1ae.fr", + }, + hostname: "svc.app.xn--testcass-e1ae.fr", + wantRecord: "svc", + wantPath: "/z3", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := &DNSRecordService{} + zid := generateZoneIdFromMap(tt.zones) + rec, zp, err := svc.getZonePathForHostname(zid, tt.hostname) + if tt.errSub != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errSub) + return + } + require.NoError(t, err) + require.Equal(t, tt.wantPath, zp, "zone path") + require.Equal(t, tt.wantRecord, rec, "relative record name") + }) + } +} + +func TestParseProjectDNSZonePath_table(t *testing.T) { + tests := []struct { + name string + path string + wantOrg string + wantProj string + wantDNSSvc string + wantZone string + wantErr bool + }{ + { + name: "valid path", + path: "/orgs/default/projects/project-1/dns-services/dns-svc-1/zones/zone-1", + wantOrg: "default", + wantProj: "project-1", + wantDNSSvc: "dns-svc-1", + wantZone: "zone-1", + }, + { + name: "wrong segment (vpcs instead of dns-services)", + path: "/orgs/a/projects/p/vpcs/vpc1/zones/z", + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + org, proj, dnsSvc, zone, err := parseProjectDNSZonePath(tc.path) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tc.wantOrg, org) + require.Equal(t, tc.wantProj, proj) + require.Equal(t, tc.wantDNSSvc, dnsSvc) + require.Equal(t, tc.wantZone, zone) + }) + } +} diff --git a/pkg/third_party/externaldns/doc.go b/pkg/third_party/externaldns/doc.go new file mode 100644 index 000000000..b5c7cbcd5 --- /dev/null +++ b/pkg/third_party/externaldns/doc.go @@ -0,0 +1,29 @@ +// Package externaldns contains code derived from Kubernetes ExternalDNS +// (https://github.com/kubernetes-sigs/external-dns, Go module sigs.k8s.io/external-dns) +// for use inside nsx-operator only. It is a **trimmed** mirror: informers, provider glue, and most +// non-Gateway sources from upstream are intentionally omitted. +// +// Upstream layout (for comparison when reading diffs): +// +// - source/gateway.go — Gateway/HTTPRoute/GRPCRoute/TLSRoute/ListenerSet DNS source: host merging +// (hosts / gateway-hostname-source), gwMatchingHost / gwHost, matchRouteToListener-style admission. +// - source/gateway_hostname.go — ASCII lower-case helper for gateway hostnames. +// - source/annotations/processors.go — SplitHostnameAnnotation and hostname list extraction. +// - endpoint/* — Endpoint model, Targets, EndpointsForHostname, SuitableType. +// +// Subpackages in this repo: +// +// - [github.com/vmware-tanzu/nsx-operator/pkg/third_party/externaldns/annotations]: hostname parsing +// aligned with external-dns/source/annotations; **annotation keys are caller-supplied strings** +// (nsx-operator uses pkg/nsx/services/common constants at the gateway controller), not fixed upstream key constants. +// - [github.com/vmware-tanzu/nsx-operator/pkg/third_party/externaldns/endpoint]: subset of external-dns/endpoint +// (see endpoint/doc.go for omissions vs upstream). +// - [github.com/vmware-tanzu/nsx-operator/pkg/third_party/externaldns/source]: stateless helpers and types +// factored from external-dns/source/gateway.go (see source/doc.go); no informers or template engine. +// gateway_host_matching.go holds GwMatchingHost / GatewayCanonicalHost (from upstream gateway.go) plus +// nsx-operator-only ClaimGwMatchingDNSName for ordered Gateway direct-DNS batch dedupe. +// - [github.com/vmware-tanzu/nsx-operator/pkg/third_party/externaldns/provider]: ZoneIDName / FindZone from +// external-dns/provider/zonefinder.go (longest suffix zone match, IDNA label normalization). +// +// Each subpackage’s doc.go lists which symbols follow upstream closely versus nsx-operator-only behavior. +package externaldns diff --git a/pkg/third_party/externaldns/endpoint/doc.go b/pkg/third_party/externaldns/endpoint/doc.go new file mode 100644 index 000000000..ff8f72158 --- /dev/null +++ b/pkg/third_party/externaldns/endpoint/doc.go @@ -0,0 +1,28 @@ +// Package endpoint mirrors parts of ExternalDNS’s provider-agnostic DNS record model used by the +// Gateway source and providers. Upstream: sigs.k8s.io/external-dns/endpoint (see endpoint.go, utils.go). +// +// # Direct copy from external-dns (same behavior; exported names preserved unless noted) +// +// (TTL).IsConfigured +// NewEndpoint +// NewEndpointWithTTL +// (Endpoint).Key — upstream Key(); here returns EndpointKey struct (subset of fields). +// (EndpointKey).String +// (Endpoint).WithSetIdentifier +// (Endpoint).WithProviderSpecific +// (Endpoint).GetProviderSpecificProperty +// (Endpoint).SetProviderSpecificProperty +// (Endpoint).WithLabel +// (Endpoint).GetBoolProviderSpecificProperty +// (Endpoint).CheckEndpoint +// (Endpoint).RetainProviderProperties +// supportsAlias, isAlias — unexported; same as upstream. +// EndpointsForHostname +// +// # Modified from external-dns +// +// SuitableType — same A/AAAA/CNAME rules; implementation uses net/netip.ParseAddr instead of upstream net.ParseIP. +// NewTargets — dedupe + sort uses slices.Sort (stdlib) instead of sort.Strings; semantics unchanged (sorted unique targets). +// NewEndpointWithTTL — omits upstream logging when DNS label length > 63 (still returns nil). +// endpoint.go — many registry/TXT/serialization helpers from upstream endpoint package are omitted (trimmed subset). +package endpoint diff --git a/pkg/third_party/externaldns/endpoint/endpoint.go b/pkg/third_party/externaldns/endpoint/endpoint.go new file mode 100644 index 000000000..f4d7cebf5 --- /dev/null +++ b/pkg/third_party/externaldns/endpoint/endpoint.go @@ -0,0 +1,230 @@ +// Copyright 2017 The Kubernetes Authors. +// Copyright 2026 Broadcom, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Derived from sigs.k8s.io/external-dns/endpoint/endpoint.go (core types + constructors, trimmed). +// Attribution: see package doc.go. + +package endpoint + +import ( + "cmp" + "slices" + "strings" +) + +const ( + RecordTypeA = "A" + RecordTypeAAAA = "AAAA" + RecordTypeCNAME = "CNAME" + RecordTypeTXT = "TXT" + RecordTypeSRV = "SRV" + RecordTypeNS = "NS" + RecordTypePTR = "PTR" + RecordTypeMX = "MX" + RecordTypeNAPTR = "NAPTR" + + ProviderSpecificRecordType = "record-type" + providerSpecificAlias = "alias" +) + +// TTL defines the TTL of a DNS record. +type TTL int64 + +// IsConfigured returns true if TTL is configured. +func (ttl TTL) IsConfigured() bool { + return ttl > 0 +} + +// Targets is a list of targets for an endpoint. +type Targets []string + +// NewTargets returns sorted unique targets (ExternalDNS-compatible). +func NewTargets(target ...string) Targets { + seen := make(map[string]struct{}) + var out Targets + for _, t := range target { + t = strings.TrimSpace(t) + if t == "" { + continue + } + if _, ok := seen[t]; ok { + continue + } + seen[t] = struct{}{} + out = append(out, t) + } + slices.Sort(out) + return out +} + +// ProviderSpecificProperty holds provider-specific configuration. +type ProviderSpecificProperty struct { + Name string `json:"name,omitempty"` + Value string `json:"value,omitempty"` +} + +// ProviderSpecific is a list of provider-specific properties. +type ProviderSpecific []ProviderSpecificProperty + +// Labels is ExternalDNS-style optional metadata on an endpoint; nsx-operator leaves it unset for produced endpoints. +type Labels map[string]string + +// Endpoint mirrors sigs.k8s.io/external-dns/endpoint.Endpoint for provider-agnostic DNS planning. +type Endpoint struct { + DNSName string `json:"dnsName,omitempty"` + Targets Targets `json:"targets,omitempty"` + RecordType string `json:"recordType,omitempty"` + SetIdentifier string `json:"setIdentifier,omitempty"` + RecordTTL TTL `json:"recordTTL,omitempty"` + Labels Labels `json:"labels,omitempty"` + ProviderSpecific ProviderSpecific `json:"providerSpecific,omitempty"` +} + +// EndpointKey separates endpoints for deduplication (subset of upstream Key()). +type EndpointKey struct { + DNSName string + RecordType string + SetIdentifier string +} + +func (ep EndpointKey) String() string { + return ep.DNSName + "/" + ep.RecordType + "/" + ep.SetIdentifier +} + +// NewEndpoint creates an endpoint with default TTL 0. +func NewEndpoint(dnsName, recordType string, targets ...string) *Endpoint { + return NewEndpointWithTTL(dnsName, recordType, TTL(0), targets...) +} + +// NewEndpointWithTTL creates an endpoint with the given TTL. +func NewEndpointWithTTL(dnsName, recordType string, ttl TTL, targets ...string) *Endpoint { + cleanTargets := make([]string, 0, len(targets)) + for _, target := range targets { + switch recordType { + case RecordTypeTXT, RecordTypeNAPTR, RecordTypeSRV: + cleanTargets = append(cleanTargets, target) + default: + cleanTargets = append(cleanTargets, strings.TrimSuffix(target, ".")) + } + } + for _, label := range strings.Split(dnsName, ".") { + if len(label) > 63 { + return nil + } + } + return &Endpoint{ + DNSName: strings.TrimSuffix(dnsName, "."), + Targets: cleanTargets, + RecordType: recordType, + RecordTTL: ttl, + } +} + +// Key returns a deduplication key for this endpoint. +func (e *Endpoint) Key() EndpointKey { + return EndpointKey{ + DNSName: e.DNSName, + RecordType: e.RecordType, + SetIdentifier: e.SetIdentifier, + } +} + +// WithSetIdentifier sets the set identifier. +func (e *Endpoint) WithSetIdentifier(setIdentifier string) *Endpoint { + e.SetIdentifier = setIdentifier + return e +} + +// WithProviderSpecific attaches a provider-specific property. +func (e *Endpoint) WithProviderSpecific(key, value string) *Endpoint { + e.SetProviderSpecificProperty(key, value) + return e +} + +// GetProviderSpecificProperty returns a provider-specific value by name. +func (e *Endpoint) GetProviderSpecificProperty(key string) (string, bool) { + for _, p := range e.ProviderSpecific { + if p.Name == key { + return p.Value, true + } + } + return "", false +} + +// SetProviderSpecificProperty sets or replaces a provider-specific property. +func (e *Endpoint) SetProviderSpecificProperty(key, value string) { + for i := range e.ProviderSpecific { + if e.ProviderSpecific[i].Name == key { + e.ProviderSpecific[i].Value = value + return + } + } + e.ProviderSpecific = append(e.ProviderSpecific, ProviderSpecificProperty{Name: key, Value: value}) +} + +// WithLabel adds or updates a label. +func (e *Endpoint) WithLabel(key, value string) *Endpoint { + if e.Labels == nil { + e.Labels = make(Labels) + } + e.Labels[key] = value + return e +} + +// GetBoolProviderSpecificProperty parses a boolean provider-specific property. +func (e *Endpoint) GetBoolProviderSpecificProperty(key string) (bool, bool) { + prop, ok := e.GetProviderSpecificProperty(key) + if !ok { + return false, false + } + switch prop { + case "true": + return true, true + case "false": + return false, true + default: + return false, true + } +} + +func (e *Endpoint) supportsAlias() bool { + switch e.RecordType { + case RecordTypeA, RecordTypeAAAA, RecordTypeCNAME: + return true + default: + return false + } +} + +func (e *Endpoint) isAlias() bool { + val, ok := e.GetBoolProviderSpecificProperty(providerSpecificAlias) + return ok && val +} + +// CheckEndpoint validates basic alias / IP constraints (subset of upstream). +func (e *Endpoint) CheckEndpoint() bool { + if !e.supportsAlias() { + if _, ok := e.GetBoolProviderSpecificProperty(providerSpecificAlias); ok { + return false + } + } + return true +} + +// RetainProviderProperties sorts provider-specific entries (upstream subset). +func (e *Endpoint) RetainProviderProperties(provider string) { + if len(e.ProviderSpecific) == 0 { + return + } + if provider != "" && provider != "cloudflare" { + prefix := provider + "/" + e.ProviderSpecific = slices.DeleteFunc(e.ProviderSpecific, func(prop ProviderSpecificProperty) bool { + return strings.Contains(prop.Name, "/") && !strings.HasPrefix(prop.Name, prefix) + }) + } + slices.SortFunc(e.ProviderSpecific, func(a, b ProviderSpecificProperty) int { + return cmp.Compare(a.Name, b.Name) + }) +} diff --git a/pkg/third_party/externaldns/endpoint/endpoint_test.go b/pkg/third_party/externaldns/endpoint/endpoint_test.go new file mode 100644 index 000000000..f7a1b85c3 --- /dev/null +++ b/pkg/third_party/externaldns/endpoint/endpoint_test.go @@ -0,0 +1,286 @@ +// Copyright 2026 Broadcom, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package endpoint + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewTargets_table(t *testing.T) { + tests := []struct { + name string + targets []string + want Targets + }{ + { + name: "empty input", + targets: nil, + want: nil, + }, + { + name: "single target", + targets: []string{"1.2.3.4"}, + want: Targets{"1.2.3.4"}, + }, + { + name: "deduplicated and sorted", + targets: []string{"z.example.com", "a.example.com", "z.example.com"}, + want: Targets{"a.example.com", "z.example.com"}, + }, + { + name: "whitespace trimmed and blank filtered", + targets: []string{" 1.2.3.4 ", "", " "}, + want: Targets{"1.2.3.4"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewTargets(tt.targets...) + require.Equal(t, tt.want, got) + }) + } +} + +func TestEndpointKey_String(t *testing.T) { + k := EndpointKey{DNSName: "a.example.com", RecordType: "A", SetIdentifier: "s1"} + assert.Equal(t, "a.example.com/A/s1", k.String()) +} + +func TestEndpoint_Key(t *testing.T) { + ep := &Endpoint{DNSName: "b.example.com", RecordType: "CNAME", SetIdentifier: "x"} + k := ep.Key() + assert.Equal(t, EndpointKey{DNSName: "b.example.com", RecordType: "CNAME", SetIdentifier: "x"}, k) +} + +func TestEndpoint_WithSetIdentifier(t *testing.T) { + ep := NewEndpoint("a.example.com", RecordTypeA, "1.2.3.4") + require.NotNil(t, ep) + ep2 := ep.WithSetIdentifier("s2") + assert.Same(t, ep, ep2) + assert.Equal(t, "s2", ep.SetIdentifier) +} + +func TestEndpoint_ProviderSpecific_table(t *testing.T) { + ep := NewEndpoint("a.example.com", RecordTypeA, "1.2.3.4") + require.NotNil(t, ep) + + t.Run("set and get present key", func(t *testing.T) { + ep.SetProviderSpecificProperty("foo", "bar") + val, ok := ep.GetProviderSpecificProperty("foo") + require.True(t, ok) + assert.Equal(t, "bar", val) + }) + + t.Run("get absent key", func(t *testing.T) { + val, ok := ep.GetProviderSpecificProperty("missing") + assert.False(t, ok) + assert.Empty(t, val) + }) + + t.Run("update existing key", func(t *testing.T) { + ep.SetProviderSpecificProperty("foo", "baz") + val, ok := ep.GetProviderSpecificProperty("foo") + require.True(t, ok) + assert.Equal(t, "baz", val) + }) + + t.Run("WithProviderSpecific returns same pointer", func(t *testing.T) { + ep2 := ep.WithProviderSpecific("k", "v") + assert.Same(t, ep, ep2) + val, ok := ep.GetProviderSpecificProperty("k") + require.True(t, ok) + assert.Equal(t, "v", val) + }) +} + +func TestEndpoint_WithLabel(t *testing.T) { + ep := NewEndpoint("a.example.com", RecordTypeA, "1.2.3.4") + require.NotNil(t, ep) + ep2 := ep.WithLabel("owner", "me") + assert.Same(t, ep, ep2) + assert.Equal(t, "me", ep.Labels["owner"]) + + // Second call updates existing key without re-allocating + ep.WithLabel("owner", "you") + assert.Equal(t, "you", ep.Labels["owner"]) +} + +func TestEndpoint_GetBoolProviderSpecificProperty_table(t *testing.T) { + tests := []struct { + name string + value string + wantBool bool + wantFound bool + }{ + {name: "absent", value: "", wantBool: false, wantFound: false}, + {name: "true", value: "true", wantBool: true, wantFound: true}, + {name: "false", value: "false", wantBool: false, wantFound: true}, + {name: "invalid", value: "yes", wantBool: false, wantFound: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ep := NewEndpoint("a.example.com", RecordTypeA, "1.2.3.4") + require.NotNil(t, ep) + if tt.value != "" { + ep.SetProviderSpecificProperty("alias", tt.value) + } + got, found := ep.GetBoolProviderSpecificProperty("alias") + assert.Equal(t, tt.wantBool, got) + assert.Equal(t, tt.wantFound, found) + }) + } +} + +func TestEndpoint_supportsAlias_and_isAlias_table(t *testing.T) { + tests := []struct { + name string + recordType string + aliasValue string + wantSupports bool + wantIsAlias bool + }{ + {name: "A supports alias, not set", recordType: RecordTypeA, aliasValue: "", wantSupports: true, wantIsAlias: false}, + {name: "AAAA supports alias", recordType: RecordTypeAAAA, aliasValue: "true", wantSupports: true, wantIsAlias: true}, + {name: "CNAME supports alias", recordType: RecordTypeCNAME, aliasValue: "true", wantSupports: true, wantIsAlias: true}, + {name: "TXT does not support alias", recordType: RecordTypeTXT, aliasValue: "", wantSupports: false, wantIsAlias: false}, + {name: "MX does not support alias", recordType: RecordTypeMX, aliasValue: "", wantSupports: false, wantIsAlias: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ep := &Endpoint{DNSName: "a.example.com", RecordType: tt.recordType} + if tt.aliasValue != "" { + ep.SetProviderSpecificProperty(providerSpecificAlias, tt.aliasValue) + } + assert.Equal(t, tt.wantSupports, ep.supportsAlias()) + assert.Equal(t, tt.wantIsAlias, ep.isAlias()) + }) + } +} + +func TestEndpoint_CheckEndpoint_table(t *testing.T) { + tests := []struct { + name string + recordType string + aliasSet bool + want bool + }{ + {name: "A without alias prop", recordType: RecordTypeA, aliasSet: false, want: true}, + {name: "A with alias prop (allowed)", recordType: RecordTypeA, aliasSet: true, want: true}, + {name: "TXT without alias prop", recordType: RecordTypeTXT, aliasSet: false, want: true}, + {name: "TXT with alias prop (not allowed)", recordType: RecordTypeTXT, aliasSet: true, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ep := &Endpoint{DNSName: "a.example.com", RecordType: tt.recordType} + if tt.aliasSet { + ep.SetProviderSpecificProperty(providerSpecificAlias, "true") + } + assert.Equal(t, tt.want, ep.CheckEndpoint()) + }) + } +} + +func TestEndpoint_RetainProviderProperties_table(t *testing.T) { + build := func(props ...ProviderSpecificProperty) *Endpoint { + ep := &Endpoint{DNSName: "a.example.com", RecordType: RecordTypeA} + ep.ProviderSpecific = props + return ep + } + + t.Run("empty props no-op", func(t *testing.T) { + ep := build() + ep.RetainProviderProperties("aws") + assert.Empty(t, ep.ProviderSpecific) + }) + + t.Run("cloudflare keeps all and sorts", func(t *testing.T) { + ep := build( + ProviderSpecificProperty{Name: "z/prop", Value: "1"}, + ProviderSpecificProperty{Name: "a/other", Value: "2"}, + ) + ep.RetainProviderProperties("cloudflare") + require.Len(t, ep.ProviderSpecific, 2) + assert.Equal(t, "a/other", ep.ProviderSpecific[0].Name) + }) + + t.Run("named provider filters and sorts", func(t *testing.T) { + ep := build( + ProviderSpecificProperty{Name: "aws/ttl", Value: "300"}, + ProviderSpecificProperty{Name: "gcp/routing", Value: "geo"}, + ProviderSpecificProperty{Name: "aws/alias", Value: "true"}, + ProviderSpecificProperty{Name: "noslash", Value: "x"}, + ) + ep.RetainProviderProperties("aws") + // keeps aws/* and noslash (no slash → not filtered), then sorts + require.Len(t, ep.ProviderSpecific, 3) + assert.Equal(t, "aws/alias", ep.ProviderSpecific[0].Name) + assert.Equal(t, "aws/ttl", ep.ProviderSpecific[1].Name) + assert.Equal(t, "noslash", ep.ProviderSpecific[2].Name) + }) + + t.Run("empty provider keeps all and sorts", func(t *testing.T) { + ep := build( + ProviderSpecificProperty{Name: "b", Value: "2"}, + ProviderSpecificProperty{Name: "a", Value: "1"}, + ) + ep.RetainProviderProperties("") + require.Len(t, ep.ProviderSpecific, 2) + assert.Equal(t, "a", ep.ProviderSpecific[0].Name) + }) +} + +func TestNewEndpointWithTTL_table(t *testing.T) { + tests := []struct { + name string + dnsName string + recordType string + ttl TTL + targets []string + wantNil bool + wantName string + }{ + { + name: "normal A record", + dnsName: "a.example.com", + recordType: RecordTypeA, + targets: []string{"1.2.3.4"}, + wantName: "a.example.com", + }, + { + name: "trailing dot stripped", + dnsName: "a.example.com.", + recordType: RecordTypeA, + targets: []string{"1.2.3.4."}, + wantName: "a.example.com", + }, + { + name: "TXT target not trimmed", + dnsName: "a.example.com", + recordType: RecordTypeTXT, + targets: []string{"some text."}, + wantName: "a.example.com", + }, + { + name: "label longer than 63 chars returns nil", + dnsName: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com", + recordType: RecordTypeA, + targets: []string{"1.2.3.4"}, + wantNil: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ep := NewEndpointWithTTL(tt.dnsName, tt.recordType, tt.ttl, tt.targets...) + if tt.wantNil { + assert.Nil(t, ep) + } else { + require.NotNil(t, ep) + assert.Equal(t, tt.wantName, ep.DNSName) + } + }) + } +} diff --git a/pkg/third_party/externaldns/endpoint/utils.go b/pkg/third_party/externaldns/endpoint/utils.go new file mode 100644 index 000000000..c982c4c24 --- /dev/null +++ b/pkg/third_party/externaldns/endpoint/utils.go @@ -0,0 +1,49 @@ +// Copyright 2026 The Kubernetes Authors. +// Copyright 2026 Broadcom, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Derived from sigs.k8s.io/external-dns/endpoint/utils.go +// Attribution: see package doc.go. + +package endpoint + +import ( + "net/netip" +) + +// SuitableType returns the DNS record type for the given target: +// A for IPv4, AAAA for IPv6, CNAME for everything else. +func SuitableType(target string) string { + ip, err := netip.ParseAddr(target) + if err != nil { + return RecordTypeCNAME + } + if ip.Is4() { + return RecordTypeA + } + return RecordTypeAAAA +} + +// EndpointsForHostname returns endpoint objects for each host–target combination, +// grouping targets by suitable DNS record type (A, AAAA, or CNAME). +// nsx-operator does not attach ExternalDNS registry labels here; gateway controllers may set Labels (e.g. parent Gateway) before DNS rows reach the DNS store. +func EndpointsForHostname(hostname string, targets Targets, ttl TTL) []*Endpoint { + byType := map[string]Targets{} + for _, t := range targets { + rt := SuitableType(t) + byType[rt] = append(byType[rt], t) + } + var endpoints []*Endpoint + for _, rt := range []string{RecordTypeA, RecordTypeAAAA, RecordTypeCNAME} { + if len(byType[rt]) == 0 { + continue + } + ep := NewEndpointWithTTL(hostname, rt, ttl, byType[rt]...) + if ep == nil { + continue + } + endpoints = append(endpoints, ep) + } + return endpoints +} diff --git a/pkg/third_party/externaldns/endpoint/utils_test.go b/pkg/third_party/externaldns/endpoint/utils_test.go new file mode 100644 index 000000000..ce318c789 --- /dev/null +++ b/pkg/third_party/externaldns/endpoint/utils_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 Broadcom, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package endpoint + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSuitableType_table(t *testing.T) { + tests := []struct { + target string + want string + }{ + {target: "1.2.3.4", want: RecordTypeA}, + {target: "2001:db8::1", want: RecordTypeAAAA}, + {target: "example.com", want: RecordTypeCNAME}, + {target: "not-an-ip", want: RecordTypeCNAME}, + } + for _, tt := range tests { + t.Run(tt.target, func(t *testing.T) { + assert.Equal(t, tt.want, SuitableType(tt.target)) + }) + } +} + +func TestEndpointsForHostname_table(t *testing.T) { + tests := []struct { + name string + hostname string + targets Targets + ttl TTL + wantLen int + wantTypes []string + }{ + { + name: "empty targets", + hostname: "a.example.com", + targets: nil, + wantLen: 0, + wantTypes: nil, + }, + { + name: "mixed ipv4 and ipv6", + hostname: "a.example.com", + targets: Targets{"1.2.3.4", "2001:db8::1"}, + wantLen: 2, + wantTypes: []string{RecordTypeA, RecordTypeAAAA}, + }, + { + name: "cname target", + hostname: "a.example.com", + targets: Targets{"target.example.com"}, + wantLen: 1, + wantTypes: []string{RecordTypeCNAME}, + }, + { + name: "multiple ipv4 grouped into single A endpoint", + hostname: "a.example.com", + targets: Targets{"1.2.3.4", "5.6.7.8"}, + wantLen: 1, + wantTypes: []string{RecordTypeA}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + eps := EndpointsForHostname(tt.hostname, tt.targets, tt.ttl) + require.Len(t, eps, tt.wantLen) + for i, ep := range eps { + assert.Equal(t, tt.wantTypes[i], ep.RecordType) + } + }) + } +} diff --git a/pkg/third_party/externaldns/provider/doc.go b/pkg/third_party/externaldns/provider/doc.go new file mode 100644 index 000000000..ad5cab931 --- /dev/null +++ b/pkg/third_party/externaldns/provider/doc.go @@ -0,0 +1,8 @@ +// Package provider mirrors small pieces of Kubernetes ExternalDNS +// (https://github.com/kubernetes-sigs/external-dns, Go module sigs.k8s.io/external-dns) +// that are not tied to a specific cloud provider implementation. +// +// zonefinder.go follows external-dns/provider/zonefinder.go (ZoneIDName, FindZone): longest +// suffix-matching zone selection and per-label IDNA (Unicode) normalization, including skipping +// IDNA conversion for labels that contain underscores (SRV/TXT-style names). +package provider diff --git a/pkg/third_party/externaldns/provider/zonefinder.go b/pkg/third_party/externaldns/provider/zonefinder.go new file mode 100644 index 000000000..f350a4974 --- /dev/null +++ b/pkg/third_party/externaldns/provider/zonefinder.go @@ -0,0 +1,76 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Derived from sigs.k8s.io/external-dns/provider/zonefinder.go (ZoneIDName, FindZone). +*/ + +package provider + +import ( + "strings" + + "golang.org/x/net/idna" +) + +// idnaLookup matches external-dns/internal/idna.Profile (MapForLookup + Transitional + not strict domain). +var idnaLookup = idna.New( + idna.MapForLookup(), + idna.Transitional(true), + idna.StrictDomainName(false), +) + +// ZoneIDName maps a stable zone identifier (e.g. NSX policy path) to a DNS zone name (Unicode). +type ZoneIDName map[string]string + +// Add inserts zoneID → zoneName, storing zoneName in Unicode form per IDNA when possible (same as upstream). +func (z ZoneIDName) Add(zoneID, zoneName string) { + u, err := idnaLookup.ToUnicode(zoneName) + if err != nil { + z[zoneID] = zoneName + return + } + z[zoneID] = u +} + +// FindZone identifies the most suitable DNS zone for a given hostname (longest suffix match). +// It returns the zone ID and zone name that best match the hostname, plus normalizedName: hostname +// with per-label IDNA ToUnicode applied (underscore labels are left unchanged), joined with ".". +// +// This mirrors sigs.k8s.io/external-dns/provider.ZoneIDName.FindZone, extended with normalizedName +// so callers can trim the matched suffix using the same string used for comparisons. +func (z ZoneIDName) FindZone(hostname string) (zoneID, zoneName, normalizedName string) { + domainLabels := strings.Split(hostname, ".") + for i, label := range domainLabels { + if strings.Contains(label, "_") { + continue + } + convertedLabel, err := idnaLookup.ToUnicode(label) + if err != nil { + convertedLabel = label + } + domainLabels[i] = convertedLabel + } + normalizedName = strings.Join(domainLabels, ".") + + for zid, zname := range z { + if normalizedName == zname || strings.HasSuffix(normalizedName, "."+zname) { + if zoneName == "" || len(zname) > len(zoneName) { + zoneID = zid + zoneName = zname + } + } + } + return zoneID, zoneName, normalizedName +} diff --git a/pkg/third_party/externaldns/provider/zonefinder_test.go b/pkg/third_party/externaldns/provider/zonefinder_test.go new file mode 100644 index 000000000..32af709dc --- /dev/null +++ b/pkg/third_party/externaldns/provider/zonefinder_test.go @@ -0,0 +1,124 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package provider + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestZoneIDName_Add_table(t *testing.T) { + tests := []struct { + name string + zoneID string + zoneName string + wantStored string + }{ + { + name: "valid ASCII zone name stored as-is", + zoneID: "z1", + zoneName: "example.com", + wantStored: "example.com", + }, + { + name: "punycode converted to unicode", + zoneID: "z2", + zoneName: "xn--testcass-e1ae.fr", + wantStored: "testécassé.fr", + }, + { + name: "invalid punycode stored verbatim", + zoneID: "z3", + zoneName: "xn--invalid--punycode", + wantStored: "xn--invalid--punycode", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + z := make(ZoneIDName) + z.Add(tt.zoneID, tt.zoneName) + require.Equal(t, tt.wantStored, z[tt.zoneID]) + }) + } +} + +func TestZoneIDName_FindZone_table(t *testing.T) { + tests := []struct { + name string + setup func(z ZoneIDName) + hostname string + wantID string + wantName string + wantNormHost string + }{ + { + name: "longest_suffix_wins", + setup: func(z ZoneIDName) { + z.Add("z1", "qux.baz") + z.Add("z2", "foo.qux.baz") + }, + hostname: "name.foo.qux.baz", + wantID: "z2", + wantName: "foo.qux.baz", + wantNormHost: "name.foo.qux.baz", + }, + { + name: "shorter_zone_when_deeper_zone_missing", + setup: func(z ZoneIDName) { + z.Add("z1", "qux.baz") + z.Add("z2", "foo.qux.baz") + }, + hostname: "name.qux.baz", + wantID: "z1", + wantName: "qux.baz", + wantNormHost: "name.qux.baz", + }, + { + name: "punycode_zone_matches_unicode_hostname", + setup: func(z ZoneIDName) { + z.Add("zone1", "xn--testcass-e1ae.fr") + }, + hostname: "example.xn--testcass-e1ae.fr", + wantID: "zone1", + wantName: "testécassé.fr", + wantNormHost: "example.testécassé.fr", + }, + { + name: "underscore_metadata_label_zone", + setup: func(z ZoneIDName) { + z.Add("zmeta", "_foo._metadata.example.com") + }, + hostname: "_foo._metadata.example.com", + wantID: "zmeta", + wantName: "_foo._metadata.example.com", + wantNormHost: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + z := make(ZoneIDName) + tt.setup(z) + id, name, norm := z.FindZone(tt.hostname) + require.Equal(t, tt.wantID, id) + require.Equal(t, tt.wantName, name) + if tt.wantNormHost != "" { + require.Equal(t, tt.wantNormHost, norm) + } + }) + } +} diff --git a/pkg/util/utils.go b/pkg/util/utils.go index a074663c4..5784a5a84 100644 --- a/pkg/util/utils.go +++ b/pkg/util/utils.go @@ -348,7 +348,7 @@ func GenerateTruncName(limit int, resName string, prefix, suffix, project, clust return generateDisplayName(common.ConnectorUnderline, resName, prefix, suffix, project, cluster) } -func BuildBasicTags(cluster string, obj interface{}, namespaceID types.UID) []model.Tag { +func BuildClusterTags(cluster string) []model.Tag { tags := []model.Tag{ { Scope: String(common.TagScopeCluster), @@ -359,6 +359,11 @@ func BuildBasicTags(cluster string, obj interface{}, namespaceID types.UID) []mo Tag: String(strings.Join(common.TagValueVersion, ".")), }, } + return tags +} + +func BuildBasicTags(cluster string, obj interface{}, namespaceID types.UID) []model.Tag { + tags := BuildClusterTags(cluster) switch i := obj.(type) { case *v1alpha1.StaticRoute: tags = append(tags, model.Tag{Scope: String(common.TagScopeNamespace), Tag: String(i.ObjectMeta.Namespace)}) From 007cac0fdea86cbe061c64576b5450117bea6929 Mon Sep 17 00:00:00 2001 From: Wenying Dong Date: Wed, 29 Apr 2026 00:03:57 +0800 Subject: [PATCH 2/3] feat(networkinfo): AllowedDNSDomains from VPC DNS zones Wire NetworkInfo reconciler to DNSRecordService for per-namespace allowed DNS domains derived from VPC DNS zone configuration. Register DNS record service initialization in cmd when VPC networking is enabled. --- cmd/main.go | 8 +- .../networkinfo/networkinfo_controller.go | 40 +++++- .../networkinfo_controller_test.go | 133 ++++++++++++------ .../networkinfo/networkinfo_utils.go | 31 +++- .../networkinfo/networkinfo_utils_test.go | 27 ++++ 5 files changed, 184 insertions(+), 55 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 25b37a751..4d8c595f3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -58,6 +58,7 @@ import ( "github.com/vmware-tanzu/nsx-operator/pkg/metrics" "github.com/vmware-tanzu/nsx-operator/pkg/nsx" "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" + "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/dns" ipaddressallocationservice "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/ipaddressallocation" "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/vpc" "github.com/vmware-tanzu/nsx-operator/pkg/nsx/util" @@ -192,6 +193,11 @@ func startServiceController(mgr manager.Manager, nsxClient *nsx.Client) { log.Error(err, "Failed to initialize staticroute commonService", "controller", "StaticRoute") os.Exit(1) } + dnsRecordService, err := dns.InitializeDNSRecordService(commonService, vpcService) + if err != nil { + log.Error(err, "Failed to initialize DNS record service", "controller", "DNS") + os.Exit(1) + } ipblocksInfoService := ipblocksinfo.InitializeIPBlocksInfoService(commonService, subnetService) subnetBindingService, err := subnetbindingservice.InitializeService(commonService) @@ -230,7 +236,7 @@ func startServiceController(mgr manager.Manager, nsxClient *nsx.Client) { subnetSetReconcile = subnetset.NewSubnetSetReconciler(mgr, subnetService, subnetPortService, vpcService, subnetBindingService) reconcilerList = append( reconcilerList, - networkinfocontroller.NewNetworkInfoReconciler(mgr, vpcService, ipblocksInfoService), + networkinfocontroller.NewNetworkInfoReconciler(mgr, vpcService, ipblocksInfoService, dnsRecordService), namespacecontroller.NewNamespaceReconciler(mgr, cf, vpcService, subnetService, subnetPortService), subnet.NewSubnetReconciler(mgr, subnetService, subnetPortService, vpcService, subnetBindingService), subnetSetReconcile, diff --git a/pkg/controllers/networkinfo/networkinfo_controller.go b/pkg/controllers/networkinfo/networkinfo_controller.go index 4f5f9a5d8..2fcc4bec8 100644 --- a/pkg/controllers/networkinfo/networkinfo_controller.go +++ b/pkg/controllers/networkinfo/networkinfo_controller.go @@ -6,7 +6,9 @@ package networkinfo import ( "context" "fmt" + "maps" "net" + "slices" "strings" "time" @@ -72,6 +74,7 @@ var ( nsMsgVPCAutoSNATDisabled = newNsUnreadyMessage("SNAT is not enabled in System VPC", NSReasonVPCSnatNotReady) nsMsgVPCDefaultSNATIPGetError = newNsUnreadyMessage("Default SNAT IP is not allocated in VPC: %v", NSReasonVPCSnatNotReady) nsMsgVPCIsReady = newNsUnreadyMessage("", "") + nsMsgVPCDNSZonesSyncError = newNsUnreadyMessage("Failed to sync permitted DNS zones from NSX: %v", NSReasonVPCNotReady) ) type nsUnreadyMessage struct { @@ -99,12 +102,18 @@ func (m *nsUnreadyMessage) getNSNetworkCondition(options ...interface{}) *corev1 return cond } +// dnsZoneSyncer is the minimal DNS interface needed by NetworkInfoReconciler for VPC DNS zone lookups. +type dnsZoneSyncer interface { + SyncDNSZonesByVpcNetworkConfig(vpcConfig *v1alpha1.VPCNetworkConfiguration) (map[string]string, error) +} + // NetworkInfoReconciler NetworkInfoReconcile reconciles a NetworkInfo object // Actually it is more like a shell, which is used to manage nsx VPC type NetworkInfoReconciler struct { Client client.Client Scheme *apimachineryruntime.Scheme Service *vpc.VPCService + DNSRecordService dnsZoneSyncer IPBlocksInfoService *ipblocksinfo.IPBlocksInfoService Recorder record.EventRecorder queue workqueue.TypedRateLimitingInterface[reconcile.Request] @@ -445,9 +454,29 @@ func (r *NetworkInfoReconciler) Reconcile(ctx context.Context, req ctrl.Request) NetworkStack: networkStack, } + var allowedDNSDomains []string + if r.DNSRecordService != nil && len(nc.Spec.DNSZones) > 0 { + zoneMap, err := r.DNSRecordService.SyncDNSZonesByVpcNetworkConfig(nc) + if err != nil { + r.StatusUpdater.UpdateFail(ctx, networkInfoCR, err, "Failed to sync DNS zones for VPC network configuration", setNetworkInfoVPCStatusWithError, state) + setNSNetworkReadyCondition(ctx, r.Client, req.Namespace, nsMsgVPCDNSZonesSyncError.getNSNetworkCondition(err)) + return common.ResultRequeueAfter10sec, err + } + // Use a Set to ensure each domain name in the allowed list is unique. + domainNamesSet := sets.New[string]() + for _, domainName := range slices.Sorted(maps.Values(zoneMap)) { + if domainName == "" { + continue + } + domainNamesSet.Insert(domainName) + } + allowedDNSDomains = domainNamesSet.UnsortedList() + slices.Sort(allowedDNSDomains) + } + // AKO needs to know the AVI subnet path created by NSX setVPCNetworkConfigurationStatusWithLBS(ctx, r.Client, ncName, state.Name, aviSubnetPath, nsxLBSPath, *createdVpc.Path) - r.StatusUpdater.UpdateSuccess(ctx, networkInfoCR, setNetworkInfoVPCStatus, state) + r.StatusUpdater.UpdateSuccess(ctx, networkInfoCR, setNetworkInfoVPCStatus, state, allowedDNSDomains) if retryWithSystemVPC { setNSNetworkReadyCondition(ctx, r.Client, req.Namespace, systemNSCondition) @@ -844,11 +873,12 @@ func (r *NetworkInfoReconciler) StartController(mgr ctrl.Manager, _ webhook.Serv return nil } -func NewNetworkInfoReconciler(mgr ctrl.Manager, vpcService *vpc.VPCService, ipblocksInfoService *ipblocksinfo.IPBlocksInfoService) *NetworkInfoReconciler { +func NewNetworkInfoReconciler(mgr ctrl.Manager, vpcService *vpc.VPCService, ipblocksInfoService *ipblocksinfo.IPBlocksInfoService, dnsRecordService dnsZoneSyncer) *NetworkInfoReconciler { networkInfoReconciler := &NetworkInfoReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: mgr.GetEventRecorderFor("networkinfo-controller"), //nolint:staticcheck // record.EventRecorder; StatusUpdater not on events.EventRecorder yet + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + DNSRecordService: dnsRecordService, + Recorder: mgr.GetEventRecorderFor("networkinfo-controller"), //nolint:staticcheck // record.EventRecorder; StatusUpdater not on events.EventRecorder yet } networkInfoReconciler.Service = vpcService networkInfoReconciler.IPBlocksInfoService = ipblocksInfoService diff --git a/pkg/controllers/networkinfo/networkinfo_controller_test.go b/pkg/controllers/networkinfo/networkinfo_controller_test.go index b23504d08..612060ec1 100644 --- a/pkg/controllers/networkinfo/networkinfo_controller_test.go +++ b/pkg/controllers/networkinfo/networkinfo_controller_test.go @@ -24,6 +24,7 @@ import ( utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/apimachinery/pkg/util/sets" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/cache" "k8s.io/client-go/tools/record" "k8s.io/client-go/util/workqueue" controllerruntime "sigs.k8s.io/controller-runtime" @@ -38,6 +39,7 @@ import ( mock_client "github.com/vmware-tanzu/nsx-operator/pkg/mock/controller-runtime/client" "github.com/vmware-tanzu/nsx-operator/pkg/nsx" servicecommon "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" + "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/dns" "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/ipblocksinfo" "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/vpc" nsxutil "github.com/vmware-tanzu/nsx-operator/pkg/nsx/util" @@ -89,6 +91,13 @@ func (c *fakeVpcAttachmentClient) Delete(orgIdParam string, projectIdParam strin var fakeAttachmentClient = &fakeVpcAttachmentClient{} +type fakeQueryClient struct{} + +func (*fakeQueryClient) List(_ string, _ *string, _ *string, _ *int64, _ *bool, _ *string) (model.SearchResponse, error) { + rc := int64(0) + return model.SearchResponse{ResultCount: &rc}, nil +} + type fakeRecorder struct{} func (recorder fakeRecorder) Event(object runtime.Object, eventtype, reason, message string) { @@ -110,16 +119,33 @@ func createNetworkInfoReconciler(objs []client.Object) *NetworkInfoReconciler { BindingType: model.VpcBindingType(), }} + lbsStore := &vpc.LBSStore{ResourceStore: servicecommon.ResourceStore{ + Indexer: cache.NewIndexer(func(obj interface{}) (string, error) { + lbs, ok := obj.(*model.LBService) + if !ok || lbs.Id == nil { + return "", fmt.Errorf("unexpected LBSStore object type %T", obj) + } + return *lbs.Id, nil + }, cache.Indexers{}), + BindingType: model.LBServiceBindingType(), + }} + service := &vpc.VPCService{ VpcStore: vpcStore, + LbsStore: lbsStore, Service: servicecommon.Service{ Client: fakeClient, NSXClient: &nsx.Client{ VPCConnectivityProfilesClient: &fakeVPCConnectivityProfilesClient{}, VpcAttachmentClient: fakeAttachmentClient, + QueryClient: &fakeQueryClient{}, + NsxConfig: &config.NSXOperatorConfig{ + CoeConfig: &config.CoeConfig{Cluster: "unit-test"}, + }, }, NSXConfig: &config.NSXOperatorConfig{ + CoeConfig: &config.CoeConfig{Cluster: "unit-test"}, NsxConfig: &config.NsxConfig{ EnforcementPoint: "vmc-enforcementpoint", UseAVILoadBalancer: false, @@ -129,11 +155,17 @@ func createNetworkInfoReconciler(objs []client.Object) *NetworkInfoReconciler { }, } + dnsSvc, err := dns.InitializeDNSRecordService(service.Service, service) + if err != nil { + panic(err) + } + r := &NetworkInfoReconciler{ - Client: fakeClient, - Scheme: fake.NewClientBuilder().Build().Scheme(), - Service: service, - Recorder: &fakeRecorder{}, + Client: fakeClient, + Scheme: fake.NewClientBuilder().Build().Scheme(), + Service: service, + DNSRecordService: dnsSvc, + Recorder: &fakeRecorder{}, } r.StatusUpdater = common.NewStatusUpdater(r.Client, r.Service.NSXConfig, r.Recorder, MetricResType, "VPC", "NetworkInfo") return r @@ -156,8 +188,8 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { { name: "Empty", prepareFunc: func(t *testing.T, r *NetworkInfoReconciler, ctx context.Context) (patches *gomonkey.Patches) { - patches = gomonkey.ApplyMethod(reflect.TypeOf(r.Service), "GetVPCsByNamespace", func(_ *vpc.VPCService, _ string) []*model.Vpc { - return []*model.Vpc{} + patches = gomonkey.ApplyPrivateMethod(reflect.TypeOf(r), "deleteVPCsByNamespace", func(_ *NetworkInfoReconciler, _ context.Context, _ string) error { + return nil }) return patches }, @@ -198,7 +230,7 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { ServiceClusterReason: servicecommon.ReasonServiceClusterNotSet, }, nil }) - patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider) (*model.Vpc, error) { + patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider, _ bool, _ bool) (*model.Vpc, error) { return &model.Vpc{ DisplayName: servicecommon.String("vpc-name"), Path: servicecommon.String("/orgs/default/projects/project-quality/vpcs/fake-vpc"), @@ -270,7 +302,7 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { ServiceClusterReady: true, }, nil }) - patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider) (*model.Vpc, error) { + patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider, _ bool, _ bool) (*model.Vpc, error) { return &model.Vpc{ DisplayName: servicecommon.String("vpc-name"), Path: servicecommon.String("/orgs/default/projects/project-quality/vpcs/fake-vpc"), @@ -319,8 +351,12 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { }, })) assert.NoError(t, r.Client.Create(ctx, &v1alpha1.VPCNetworkConfiguration{ - ObjectMeta: metav1.ObjectMeta{ - Name: "system", + ObjectMeta: metav1.ObjectMeta{Name: "system"}, + Status: v1alpha1.VPCNetworkConfigurationStatus{ + Conditions: []v1alpha1.Condition{{ + Type: v1alpha1.GatewayConnectionReady, + Status: corev1.ConditionTrue, + }}, }, })) patches = gomonkey.ApplyMethod(reflect.TypeOf(r.Service), "GetNetworkconfigNameFromNS", func(_ *vpc.VPCService, _ context.Context, _ string) (string, error) { @@ -335,9 +371,6 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { }, }, true, nil }) - patches.ApplyFunc(getGatewayConnectionStatus, func(_ *v1alpha1.VPCNetworkConfiguration) (bool, string) { - return true, "" - }) patches.ApplyMethod(reflect.TypeOf(r.Service), "ValidateConnectionStatus", func(_ *vpc.VPCService, _ *v1alpha1.VPCNetworkConfiguration, _ string) (*servicecommon.VPCConnectionStatus, error) { assert.FailNow(t, "should not be called") return &servicecommon.VPCConnectionStatus{ @@ -346,7 +379,7 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { ServiceClusterReason: servicecommon.ReasonServiceClusterNotSet, }, nil }) - patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider) (*model.Vpc, error) { + patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider, _ bool, _ bool) (*model.Vpc, error) { return &model.Vpc{ DisplayName: servicecommon.String("vpc-name"), Path: servicecommon.String("/orgs/default/projects/project-quality/vpcs/fake-vpc"), @@ -420,7 +453,7 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { ServiceClusterReason: servicecommon.ReasonServiceClusterNotSet, }, nil }) - patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider) (*model.Vpc, error) { + patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider, _ bool, _ bool) (*model.Vpc, error) { assert.FailNow(t, "should not be called") return &model.Vpc{}, nil }) @@ -473,7 +506,7 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { patches.ApplyMethod(reflect.TypeOf(r.Service), "GetNSXLBSNATIP", func(_ *vpc.VPCService, _ model.Vpc, _ string) ([]string, error) { return []string{"100.64.0.3"}, nil }) - patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider) (*model.Vpc, error) { + patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider, _ bool, _ bool) (*model.Vpc, error) { return &model.Vpc{ DisplayName: servicecommon.String("vpc-name"), Path: servicecommon.String("/orgs/default/projects/project-quality/vpcs/fake-vpc"), @@ -565,7 +598,7 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { patches.ApplyMethod(reflect.TypeOf(r.Service), "GetNSXLBSNATIP", func(_ *vpc.VPCService, _ model.Vpc, _ string) ([]string, error) { return []string{"100.64.0.3"}, nil }) - patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider) (*model.Vpc, error) { + patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider, _ bool, _ bool) (*model.Vpc, error) { return &model.Vpc{ DisplayName: servicecommon.String("vpc-name"), Path: servicecommon.String("/orgs/default/projects/project-quality/vpcs/fake-vpc"), @@ -647,7 +680,7 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { patches.ApplyMethod(reflect.TypeOf(r.Service), "GetNSXLBSNATIP", func(_ *vpc.VPCService, _ model.Vpc, _ string) ([]string, error) { return []string{"100.64.0.3"}, nil }) - patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider) (*model.Vpc, error) { + patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider, _ bool, _ bool) (*model.Vpc, error) { return &model.Vpc{ DisplayName: servicecommon.String("vpc-name"), Path: servicecommon.String("/orgs/default/projects/project-quality/vpcs/fake-vpc"), @@ -701,8 +734,12 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { }, })) assert.NoError(t, r.Client.Create(ctx, &v1alpha1.VPCNetworkConfiguration{ - ObjectMeta: metav1.ObjectMeta{ - Name: "system", + ObjectMeta: metav1.ObjectMeta{Name: "system"}, + Status: v1alpha1.VPCNetworkConfigurationStatus{ + Conditions: []v1alpha1.Condition{{ + Type: v1alpha1.GatewayConnectionReady, + Status: corev1.ConditionTrue, + }}, }, })) patches = gomonkey.ApplyMethod(reflect.TypeOf(r.Service), "GetNetworkconfigNameFromNS", func(_ *vpc.VPCService, ctx context.Context, _ string) (string, error) { @@ -717,9 +754,6 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { }, }, true, nil }) - patches.ApplyFunc(getGatewayConnectionStatus, func(_ *v1alpha1.VPCNetworkConfiguration) (bool, string) { - return true, "" - }) patches.ApplyMethod(reflect.TypeOf(r.Service), "ValidateConnectionStatus", func(_ *vpc.VPCService, _ *v1alpha1.VPCNetworkConfiguration, _ string) (*servicecommon.VPCConnectionStatus, error) { return &servicecommon.VPCConnectionStatus{ GatewayConnectionReady: true, @@ -733,7 +767,7 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { patches.ApplyMethod(reflect.TypeOf(r.Service), "GetNSXLBSNATIP", func(_ *vpc.VPCService, _ model.Vpc, _ string) ([]string, error) { return []string{"100.64.0.3"}, nil }) - patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider) (*model.Vpc, error) { + patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider, _ bool, _ bool) (*model.Vpc, error) { return &model.Vpc{ DisplayName: servicecommon.String("vpc-name"), Path: servicecommon.String("/orgs/default/projects/project-quality/vpcs/fake-vpc"), @@ -820,7 +854,7 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { patches.ApplyMethod(reflect.TypeOf(r.Service), "GetNSXLBSNATIP", func(_ *vpc.VPCService, _ model.Vpc, _ string) ([]string, error) { return []string{"100.64.0.3"}, nil }) - patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider) (*model.Vpc, error) { + patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider, _ bool, _ bool) (*model.Vpc, error) { return &model.Vpc{ DisplayName: servicecommon.String("vpc-name"), Path: servicecommon.String("/orgs/default/projects/project-quality/vpcs/fake-vpc"), @@ -876,8 +910,12 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { }, })) assert.NoError(t, r.Client.Create(ctx, &v1alpha1.VPCNetworkConfiguration{ - ObjectMeta: metav1.ObjectMeta{ - Name: "system", + ObjectMeta: metav1.ObjectMeta{Name: "system"}, + Status: v1alpha1.VPCNetworkConfigurationStatus{ + Conditions: []v1alpha1.Condition{{ + Type: v1alpha1.GatewayConnectionReady, + Status: corev1.ConditionTrue, + }}, }, })) patches = gomonkey.ApplyMethod(reflect.TypeOf(r.Service), "GetNetworkconfigNameFromNS", func(_ *vpc.VPCService, ctx context.Context, _ string) (string, error) { @@ -894,13 +932,10 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { }, }, true, nil }) - patches.ApplyFunc(getGatewayConnectionStatus, func(_ *v1alpha1.VPCNetworkConfiguration) (bool, string) { - return true, "" - }) patches.ApplyMethod(reflect.TypeOf(r.Service), "GetLBProvider", func(_ *vpc.VPCService) (vpc.LBProvider, error) { return vpc.AVILB, nil }) - patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider) (*model.Vpc, error) { + patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider, _ bool, _ bool) (*model.Vpc, error) { return &model.Vpc{ DisplayName: servicecommon.String("vpc-name"), Path: servicecommon.String("/orgs/default/projects/project-quality/vpcs/fake-vpc"), @@ -940,8 +975,12 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { }, })) assert.NoError(t, r.Client.Create(ctx, &v1alpha1.VPCNetworkConfiguration{ - ObjectMeta: metav1.ObjectMeta{ - Name: "system", + ObjectMeta: metav1.ObjectMeta{Name: "system"}, + Status: v1alpha1.VPCNetworkConfigurationStatus{ + Conditions: []v1alpha1.Condition{{ + Type: v1alpha1.GatewayConnectionReady, + Status: corev1.ConditionTrue, + }}, }, })) patches = gomonkey.ApplyMethod(reflect.TypeOf(r.Service), "GetNetworkconfigNameFromNS", func(_ *vpc.VPCService, ctx context.Context, _ string) (string, error) { @@ -958,13 +997,10 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { }, }, true, nil }) - patches.ApplyFunc(getGatewayConnectionStatus, func(_ *v1alpha1.VPCNetworkConfiguration) (bool, string) { - return true, "" - }) patches.ApplyMethod(reflect.TypeOf(r.Service), "GetLBProvider", func(_ *vpc.VPCService) (vpc.LBProvider, error) { return vpc.NSXLB, nil }) - patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider) (*model.Vpc, error) { + patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider, _ bool, _ bool) (*model.Vpc, error) { return &model.Vpc{ DisplayName: servicecommon.String("vpc-name"), Path: servicecommon.String("/orgs/default/projects/project-quality/vpcs/fake-vpc"), @@ -1039,7 +1075,7 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { patches.ApplyMethod(reflect.TypeOf(r.Service), "GetLBProvider", func(_ *vpc.VPCService) (vpc.LBProvider, error) { return vpc.AVILB, nil }) - patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider) (*model.Vpc, error) { + patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider, _ bool, _ bool) (*model.Vpc, error) { return &model.Vpc{ DisplayName: servicecommon.String("vpc-name"), Path: servicecommon.String("/orgs/default/projects/project-quality/vpcs/fake-vpc"), @@ -1101,7 +1137,7 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { return vpc.AVILB, nil }) // Return VPC with LoadBalancerVpcEndpoint.Enabled = true to trigger AVI LB path retrieval - patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider) (*model.Vpc, error) { + patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider, _ bool, _ bool) (*model.Vpc, error) { return &model.Vpc{ DisplayName: servicecommon.String("vpc-name"), Path: servicecommon.String("/orgs/default/projects/project-quality/vpcs/fake-vpc"), @@ -1171,7 +1207,7 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { patches.ApplyMethod(reflect.TypeOf(r.Service), "GetLBProvider", func(_ *vpc.VPCService) (vpc.LBProvider, error) { return vpc.NSXLB, nil }) - patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider) (*model.Vpc, error) { + patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider, _ bool, _ bool) (*model.Vpc, error) { return &model.Vpc{ DisplayName: servicecommon.String("vpc-name"), Path: servicecommon.String("/orgs/default/projects/project-quality/vpcs/fake-vpc"), @@ -1243,7 +1279,7 @@ func TestNetworkInfoReconciler_Reconcile(t *testing.T) { patches.ApplyMethod(reflect.TypeOf(r.Service), "GetLBProvider", func(_ *vpc.VPCService) (vpc.LBProvider, error) { return vpc.NSXLB, nil }) - patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider) (*model.Vpc, error) { + patches.ApplyMethod(reflect.TypeOf(r.Service), "CreateOrUpdateVPC", func(_ *vpc.VPCService, ctx context.Context, _ *v1alpha1.NetworkInfo, _ *v1alpha1.VPCNetworkConfiguration, _ vpc.LBProvider, _ bool, _ bool) (*model.Vpc, error) { return &model.Vpc{ DisplayName: servicecommon.String("vpc-name"), Path: servicecommon.String("/orgs/default/projects/project-quality/vpcs/fake-vpc"), @@ -1875,6 +1911,15 @@ func TestNetworkInfoReconciler_StartController(t *testing.T) { vpcService := &vpc.VPCService{ Service: servicecommon.Service{ Client: fakeClient, + NSXClient: &nsx.Client{ + QueryClient: &fakeQueryClient{}, + NsxConfig: &config.NSXOperatorConfig{ + CoeConfig: &config.CoeConfig{Cluster: "unit-test"}, + }, + }, + NSXConfig: &config.NSXOperatorConfig{ + CoeConfig: &config.CoeConfig{Cluster: "unit-test"}, + }, }, } ipblocksInfoService := &ipblocksinfo.IPBlocksInfoService{ @@ -1891,8 +1936,10 @@ func TestNetworkInfoReconciler_StartController(t *testing.T) { patches.ApplyFunc(common.GenericGarbageCollector, func(cancel chan bool, timeout time.Duration, f func(ctx context.Context) error) { }) defer patches.Reset() - r := NewNetworkInfoReconciler(mockMgr, vpcService, ipblocksInfoService) - err := r.StartController(mockMgr, nil) + dnsSvc, err := dns.InitializeDNSRecordService(vpcService.Service, vpcService) + require.NoError(t, err) + r := NewNetworkInfoReconciler(mockMgr, vpcService, ipblocksInfoService, dnsSvc) + err = r.StartController(mockMgr, nil) assert.Nil(t, err) // Sleep to make sure the patches are reset after the goroutine in StartController get executed time.Sleep(time.Second) diff --git a/pkg/controllers/networkinfo/networkinfo_utils.go b/pkg/controllers/networkinfo/networkinfo_utils.go index 5871b23a3..2827cf3a2 100644 --- a/pkg/controllers/networkinfo/networkinfo_utils.go +++ b/pkg/controllers/networkinfo/networkinfo_utils.go @@ -21,19 +21,27 @@ func setNetworkInfoVPCStatusWithError(client client.Client, ctx context.Context, } func setNetworkInfoVPCStatus(client client.Client, ctx context.Context, obj client.Object, _ metav1.Time, args ...interface{}) { - if len(args) != 1 { + if len(args) < 1 { log.Error(nil, "VPC State is needed when updating NetworkInfo status") return } networkInfo := obj.(*v1alpha1.NetworkInfo) - var createdVPC *v1alpha1.VPCState if args[0] == nil { // Not clear the existing VPC in NetworkInfo as // currently one Namespace only maps to one VPC return - } else { - createdVPC = args[0].(*v1alpha1.VPCState) } + createdVPC := args[0].(*v1alpha1.VPCState) + + var allowedDNSDomains []string + updateAllowedDomains := false + if len(args) >= 2 { + if d, ok := args[1].([]string); ok { + allowedDNSDomains = d + updateAllowedDomains = true + } + } + existingVPC := &v1alpha1.VPCState{} if len(networkInfo.VPCs) > 0 { existingVPC = &networkInfo.VPCs[0] @@ -42,11 +50,22 @@ func setNetworkInfoVPCStatus(client client.Client, ctx context.Context, obj clie slices.Sort(createdVPC.PrivateIPs) slices.Sort(existingVPC.LoadBalancerBackendIPs) slices.Sort(createdVPC.LoadBalancerBackendIPs) - if reflect.DeepEqual(*existingVPC, *createdVPC) { + + domainsEqual := true + if updateAllowedDomains { + domainsEqual = slices.Equal(networkInfo.AllowedDNSDomains, allowedDNSDomains) + } + + if reflect.DeepEqual(*existingVPC, *createdVPC) && domainsEqual { return } networkInfo.VPCs = []v1alpha1.VPCState{*createdVPC} - client.Update(ctx, networkInfo) + if updateAllowedDomains { + networkInfo.AllowedDNSDomains = allowedDNSDomains + } + if err := client.Update(ctx, networkInfo); err != nil { + log.Error(err, "Failed to update NetworkInfo VPC status", "NetworkInfo", networkInfo.Name) + } } func setVPCNetworkConfigurationStatusWithLBS(ctx context.Context, client client.Client, ncName, vpcName, aviSubnetPath, nsxLBSPath, vpcPath string) { diff --git a/pkg/controllers/networkinfo/networkinfo_utils_test.go b/pkg/controllers/networkinfo/networkinfo_utils_test.go index 658357c3e..56179d0b4 100644 --- a/pkg/controllers/networkinfo/networkinfo_utils_test.go +++ b/pkg/controllers/networkinfo/networkinfo_utils_test.go @@ -487,3 +487,30 @@ func TestHasPodOrVMDefaultSubnets(t *testing.T) { assert.False(t, hasPodDefaultSubnets(subnets)) assert.False(t, hasVMDefaultSubnets(subnets)) } + +func TestSetNetworkInfoVPCStatus_AllowedDNSDomains(t *testing.T) { + ctx := context.TODO() + scheme := clientgoscheme.Scheme + v1alpha1.AddToScheme(scheme) + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&v1alpha1.NetworkInfo{ + ObjectMeta: metav1.ObjectMeta{Namespace: "ns1", Name: "ni1"}, + }).Build() + + networkInfoCR := &v1alpha1.NetworkInfo{} + require.NoError(t, kubeClient.Get(ctx, apitypes.NamespacedName{Namespace: "ns1", Name: "ni1"}, networkInfoCR)) + + state := &v1alpha1.VPCState{Name: "vpc-a"} + domains := []string{"zone1.example.com", "zone2.example.com"} + setNetworkInfoVPCStatus(kubeClient, ctx, networkInfoCR, metav1.Now(), state, domains) + + require.NoError(t, kubeClient.Get(ctx, apitypes.NamespacedName{Namespace: "ns1", Name: "ni1"}, networkInfoCR)) + require.Len(t, networkInfoCR.VPCs, 1) + assert.Equal(t, "vpc-a", networkInfoCR.VPCs[0].Name) + assert.Equal(t, domains, networkInfoCR.AllowedDNSDomains) + + // Clearing domains while keeping VPC state requires explicit second arg (nil slice). + state2 := &v1alpha1.VPCState{Name: "vpc-a"} + setNetworkInfoVPCStatus(kubeClient, ctx, networkInfoCR, metav1.Now(), state2, []string(nil)) + require.NoError(t, kubeClient.Get(ctx, apitypes.NamespacedName{Namespace: "ns1", Name: "ni1"}, networkInfoCR)) + assert.Nil(t, networkInfoCR.AllowedDNSDomains) +} From ec1e2bc3beba744e56cad3b93249a31f95562c65 Mon Sep 17 00:00:00 2001 From: Wenying Dong Date: Thu, 14 May 2026 19:39:39 +0800 Subject: [PATCH 3/3] feat(service): reconcile DNS records for LoadBalancer Services - Reconcile DNS records based on the hostname annotation on LoadBalancer Services using VPCNetworkConfiguration allowed DNS zones - Report DNSRecordReady condition for DNS zone validation errors and generic DNS build errors --- cmd/main.go | 2 +- .../service/service_lb_controller.go | 138 ++++- .../service/service_lb_controller_test.go | 519 ++++++++++++------ pkg/controllers/service/service_lb_dns.go | 270 +++++++++ .../service/service_lb_dns_test.go | 408 ++++++++++++++ pkg/nsx/services/common/types.go | 3 + .../externaldns/annotations/doc.go | 20 + .../externaldns/annotations/hostnames.go | 34 ++ pkg/third_party/externaldns/source/doc.go | 47 ++ .../externaldns/source/route_hostnames.go | 92 ++++ .../source/route_hostnames_test.go | 84 +++ 11 files changed, 1426 insertions(+), 191 deletions(-) create mode 100644 pkg/controllers/service/service_lb_dns.go create mode 100644 pkg/controllers/service/service_lb_dns_test.go create mode 100644 pkg/third_party/externaldns/annotations/doc.go create mode 100644 pkg/third_party/externaldns/annotations/hostnames.go create mode 100644 pkg/third_party/externaldns/source/doc.go create mode 100644 pkg/third_party/externaldns/source/route_hostnames.go create mode 100644 pkg/third_party/externaldns/source/route_hostnames_test.go diff --git a/cmd/main.go b/cmd/main.go index 4d8c595f3..9c3e613b1 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -247,7 +247,7 @@ func startServiceController(mgr manager.Manager, nsxClient *nsx.Client) { subnetport.NewSubnetPortReconciler(mgr, subnetPortService, subnetService, vpcService, ipAddressAllocationService), pod.NewPodReconciler(mgr, subnetPortService, subnetService, vpcService, nodeService), networkpolicycontroller.NewNetworkPolicyReconciler(mgr, commonService, vpcService), - service.NewServiceLbReconciler(mgr, commonService), + service.NewServiceLbReconciler(mgr, commonService, dnsRecordService), subnetbindingcontroller.NewReconciler(mgr, subnetService, subnetBindingService), subnetipreservationcontroller.NewReconciler(mgr, subnetIPReservationService, subnetService), ) diff --git a/pkg/controllers/service/service_lb_controller.go b/pkg/controllers/service/service_lb_controller.go index 1f991b36d..a99eb14c0 100644 --- a/pkg/controllers/service/service_lb_controller.go +++ b/pkg/controllers/service/service_lb_controller.go @@ -5,25 +5,32 @@ package service import ( "context" + "fmt" "time" v1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" apimachineryruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/version" clientset "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/webhook" + "github.com/vmware-tanzu/nsx-operator/pkg/apis/vpc/v1alpha1" "github.com/vmware-tanzu/nsx-operator/pkg/controllers/common" "github.com/vmware-tanzu/nsx-operator/pkg/logger" "github.com/vmware-tanzu/nsx-operator/pkg/metrics" _ "github.com/vmware-tanzu/nsx-operator/pkg/nsx/ratelimiter" servicecommon "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" + "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/dns" ) var ( @@ -38,6 +45,7 @@ type ServiceLbReconciler struct { Client client.Client Scheme *apimachineryruntime.Scheme Service *servicecommon.Service + DNS dns.DNSRecordProvider Recorder record.EventRecorder } @@ -63,25 +71,54 @@ func (r *ServiceLbReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( if err := r.Client.Get(ctx, req.NamespacedName, service); err != nil { if apierrors.IsNotFound(err) { log.Info("Not found LB service", "req", req.NamespacedName) - return ResultNormal, client.IgnoreNotFound(err) + if _, delErr := r.DNS.DeleteRecordByOwnerNN(ctx, dns.ResourceKindService, req.Namespace, req.Name); delErr != nil { + log.Error(delErr, "Failed to delete DNS records for Service", "Namespace", req.Namespace, "Name", req.Name) + return common.ResultRequeueAfter10sec, delErr + } + return ResultNormal, nil } log.Error(err, "Failed to fetch LB service", "req", req.NamespacedName) return common.ResultRequeueAfter10sec, err } - if service.Spec.Type == v1.ServiceTypeLoadBalancer { - log.Info("Reconciling LB service", "LBService", req.NamespacedName) - log.Debug("Reconciling LB Service", "name", service.Name, "version", service.ResourceVersion, "status", service.Status) - metrics.CounterInc(r.Service.NSXConfig, metrics.ControllerSyncTotal, MetricResType) + if service.Spec.Type != v1.ServiceTypeLoadBalancer || !service.ObjectMeta.DeletionTimestamp.IsZero() { + // Try to delete DNS records for Service when it is not a LoadBalancer or is marked for deletion + if _, err := r.DNS.DeleteRecordByOwnerNN(ctx, dns.ResourceKindService, service.Namespace, service.Name); err != nil { + log.Error(err, "Failed to delete DNS records for Service", "Namespace", service.Namespace, "Name", service.Name) + return common.ResultRequeueAfter10sec, err + } + if uerr := r.removeServiceDNSReadyCondition(ctx, req.NamespacedName); uerr != nil { + log.Error(uerr, "Failed to clear Service DNS Ready condition", "Service", req.NamespacedName.String()) + return common.ResultRequeueAfter10sec, uerr + } + return ResultNormal, nil + } - if service.ObjectMeta.DeletionTimestamp.IsZero() { - metrics.CounterInc(r.Service.NSXConfig, metrics.ControllerUpdateTotal, MetricResType) - err := updateSuccess(r, ctx, service) - if err != nil { - log.Error(err, "Failed to update LB service", "Name", service.Name, "Namespace", service.Namespace) - return common.ResultRequeueAfter10sec, err - } + if isServiceDNSSkipped(service.GetAnnotations()) { + log.Info("Skipping DNS reconcile for LB service due to annotation", "Service", req.NamespacedName) + if _, err := r.DNS.DeleteRecordByOwnerNN(ctx, dns.ResourceKindService, service.Namespace, service.Name); err != nil { + log.Error(err, "Failed to delete DNS records for skipped Service", "Service", req.NamespacedName) + return common.ResultRequeueAfter10sec, err } + if uerr := r.removeServiceDNSReadyCondition(ctx, req.NamespacedName); uerr != nil { + log.Error(uerr, "Failed to clear Service DNS Ready condition for skipped Service", "Service", req.NamespacedName.String()) + return common.ResultRequeueAfter10sec, uerr + } + return ResultNormal, nil + } + + log.Info("Reconciling LB service", "LBService", req.NamespacedName) + log.Debug("Reconciling LB Service", "name", service.Name, "version", service.ResourceVersion, "status", service.Status) + metrics.CounterInc(r.Service.NSXConfig, metrics.ControllerSyncTotal, MetricResType) + + if err := r.reconcileLoadBalancerServiceDNS(ctx, service); err != nil { + log.Error(err, "Failed to reconcile DNS for LoadBalancer Service", "Name", service.Name, "Namespace", service.Namespace) + return common.ResultRequeueAfter10sec, err + } + + if err := updateSuccess(r, ctx, service); err != nil { + log.Error(err, "Failed to update LB service", "Name", service.Name, "Namespace", service.Namespace) + return common.ResultRequeueAfter10sec, err } return ResultNormal, nil @@ -119,13 +156,18 @@ func (r *ServiceLbReconciler) setServiceLbStatus(ctx context.Context, lbService } func (r *ServiceLbReconciler) setupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). + b := ctrl.NewControllerManagedBy(mgr). For(&v1.Service{}). + Watches( + &v1alpha1.NetworkInfo{}, + handler.EnqueueRequestsFromMapFunc(r.enqueueLBServiceRequestsFromNetworkInfo), + builder.WithPredicates(predicateNetworkInfoAllowedDNSDomainsChanged()), + ). WithOptions( controller.Options{ MaxConcurrentReconciles: common.NumReconcile(), - }). - Complete(r) + }) + return b.Complete(r) } // Start setup manager @@ -172,18 +214,82 @@ func (r *ServiceLbReconciler) StartController(mgr ctrl.Manager, _ webhook.Server log.Error(err, "Failed to create controller", "controller", "ServiceLb") return err } + go common.GenericGarbageCollector(make(chan bool), servicecommon.GCInterval, r.CollectGarbage) return nil } +// isServiceDNSSkipped reports whether the Service has opted out of DNS management via the skip annotation. +func isServiceDNSSkipped(annotations map[string]string) bool { + _, ok := annotations[servicecommon.AnnotationsDNSSkip] + return ok +} + +// listLoadBalancerServicesWithDNSAnnotation returns Service NNs that should retain DNS rows (LB, not terminating, hostname annotation). +func listLoadBalancerServicesWithDNSAnnotation(ctx context.Context, c client.Client) (sets.Set[types.NamespacedName], error) { + svcList := &v1.ServiceList{} + if err := c.List(ctx, svcList); err != nil { + return nil, err + } + nnSet := sets.New[types.NamespacedName]() + for i := range svcList.Items { + svc := &svcList.Items[i] + if svc.Spec.Type != v1.ServiceTypeLoadBalancer || !svc.ObjectMeta.DeletionTimestamp.IsZero() { + continue + } + if isServiceDNSSkipped(svc.GetAnnotations()) { + continue + } + if len(parseDNSHostnamesFromServiceAnnotation(svc.GetAnnotations())) == 0 { + continue + } + nnSet.Insert(types.NamespacedName{Namespace: svc.Namespace, Name: svc.Name}) + } + return nnSet, nil +} + func (r *ServiceLbReconciler) CollectGarbage(ctx context.Context) error { + if r.DNS == nil { + return nil + } + apiSet, err := listLoadBalancerServicesWithDNSAnnotation(ctx, r.Client) + if err != nil { + log.Error(err, "Service LB GC: failed to list Services") + return err + } + ownersByKind := r.DNS.ListRecordOwnerResource() + cachedServices := ownersByKind[dns.ResourceKindService] + var errs []error + for nn := range cachedServices { + if apiSet.Has(nn) { + continue + } + if _, err := r.DNS.DeleteRecordByOwnerNN(ctx, dns.ResourceKindService, nn.Namespace, nn.Name); err != nil { + log.Error(err, "Service LB GC: failed to delete DNS records for Service owner missing from API or no longer eligible", + "Namespace", nn.Namespace, "Name", nn.Name) + errs = append(errs, err) + continue + } + if err := r.removeServiceDNSReadyCondition(ctx, nn); err != nil { + log.Error(err, "Service LB GC: failed to clear Service DNS Ready condition", "Namespace", nn.Namespace, "Name", nn.Name) + errs = append(errs, err) + } + } + if len(errs) > 0 { + return fmt.Errorf("service LB garbage collection encountered %d error(s): %v", len(errs), errs) + } return nil } -func NewServiceLbReconciler(mgr ctrl.Manager, commonService servicecommon.Service) *ServiceLbReconciler { +func NewServiceLbReconciler(mgr ctrl.Manager, commonService servicecommon.Service, dnsRecordService *dns.DNSRecordService) *ServiceLbReconciler { if isServiceLbStatusIpModeSupported(mgr.GetConfig()) { + var dnsProv dns.DNSRecordProvider + if dnsRecordService != nil { + dnsProv = dnsRecordService + } serviceLbReconciler := &ServiceLbReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), + DNS: dnsProv, Recorder: mgr.GetEventRecorderFor("serviceLb-controller"), //nolint:staticcheck // record.EventRecorder; StatusUpdater not on events.EventRecorder yet } serviceLbReconciler.Service = &commonService diff --git a/pkg/controllers/service/service_lb_controller_test.go b/pkg/controllers/service/service_lb_controller_test.go index 948142ec9..2896f372b 100644 --- a/pkg/controllers/service/service_lb_controller_test.go +++ b/pkg/controllers/service/service_lb_controller_test.go @@ -5,18 +5,17 @@ package service import ( "context" - "errors" - "os" - "reflect" "testing" "github.com/agiledragon/gomonkey/v2" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/rest" "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" @@ -25,17 +24,44 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" "github.com/vmware-tanzu/nsx-operator/pkg/config" + mockdns "github.com/vmware-tanzu/nsx-operator/pkg/mock/dnsrecordprovider" - mock_client "github.com/vmware-tanzu/nsx-operator/pkg/mock/controller-runtime/client" + "github.com/vmware-tanzu/nsx-operator/pkg/apis/vpc/v1alpha1" "github.com/vmware-tanzu/nsx-operator/pkg/nsx" "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" + "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/dns" ) +func emptyDNSRecordService() *dns.DNSRecordService { + return &dns.DNSRecordService{DNSRecordStore: dns.BuildDNSRecordStore()} +} + +// serviceLbFakeClient builds a fake client; scheme must match the reconciler's Scheme (same *runtime.Scheme instance). +func serviceLbFakeClient(scheme *runtime.Scheme, withStatus bool, objs ...client.Object) client.Client { + b := fake.NewClientBuilder().WithScheme(scheme) + if withStatus { + b = b.WithStatusSubresource(&v1.Service{}) + } + if len(objs) > 0 { + b = b.WithObjects(objs...) + } + return b.Build() +} + +func assignDNSListStubs(m *mockdns.MockDNSRecordProvider) { + m.EXPECT().ListReferredGatewayNN().Return(sets.New[types.NamespacedName]()).AnyTimes() + m.EXPECT().ListRecordOwnerResource().Return(nil).AnyTimes() +} + func NewFakeServiceLbReconciler() *ServiceLbReconciler { + s := runtime.NewScheme() + _ = v1.AddToScheme(s) + c := fake.NewClientBuilder().WithScheme(s).Build() return &ServiceLbReconciler{ - Client: fake.NewClientBuilder().Build(), - Scheme: fake.NewClientBuilder().Build().Scheme(), + Client: c, + Scheme: s, Service: nil, + DNS: emptyDNSRecordService(), Recorder: fakeRecorder{}, } } @@ -82,211 +108,356 @@ func (m *MockManager) Start(context.Context) error { return nil } -func TestServiceLbReconciler_setServiceLbStatus(t *testing.T) { - r := NewFakeServiceLbReconciler() - ctx := context.TODO() - lbService := &v1.Service{} - lbService.Spec.Type = v1.ServiceTypeLoadBalancer - lbService.Labels = map[string]string{ - common.LabelLbIngressIpMode: common.LabelLbIngressIpModeVipValue, +func TestServiceLbReconciler_setServiceLbStatus_table(t *testing.T) { + vipMode := v1.LoadBalancerIPModeVIP + proxyMode := v1.LoadBalancerIPModeProxy + + makeSvc := func(ipMode *v1.LoadBalancerIPMode, ip string, labelValue string) *v1.Service { + svc := &v1.Service{Spec: v1.ServiceSpec{Type: v1.ServiceTypeLoadBalancer}} + svc.Status.LoadBalancer.Ingress = []v1.LoadBalancerIngress{{IP: ip, IPMode: ipMode}} + if labelValue != "" { + svc.Labels = map[string]string{common.LabelLbIngressIpMode: labelValue} + } + return svc } - vipIpMode := v1.LoadBalancerIPModeVIP - lbService.Status.LoadBalancer.Ingress = []v1.LoadBalancerIngress{ + + tests := []struct { + name string + svc *v1.Service + wantIPMode *v1.LoadBalancerIPMode + }{ { - IP: "192.168.28.1", - IPMode: &vipIpMode, + name: "vip_label_keeps_vip_mode", + svc: makeSvc(&vipMode, "192.168.28.1", common.LabelLbIngressIpModeVipValue), + wantIPMode: &vipMode, }, - } - - // Case: IPMode is set and ingress-ip-mode label is set as vip. - r.setServiceLbStatus(ctx, lbService) - assert.Equal(t, v1.LoadBalancerIPModeVIP, *lbService.Status.LoadBalancer.Ingress[0].IPMode) - - // Case: IPMode is set and ingress-ip-mode label is set as proxy. - lbService.Labels = map[string]string{ - common.LabelLbIngressIpMode: common.LabelLbIngressIpModeProxyValue, - } - r.setServiceLbStatus(ctx, lbService) - assert.Equal(t, v1.LoadBalancerIPModeProxy, *lbService.Status.LoadBalancer.Ingress[0].IPMode) - - // Case: IPMode is set and ingress-ip-mode label is not set. - lbService.Labels = nil - lbService.Status.LoadBalancer.Ingress[0].IPMode = &vipIpMode - r.setServiceLbStatus(ctx, lbService) - assert.Equal(t, v1.LoadBalancerIPModeProxy, *lbService.Status.LoadBalancer.Ingress[0].IPMode) - - // Case IPMode is not set and label is set as VIP. - lbService.Labels = map[string]string{ - common.LabelLbIngressIpMode: common.LabelLbIngressIpModeVipValue, - } - lbService.Status.LoadBalancer.Ingress = []v1.LoadBalancerIngress{ { - IP: "192.168.28.1", - IPMode: nil, + name: "proxy_label_overrides_to_proxy", + svc: makeSvc(&vipMode, "192.168.28.1", common.LabelLbIngressIpModeProxyValue), + wantIPMode: &proxyMode, }, - } - r.setServiceLbStatus(ctx, lbService) - assert.Equal(t, v1.LoadBalancerIPModeVIP, *lbService.Status.LoadBalancer.Ingress[0].IPMode) - - // Case IPMode is not set and label is set as proxy. - lbService.Labels = map[string]string{ - common.LabelLbIngressIpMode: common.LabelLbIngressIpModeProxyValue, - } - lbService.Status.LoadBalancer.Ingress = []v1.LoadBalancerIngress{ { - IP: "192.168.28.1", - IPMode: nil, + name: "no_label_defaults_to_proxy", + svc: makeSvc(&vipMode, "192.168.28.1", ""), + wantIPMode: &proxyMode, }, - } - r.setServiceLbStatus(ctx, lbService) - assert.Equal(t, v1.LoadBalancerIPModeProxy, *lbService.Status.LoadBalancer.Ingress[0].IPMode) - - // Case IPMode is not set and label is not set - lbService.Labels = nil - lbService.Status.LoadBalancer.Ingress = []v1.LoadBalancerIngress{ { - IP: "192.168.28.1", - IPMode: nil, + name: "nil_ipmode_vip_label_sets_vip", + svc: makeSvc(nil, "192.168.28.1", common.LabelLbIngressIpModeVipValue), + wantIPMode: &vipMode, }, - } - r.setServiceLbStatus(ctx, lbService) - assert.Equal(t, v1.LoadBalancerIPModeProxy, *lbService.Status.LoadBalancer.Ingress[0].IPMode) - - // Case Ingress.IP is not set - lbService.Status.LoadBalancer.Ingress = []v1.LoadBalancerIngress{ { - IP: "", - IPMode: nil, + name: "nil_ipmode_proxy_label_sets_proxy", + svc: makeSvc(nil, "192.168.28.1", common.LabelLbIngressIpModeProxyValue), + wantIPMode: &proxyMode, }, + { + name: "nil_ipmode_no_label_sets_proxy", + svc: makeSvc(nil, "192.168.28.1", ""), + wantIPMode: &proxyMode, + }, + { + name: "empty_ip_leaves_nil_ipmode", + svc: makeSvc(nil, "", ""), + wantIPMode: nil, + }, + } + + r := NewFakeServiceLbReconciler() + ctx := context.TODO() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r.setServiceLbStatus(ctx, tt.svc) + assert.Equal(t, tt.wantIPMode, tt.svc.Status.LoadBalancer.Ingress[0].IPMode) + }) } - r.setServiceLbStatus(ctx, lbService) - assert.Equal(t, (*v1.LoadBalancerIPMode)(nil), lbService.Status.LoadBalancer.Ingress[0].IPMode) } -func TestServiceLbReconciler_Reconcile(t *testing.T) { - mockCtl := gomock.NewController(t) - k8sClient := mock_client.NewMockClient(mockCtl) - service := &common.Service{ +func serviceLbTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + require.NoError(t, v1.AddToScheme(s)) + require.NoError(t, v1alpha1.AddToScheme(s)) + return s +} + +func testNSXServiceForLb() *common.Service { + return &common.Service{ NSXClient: &nsx.Client{}, NSXConfig: &config.NSXOperatorConfig{ - CoeConfig: &config.CoeConfig{ - EnableVPCNetwork: true, + CoeConfig: &config.CoeConfig{EnableVPCNetwork: true}, + NsxConfig: &config.NsxConfig{EnforcementPoint: "vmc-enforcementpoint"}, + }, + } +} + +func TestServiceLbReconciler_Reconcile_table(t *testing.T) { + ctx := context.Background() + scheme := serviceLbTestScheme(t) + nsxSvc := testNSXServiceForLb() + + tests := []struct { + name string + objs []client.Object + setupMock func(m *mockdns.MockDNSRecordProvider) + req types.NamespacedName + wantErr bool + }{ + { + name: "not_found_deletes_dns", + objs: nil, + setupMock: func(m *mockdns.MockDNSRecordProvider) { + m.EXPECT().DeleteRecordByOwnerNN(gomock.Any(), dns.ResourceKindService, "dummy", "missing").Return(false, nil).Times(1) }, - NsxConfig: &config.NsxConfig{ - EnforcementPoint: "vmc-enforcementpoint", + req: types.NamespacedName{Namespace: "dummy", Name: "missing"}, + }, + { + name: "loadbalancer_active", + objs: []client.Object{&v1.Service{ + ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "lb", ResourceVersion: "1"}, + Spec: v1.ServiceSpec{Type: v1.ServiceTypeLoadBalancer}, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{Ingress: []v1.LoadBalancerIngress{{IP: "192.168.28.1"}}}, + }, + }}, + req: types.NamespacedName{Namespace: "ns", Name: "lb"}, + }, + { + name: "loadbalancer_with_deletion_timestamp", + objs: []client.Object{&v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", Name: "lb-del", ResourceVersion: "1", + DeletionTimestamp: func() *metav1.Time { t := metav1.Now(); return &t }(), + Finalizers: []string{"test.finalizer/nsx-operator"}, + }, + Spec: v1.ServiceSpec{Type: v1.ServiceTypeLoadBalancer}, + }}, + req: types.NamespacedName{Namespace: "ns", Name: "lb-del"}, + }, + { + name: "skip_annotation_clears_dns", + objs: []client.Object{&v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "lb-skip", + ResourceVersion: "1", + Annotations: map[string]string{common.AnnotationsDNSSkip: "true"}, + }, + Spec: v1.ServiceSpec{Type: v1.ServiceTypeLoadBalancer}, + }}, + setupMock: func(m *mockdns.MockDNSRecordProvider) { + m.EXPECT().DeleteRecordByOwnerNN(gomock.Any(), dns.ResourceKindService, "ns", "lb-skip").Return(false, nil).Times(1) }, + req: types.NamespacedName{Namespace: "ns", Name: "lb-skip"}, + }, + { + name: "cluster_ip_clears_dns", + objs: []client.Object{&v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", Name: "ci", ResourceVersion: "1", + DeletionTimestamp: func() *metav1.Time { t := metav1.Now(); return &t }(), + Finalizers: []string{"test.finalizer/nsx-operator"}, + }, + Spec: v1.ServiceSpec{Type: v1.ServiceTypeClusterIP}, + }}, + req: types.NamespacedName{Namespace: "ns", Name: "ci"}, }, } - r := &ServiceLbReconciler{ - Client: k8sClient, - Scheme: nil, - Service: service, - Recorder: fakeRecorder{}, + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockCtl := gomock.NewController(t) + t.Cleanup(func() { mockCtl.Finish() }) + m := mockdns.NewMockDNSRecordProvider(mockCtl) + assignDNSListStubs(m) + if tt.setupMock != nil { + tt.setupMock(m) + } + + var dnsProvider dns.DNSRecordProvider = m + // For cases without mock expectations, use the empty in-memory service. + if tt.setupMock == nil { + dnsProvider = emptyDNSRecordService() + } + r := &ServiceLbReconciler{ + Client: serviceLbFakeClient(scheme, true, tt.objs...), + Scheme: scheme, + Service: nsxSvc, + DNS: dnsProvider, + Recorder: fakeRecorder{}, + } + _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: tt.req}) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) } +} + +func TestServiceLbReconciler_CollectGarbage_table(t *testing.T) { ctx := context.Background() - req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "dummy", Name: "dummy"}} - - // lb service not found obj case - errNotFound := errors.New("not found") - k8sClient.EXPECT().Get(ctx, gomock.Any(), gomock.Any()).Return(errNotFound) - _, err := r.Reconcile(ctx, req) - assert.Equal(t, err, errNotFound) - - // DeletionTimestamp.IsZero = true and service type is LoadBalancer - lbService := &v1.Service{} - k8sClient.EXPECT().Get(ctx, gomock.Any(), lbService).Return(nil).Do(func(_ context.Context, _ client.ObjectKey, obj client.Object, option ...client.GetOption) error { - v1lbservice := obj.(*v1.Service) - v1lbservice.Spec.Type = v1.ServiceTypeLoadBalancer - return nil - }) - _, err = r.Reconcile(ctx, req) - assert.Equal(t, err, nil) - - // DeletionTimestamp.IsZero = false and service type is LoadBalancer - k8sClient.EXPECT().Get(ctx, gomock.Any(), lbService).Return(nil).Do(func(_ context.Context, _ client.ObjectKey, obj client.Object, option ...client.GetOption) error { - v1lbservice := obj.(*v1.Service) - v1lbservice.Spec.Type = v1.ServiceTypeLoadBalancer - time := metav1.Now() - v1lbservice.ObjectMeta.DeletionTimestamp = &time - return nil - }) - _, err = r.Reconcile(ctx, req) - assert.Equal(t, err, nil) - - // service type is not LoadBalancer - k8sClient.EXPECT().Get(ctx, gomock.Any(), lbService).Return(nil).Do(func(_ context.Context, _ client.ObjectKey, obj client.Object, option ...client.GetOption) error { - v1lbservice := obj.(*v1.Service) - v1lbservice.Spec.Type = v1.ServiceTypeClusterIP - time := metav1.Now() - v1lbservice.ObjectMeta.DeletionTimestamp = &time - return nil - }) - _, err = r.Reconcile(ctx, req) - assert.Equal(t, err, nil) + scheme := serviceLbTestScheme(t) + + tests := []struct { + name string + dns dns.DNSRecordProvider + objs []client.Object + setupMock func(m *mockdns.MockDNSRecordProvider) + wantErr bool + }{ + { + name: "nil_dns_is_noop", + dns: nil, + }, + { + name: "empty_store_is_noop", + dns: emptyDNSRecordService(), + }, + { + name: "prunes_stale_service_owners", + objs: []client.Object{&v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", Name: "keep", + Annotations: map[string]string{common.AnnotationDNSHostnameKey: "app.example.com"}, + }, + Spec: v1.ServiceSpec{Type: v1.ServiceTypeLoadBalancer}, + }}, + setupMock: func(m *mockdns.MockDNSRecordProvider) { + keepNN := types.NamespacedName{Namespace: "ns", Name: "keep"} + staleNN := types.NamespacedName{Namespace: "ns", Name: "stale"} + m.EXPECT().ListReferredGatewayNN().Return(sets.New[types.NamespacedName]()).AnyTimes() + m.EXPECT().ListRecordOwnerResource().Return(map[string]sets.Set[types.NamespacedName]{ + dns.ResourceKindService: sets.New(keepNN, staleNN), + }).Times(1) + m.EXPECT().DeleteRecordByOwnerNN(gomock.Any(), dns.ResourceKindService, staleNN.Namespace, staleNN.Name).Return(true, nil).Times(1) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dnsProvider := tt.dns + if tt.setupMock != nil { + mockCtl := gomock.NewController(t) + t.Cleanup(func() { mockCtl.Finish() }) + m := mockdns.NewMockDNSRecordProvider(mockCtl) + tt.setupMock(m) + dnsProvider = m + } + r := &ServiceLbReconciler{ + Client: serviceLbFakeClient(scheme, true, tt.objs...), + Scheme: scheme, + DNS: dnsProvider, + } + err := r.CollectGarbage(ctx) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } } -func TestServiceLbReconciler_StartController(t *testing.T) { +func TestNewServiceLbReconciler_whenIpModeSupported(t *testing.T) { fakeClient := fake.NewClientBuilder().WithObjects().Build() - commonService := common.Service{ - Client: fakeClient, - } - mockMgr := &MockManager{ - scheme: runtime.NewScheme(), - config: &rest.Config{}, + commonService := common.Service{Client: fakeClient} + mockMgr := &MockManager{scheme: runtime.NewScheme(), config: &rest.Config{}} + + patches := gomonkey.ApplyFunc(isServiceLbStatusIpModeSupported, func(c *rest.Config) bool { return true }) + defer patches.Reset() + + r := NewServiceLbReconciler(mockMgr, commonService, nil) + require.NotNil(t, r) +} + +func TestListLoadBalancerServicesWithDNSAnnotation_table(t *testing.T) { + ctx := context.Background() + scheme := serviceLbTestScheme(t) + + makeLbSvc := func(name string, annotations map[string]string) *v1.Service { + return &v1.Service{ + ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: name, Annotations: annotations}, + Spec: v1.ServiceSpec{Type: v1.ServiceTypeLoadBalancer}, + } } - testCases := []struct { - name string - expectErrStr string - patches func() *gomonkey.Patches + tests := []struct { + name string + objs []client.Object + wantNNs []types.NamespacedName + wantMiss []types.NamespacedName }{ - // expected no error when starting the serviceLb controller { - name: "Start serviceLb Controller", - patches: func() *gomonkey.Patches { - patches := gomonkey.ApplyFunc(os.Exit, func(code int) { - assert.FailNow(t, "os.Exit should not be called") - }) - patches.ApplyFunc(isServiceLbStatusIpModeSupported, func(c *rest.Config) bool { - return true - }) - patches.ApplyMethod(reflect.TypeOf(&ServiceLbReconciler{}), "Start", func(_ *ServiceLbReconciler, r ctrl.Manager) error { - return nil - }) - return patches + name: "service_with_hostname_is_included", + objs: []client.Object{ + makeLbSvc("with-hostname", map[string]string{common.AnnotationDNSHostnameKey: "app.example.com"}), + }, + wantNNs: []types.NamespacedName{{Namespace: "ns", Name: "with-hostname"}}, + }, + { + name: "service_with_skip_annotation_is_excluded", + objs: []client.Object{ + makeLbSvc("skipped", map[string]string{ + common.AnnotationDNSHostnameKey: "app.example.com", + common.AnnotationsDNSSkip: "true", + }), }, + wantMiss: []types.NamespacedName{{Namespace: "ns", Name: "skipped"}}, }, { - name: "Start serviceLb controller return error", - expectErrStr: "failed to setupWithManager", - patches: func() *gomonkey.Patches { - patches := gomonkey.ApplyFunc(os.Exit, func(code int) { - }) - patches.ApplyFunc(isServiceLbStatusIpModeSupported, func(c *rest.Config) bool { - return true - }) - patches.ApplyPrivateMethod(reflect.TypeOf(&ServiceLbReconciler{}), "setupWithManager", func(_ *ServiceLbReconciler, mgr ctrl.Manager) error { - return errors.New("failed to setupWithManager") - }) - return patches + name: "skip_wins_over_hostname_annotation", + objs: []client.Object{ + makeLbSvc("eligible", map[string]string{common.AnnotationDNSHostnameKey: "a.example.com"}), + makeLbSvc("opted-out", map[string]string{ + common.AnnotationDNSHostnameKey: "b.example.com", + common.AnnotationsDNSSkip: "", + }), }, + wantNNs: []types.NamespacedName{{Namespace: "ns", Name: "eligible"}}, + wantMiss: []types.NamespacedName{{Namespace: "ns", Name: "opted-out"}}, }, } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - patches := testCase.patches() - defer patches.Reset() - - r := NewServiceLbReconciler(mockMgr, commonService) - err := r.StartController(mockMgr, nil) - - if testCase.expectErrStr != "" { - assert.Contains(t, err.Error(), testCase.expectErrStr) - } else { - assert.Nil(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := serviceLbFakeClient(scheme, false, tt.objs...) + got, err := listLoadBalancerServicesWithDNSAnnotation(ctx, c) + require.NoError(t, err) + for _, nn := range tt.wantNNs { + require.True(t, got.Has(nn), "expected %v to be in set", nn) + } + for _, nn := range tt.wantMiss { + require.False(t, got.Has(nn), "expected %v NOT to be in set", nn) } }) } } + +func TestEnqueueLBServiceRequestsFromNetworkInfo_skipAnnotation(t *testing.T) { + ctx := context.Background() + scheme := serviceLbTestScheme(t) + ni := &v1alpha1.NetworkInfo{} + + makeLbSvc := func(name string, annotations map[string]string) client.Object { + return &v1.Service{ + ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: name, Annotations: annotations}, + Spec: v1.ServiceSpec{Type: v1.ServiceTypeLoadBalancer}, + } + } + + // "eligible" has a hostname annotation and no skip → should be enqueued. + // "skipped" has both annotations → should NOT be enqueued. + c := serviceLbFakeClient(scheme, false, + makeLbSvc("eligible", map[string]string{common.AnnotationDNSHostnameKey: "app.example.com"}), + makeLbSvc("skipped", map[string]string{ + common.AnnotationDNSHostnameKey: "app.example.com", + common.AnnotationsDNSSkip: "true", + }), + ) + r := &ServiceLbReconciler{Client: c, DNS: emptyDNSRecordService()} + reqs := r.enqueueLBServiceRequestsFromNetworkInfo(ctx, ni) + require.Len(t, reqs, 1) + require.Equal(t, types.NamespacedName{Namespace: "ns", Name: "eligible"}, reqs[0].NamespacedName) +} diff --git a/pkg/controllers/service/service_lb_dns.go b/pkg/controllers/service/service_lb_dns.go new file mode 100644 index 000000000..04c742c19 --- /dev/null +++ b/pkg/controllers/service/service_lb_dns.go @@ -0,0 +1,270 @@ +/* Copyright © 2026 Broadcom, Inc. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 */ + +package service + +import ( + "context" + "errors" + "slices" + "strings" + + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/vmware-tanzu/nsx-operator/pkg/apis/vpc/v1alpha1" + servicecommon "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" + "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/dns" + extdns "github.com/vmware-tanzu/nsx-operator/pkg/third_party/externaldns/endpoint" + extdnssrc "github.com/vmware-tanzu/nsx-operator/pkg/third_party/externaldns/source" +) + +const ( + // serviceDNSReadyConditionType is the Service status condition type for LoadBalancer DNS publish state (mirrors Gateway DNSConfig semantics, name per product request). + serviceDNSReadyConditionType = "Ready" + reasonServiceDNSRecordConfigured = "DNSRecordConfigured" + reasonServiceDNSRecordFailed = "DNSRecordFailed" +) + +// allowedZonePathSet converts a zone-path→domain map to a set of zone paths. +func allowedZonePathSet(m map[string]string) sets.Set[string] { + s := make(sets.Set[string], len(m)) + for k := range m { + s.Insert(k) + } + return s +} + +// parseDNSHostnamesFromServiceAnnotation returns trimmed unique FQDNs from nsx.vmware.com/hostname using the same +// gateway-hostname-source + hostname annotation path as Gateway direct DNS (comma-separated tokens are supported). +func parseDNSHostnamesFromServiceAnnotation(annotations map[string]string) []string { + if annotations == nil { + return nil + } + raw := annotations[servicecommon.AnnotationDNSHostnameKey] + if strings.TrimSpace(raw) == "" { + return nil + } + anno := map[string]string{ + servicecommon.AnnotationDNSHostnameSourceKey: extdnssrc.GatewayHostnameSourceAnnotationOnly, + servicecommon.AnnotationDNSHostnameKey: raw, + } + hosts := extdnssrc.RouteHostnames(&metav1.ObjectMeta{Annotations: anno}, nil, + servicecommon.AnnotationDNSHostnameSourceKey, servicecommon.AnnotationDNSHostnameKey, false) + seen := sets.New[string]() + var out []string + for _, h := range hosts { + h = strings.TrimSpace(h) + if h == "" || seen.Has(h) { + continue + } + seen.Insert(h) + out = append(out, h) + } + return out +} + +// targetsFromLoadBalancerIngress collects IP and Hostname values from Service.Status.LoadBalancer.Ingress. +func targetsFromLoadBalancerIngress(ingress []v1.LoadBalancerIngress) extdns.Targets { + var vals []string + for i := range ingress { + ing := ingress[i] + if ip := strings.TrimSpace(ing.IP); ip != "" { + vals = append(vals, ip) + } + if hn := strings.TrimSpace(ing.Hostname); hn != "" { + vals = append(vals, hn) + } + } + return extdns.NewTargets(vals...) +} + +// buildLoadBalancerServiceDNSBatch builds owner-scoped DNS rows for a LoadBalancer Service: annotation hostnames, +// targets from LB ingress, then ValidateEndpointsByZone for namespace VPC policy. +func buildLoadBalancerServiceDNSBatch(svc *v1.Service, w dns.DNSRecordProvider) (*dns.AggregatedDNSEndpoints, map[string]string, error) { + hostnames := parseDNSHostnamesFromServiceAnnotation(svc.GetAnnotations()) + if len(hostnames) == 0 { + return nil, nil, nil + } + targets := targetsFromLoadBalancerIngress(svc.Status.LoadBalancer.Ingress) + if len(targets) == 0 { + log.Debug("LB service has hostname annotation but no ingress targets yet", "namespace", svc.Namespace, "name", svc.Name) + return nil, nil, nil + } + log.Debug("Building DNS batch for LB service", "namespace", svc.Namespace, "name", svc.Name, + "hostnames", len(hostnames), "targets", len(targets)) + ttl := extdns.TTL(0) + var eps []*extdns.Endpoint + for _, h := range hostnames { + for _, ep := range extdns.EndpointsForHostname(h, targets, ttl) { + if ep == nil { + continue + } + eps = append(eps, ep) + } + } + if len(eps) == 0 { + return nil, nil, nil + } + owner := &dns.ResourceRef{Kind: dns.ResourceKindService, Object: svc.GetObjectMeta()} + rows, allowed, err := w.ValidateEndpointsByZone(svc.Namespace, owner, eps) + if err != nil { + return nil, allowed, err + } + if len(rows) == 0 { + return nil, allowed, nil + } + log.Info("DNS batch built for LB service", "namespace", svc.Namespace, "name", svc.Name, "rows", len(rows)) + return dns.NewOwnerScopedAggregatedRouteDNS(owner, rows), allowed, nil +} + +func buildServiceDNSReadyCondition(err error) metav1.Condition { + cond := metav1.Condition{ + Type: serviceDNSReadyConditionType, + LastTransitionTime: metav1.Now(), + } + if err != nil { + cond.Status = metav1.ConditionFalse + cond.Reason = reasonServiceDNSRecordFailed + cond.Message = err.Error() + } else { + cond.Status = metav1.ConditionTrue + cond.Reason = reasonServiceDNSRecordConfigured + } + return cond +} + +// updateServiceDNSReadyCondition sets Service status condition type Ready from DNS reconcile outcome (True when err is nil). +func (r *ServiceLbReconciler) updateServiceDNSReadyCondition(ctx context.Context, ownerKey types.NamespacedName, err error) error { + cond := buildServiceDNSReadyCondition(err) + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + svc := &v1.Service{} + if getErr := r.Client.Get(ctx, ownerKey, svc); getErr != nil { + return client.IgnoreNotFound(getErr) + } + c := cond + c.ObservedGeneration = svc.GetGeneration() + if !meta.SetStatusCondition(&svc.Status.Conditions, c) { + return nil + } + return r.Client.Status().Update(ctx, svc) + }) +} + +// removeServiceDNSReadyCondition removes the Ready DNS status condition; ignores NotFound. +func (r *ServiceLbReconciler) removeServiceDNSReadyCondition(ctx context.Context, key types.NamespacedName) error { + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + svc := &v1.Service{} + if err := r.Client.Get(ctx, key, svc); err != nil { + return client.IgnoreNotFound(err) + } + if !meta.RemoveStatusCondition(&svc.Status.Conditions, serviceDNSReadyConditionType) { + return nil + } + return r.Client.Status().Update(ctx, svc) + }) +} + +// reconcileLoadBalancerServiceDNS applies DNS rows for the Service. published is true when DNS records were created or updated (not delete-only). +func (r *ServiceLbReconciler) reconcileLoadBalancerServiceDNS(ctx context.Context, svc *v1.Service) error { + svcNN := types.NamespacedName{Namespace: svc.Namespace, Name: svc.Name} + log.Info("Reconciling DNS for LB service", "Service", svcNN) + batch, allowedZones, err := buildLoadBalancerServiceDNSBatch(svc, r.DNS) + if err != nil { + var zoneValErr *dns.DNSZoneValidationError + if errors.As(err, &zoneValErr) { + log.Error(err, "Failed to validate DNS records for Service with the allowed DNS zones", "Service", svcNN) + if uerr := r.updateServiceDNSReadyCondition(ctx, svcNN, err); uerr != nil { + log.Error(uerr, "Failed to update DNS conditions", "Service", svcNN) + return uerr + } + if _, derr := r.DNS.DeleteRecordsForOwnerOutsideAllowedZones(ctx, dns.ResourceKindService, svc.Namespace, svc.Name, allowedZonePathSet(allowedZones)); derr != nil { + log.Error(derr, "Failed to clean up the disallowed DNS records", "Service", svcNN) + return derr + } + return nil + } + log.Error(err, "Failed to build DNS endpoints for Service", "Service", svcNN) + if uerr := r.updateServiceDNSReadyCondition(ctx, svcNN, err); uerr != nil { + log.Error(uerr, "Failed to update DNS conditions", "Service", svcNN) + return uerr + } + return err + } + + if batch == nil || len(batch.Rows) == 0 { + if _, derr := r.DNS.DeleteRecordByOwnerNN(ctx, dns.ResourceKindService, svc.Namespace, svc.Name); derr != nil { + log.Error(derr, "Failed to clean up the stale DNS records", "Service", svcNN) + return derr + } + return r.removeServiceDNSReadyCondition(ctx, svcNN) + } + + _, uErr := r.DNS.CreateOrUpdateRecords(ctx, batch) + if condErr := r.updateServiceDNSReadyCondition(ctx, svcNN, uErr); condErr != nil { + log.Error(condErr, "Failed to update DNS ready condition", "Service", svcNN) + return condErr + } + if uErr != nil { + log.Error(uErr, "Failed to reconcile DNS records", "Service", svcNN) + } + return uErr +} + +// enqueueLBServiceRequestsFromNetworkInfo requeues LoadBalancer Services that publish DNS when namespace AllowedDNSDomains change. +func (r *ServiceLbReconciler) enqueueLBServiceRequestsFromNetworkInfo(ctx context.Context, obj client.Object) []reconcile.Request { + ni, ok := obj.(*v1alpha1.NetworkInfo) + if !ok || ni == nil { + return nil + } + svcList := &v1.ServiceList{} + if err := r.Client.List(ctx, svcList, client.InNamespace(ni.Namespace)); err != nil { + log.Error(err, "Failed to list Services for NetworkInfo DNS domain change", "Namespace", ni.Namespace) + return nil + } + var reqs []reconcile.Request + for i := range svcList.Items { + svc := &svcList.Items[i] + if svc.Spec.Type != v1.ServiceTypeLoadBalancer || !svc.ObjectMeta.DeletionTimestamp.IsZero() { + continue + } + if isServiceDNSSkipped(svc.GetAnnotations()) { + continue + } + if len(parseDNSHostnamesFromServiceAnnotation(svc.GetAnnotations())) == 0 { + continue + } + reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: svc.Namespace, Name: svc.Name}}) + } + return reqs +} + +func predicateNetworkInfoAllowedDNSDomainsChanged() predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(event.CreateEvent) bool { + return false + }, + DeleteFunc: func(event.DeleteEvent) bool { + return false + }, + UpdateFunc: func(e event.UpdateEvent) bool { + oldNI, ok1 := e.ObjectOld.(*v1alpha1.NetworkInfo) + newNI, ok2 := e.ObjectNew.(*v1alpha1.NetworkInfo) + if !ok1 || !ok2 { + return false + } + return !slices.Equal(oldNI.AllowedDNSDomains, newNI.AllowedDNSDomains) + }, + GenericFunc: func(event.GenericEvent) bool { + return false + }, + } +} diff --git a/pkg/controllers/service/service_lb_dns_test.go b/pkg/controllers/service/service_lb_dns_test.go new file mode 100644 index 000000000..e3dffb18c --- /dev/null +++ b/pkg/controllers/service/service_lb_dns_test.go @@ -0,0 +1,408 @@ +/* Copyright © 2026 Broadcom, Inc. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 */ + +package service + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + ctrl "sigs.k8s.io/controller-runtime" + + mockdns "github.com/vmware-tanzu/nsx-operator/pkg/mock/dnsrecordprovider" + servicecommon "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/common" + "github.com/vmware-tanzu/nsx-operator/pkg/nsx/services/dns" + extdns "github.com/vmware-tanzu/nsx-operator/pkg/third_party/externaldns/endpoint" +) + +// stubValidatedRows mimics ValidateEndpointsByZone for *.example.com under /zones/t. +func stubValidatedRows(eps []*extdns.Endpoint) ([]dns.EndpointRow, map[string]string, error) { + const zpath = "/orgs/org1/projects/proj1/dns-services/dns1/zones/t" + var rows []dns.EndpointRow + for _, ep := range eps { + if ep == nil { + continue + } + dn := strings.ToLower(strings.TrimSpace(ep.DNSName)) + if !strings.HasSuffix(dn, ".example.com") { + return nil, nil, fmt.Errorf("hostname %q does not match stub allowed domain", ep.DNSName) + } + rel := strings.TrimSuffix(dn, ".example.com") + rel = strings.TrimPrefix(rel, ".") + rows = append(rows, *dns.NewEndpointRow(ep, zpath, rel)) + } + return rows, map[string]string{zpath: "example.com"}, nil +} + +func TestParseDNSHostnamesFromServiceAnnotation_table(t *testing.T) { + tests := []struct { + name string + ann map[string]string + want []string + }{ + { + name: "comma_separated_trims_spaces", + ann: map[string]string{servicecommon.AnnotationDNSHostnameKey: "a.example.com, b.example.com "}, + want: []string{"a.example.com", "b.example.com"}, + }, + { + name: "empty_annotation", + ann: map[string]string{}, + want: nil, + }, + { + name: "no_annotation", + ann: nil, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseDNSHostnamesFromServiceAnnotation(tt.ann) + if tt.want == nil { + require.Empty(t, got) + return + } + require.Equal(t, tt.want, got) + }) + } +} + +func TestTargetsFromLoadBalancerIngress_table(t *testing.T) { + tests := []struct { + name string + ingress []v1.LoadBalancerIngress + want []string + }{ + { + name: "ip_and_hostname", + ingress: []v1.LoadBalancerIngress{ + {IP: "10.0.0.1"}, + {Hostname: "lb.vendor.example"}, + }, + want: []string{"10.0.0.1", "lb.vendor.example"}, + }, + { + name: "empty", + ingress: nil, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := targetsFromLoadBalancerIngress(tt.ingress) + if tt.want == nil { + require.Empty(t, got) + return + } + assert.ElementsMatch(t, tt.want, []string(got)) + }) + } +} + +func TestBuildLoadBalancerServiceDNSBatch_table(t *testing.T) { + makeSvc := func(ns, name, uid string, annotations map[string]string, ingress []v1.LoadBalancerIngress) *v1.Service { + return &v1.Service{ + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name, UID: types.UID(uid), Annotations: annotations}, + Status: v1.ServiceStatus{LoadBalancer: v1.LoadBalancerStatus{Ingress: ingress}}, + } + } + + tests := []struct { + name string + svc *v1.Service + wantNRows int + wantErr bool + }{ + { + name: "two_hostnames_one_ip", + svc: makeSvc("ns1", "svc1", "uid-1", + map[string]string{servicecommon.AnnotationDNSHostnameKey: "h1.example.com,h2.example.com"}, + []v1.LoadBalancerIngress{{IP: "192.0.2.1"}}), + wantNRows: 2, + }, + { + name: "no_hostname_annotation", + svc: makeSvc("ns1", "svc2", "uid-2", nil, + []v1.LoadBalancerIngress{{IP: "192.0.2.1"}}), + wantNRows: 0, + }, + { + name: "service_owned_by_gateway_is_skipped", + svc: &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns1", + Name: "gw-svc", + UID: "uid-gw", + Annotations: map[string]string{servicecommon.AnnotationDNSHostnameKey: "h.example.com"}, + OwnerReferences: []metav1.OwnerReference{ + {APIVersion: "gateway.networking.k8s.io/v1", Kind: "Gateway", Name: "my-gw"}, + }, + }, + Status: v1.ServiceStatus{LoadBalancer: v1.LoadBalancerStatus{Ingress: []v1.LoadBalancerIngress{{IP: "192.0.2.9"}}}}, + }, + wantNRows: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockCtl := gomock.NewController(t) + t.Cleanup(func() { mockCtl.Finish() }) + w := mockdns.NewMockDNSRecordProvider(mockCtl) + assignDNSListStubs(w) + if tt.wantNRows > 0 { + w.EXPECT().ValidateEndpointsByZone(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ string, _ *dns.ResourceRef, eps []*extdns.Endpoint) ([]dns.EndpointRow, map[string]string, error) { + return stubValidatedRows(eps) + }).Times(1) + } + batch, _, err := buildLoadBalancerServiceDNSBatch(tt.svc, w) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + if tt.wantNRows == 0 { + require.Nil(t, batch) + return + } + require.NotNil(t, batch) + require.Equal(t, dns.ResourceKindService, batch.Owner.Kind) + require.Len(t, batch.Rows, tt.wantNRows) + }) + } +} + +func TestReconcileLoadBalancerServiceDNS(t *testing.T) { + ctx := context.Background() + scheme := serviceLbTestScheme(t) + + makeLbSvc := func(ns, name, uid, hostname, ip string) *v1.Service { + svc := &v1.Service{ + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name, UID: types.UID(uid)}, + Spec: v1.ServiceSpec{Type: v1.ServiceTypeLoadBalancer}, + } + if hostname != "" { + svc.Annotations = map[string]string{servicecommon.AnnotationDNSHostnameKey: hostname} + } + if ip != "" { + svc.Status.LoadBalancer.Ingress = []v1.LoadBalancerIngress{{IP: ip}} + } + return svc + } + + tests := []struct { + name string + svc *v1.Service + setupMock func(m *mockdns.MockDNSRecordProvider) + }{ + { + name: "hostname_with_ip_publishes", + svc: makeLbSvc("ns1", "lb", "u1", "app.example.com", "203.0.113.5"), + setupMock: func(m *mockdns.MockDNSRecordProvider) { + m.EXPECT().ValidateEndpointsByZone(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ string, _ *dns.ResourceRef, eps []*extdns.Endpoint) ([]dns.EndpointRow, map[string]string, error) { + return stubValidatedRows(eps) + }).Times(1) + m.EXPECT().CreateOrUpdateRecords(gomock.Any(), gomock.Any()).Return(true, nil).Times(1) + }, + }, + { + name: "dnsZoneValidationError_deletesOutsideAllowed", + svc: makeLbSvc("ns1", "lb", "u4", "app.example.com", "203.0.113.6"), + setupMock: func(m *mockdns.MockDNSRecordProvider) { + allowed := map[string]string{"/zones/t": "example.com"} + m.EXPECT().ValidateEndpointsByZone(gomock.Any(), gomock.Any(), gomock.Any()).Return( + nil, allowed, &dns.DNSZoneValidationError{Msg: "zone mismatch"}).Times(1) + m.EXPECT().DeleteRecordsForOwnerOutsideAllowedZones(gomock.Any(), + dns.ResourceKindService, "ns1", "lb", gomock.Any()).DoAndReturn( + func(_ context.Context, kind, ns, name string, got sets.Set[string]) (bool, error) { + require.Equal(t, allowedZonePathSet(allowed), got) + return false, nil + }).Times(1) + }, + }, + { + name: "no_ip_targets_deletes", + svc: makeLbSvc("ns1", "lb", "u2", "app.example.com", ""), + setupMock: func(m *mockdns.MockDNSRecordProvider) { + m.EXPECT().DeleteRecordByOwnerNN(gomock.Any(), dns.ResourceKindService, "ns1", "lb").Return(true, nil).Times(1) + }, + }, + { + name: "no_annotation_skips", + svc: makeLbSvc("ns1", "lb", "u3", "", "203.0.113.5"), + setupMock: func(m *mockdns.MockDNSRecordProvider) { + m.EXPECT().DeleteRecordByOwnerNN(gomock.Any(), dns.ResourceKindService, "ns1", "lb").Return(false, nil).Times(1) + }, + }, + { + // Service is owned by a Gateway: buildLoadBalancerServiceDNSBatch returns nil + // so the reconcile cleans up any stale records without creating new ones. + name: "service_owned_by_gateway_cleans_up_dns", + svc: func() *v1.Service { + svc := makeLbSvc("ns1", "gw-svc", "uid-gw", "h.example.com", "192.0.2.1") + svc.OwnerReferences = []metav1.OwnerReference{ + {APIVersion: "gateway.networking.k8s.io/v1", Kind: "Gateway", Name: "my-gw"}, + } + return svc + }(), + setupMock: func(m *mockdns.MockDNSRecordProvider) { + m.EXPECT().DeleteRecordByOwnerNN(gomock.Any(), dns.ResourceKindService, "ns1", "gw-svc").Return(false, nil).Times(1) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockCtl := gomock.NewController(t) + t.Cleanup(func() { mockCtl.Finish() }) + m := mockdns.NewMockDNSRecordProvider(mockCtl) + assignDNSListStubs(m) + tt.setupMock(m) + r := &ServiceLbReconciler{ + Client: serviceLbFakeClient(scheme, false, tt.svc), + DNS: m, + } + err := r.reconcileLoadBalancerServiceDNS(ctx, tt.svc) + require.NoError(t, err) + }) + } +} + +func TestServiceLbReconciler_Reconcile_dnsOnDelete(t *testing.T) { + ctx := context.Background() + scheme := serviceLbTestScheme(t) + mockCtl := gomock.NewController(t) + t.Cleanup(func() { mockCtl.Finish() }) + m := mockdns.NewMockDNSRecordProvider(mockCtl) + assignDNSListStubs(m) + m.EXPECT().DeleteRecordByOwnerNN(gomock.Any(), dns.ResourceKindService, "ns1", "gone").Return(false, nil).Times(1) + r := &ServiceLbReconciler{ + Client: serviceLbFakeClient(scheme, false), + DNS: m, + Recorder: fakeRecorder{}, + } + _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "ns1", Name: "gone"}}) + require.NoError(t, err) +} + +// TestReconcileLoadBalancerServiceDNS_nonZoneValidationErrUpdatesCondition verifies that when +// buildLoadBalancerServiceDNSBatch returns a plain (non-DNSZoneValidationError) error, the +// reconciler still sets the Ready condition to False/DNSRecordFailed and returns the error. +func TestReconcileLoadBalancerServiceDNS_nonZoneValidationErrUpdatesCondition(t *testing.T) { + ctx := context.Background() + scheme := serviceLbTestScheme(t) + mockCtl := gomock.NewController(t) + t.Cleanup(func() { mockCtl.Finish() }) + m := mockdns.NewMockDNSRecordProvider(mockCtl) + assignDNSListStubs(m) + + infraErr := fmt.Errorf("vpc service unavailable") + m.EXPECT().ValidateEndpointsByZone(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, infraErr).Times(1) + + svc := &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns1", + Name: "lb", + UID: "u-infra", + Annotations: map[string]string{servicecommon.AnnotationDNSHostnameKey: "app.example.com"}, + Generation: 2, + }, + Spec: v1.ServiceSpec{Type: v1.ServiceTypeLoadBalancer}, + Status: v1.ServiceStatus{ + LoadBalancer: v1.LoadBalancerStatus{Ingress: []v1.LoadBalancerIngress{{IP: "203.0.113.5"}}}, + }, + } + r := &ServiceLbReconciler{ + Client: serviceLbFakeClient(scheme, true, svc), + DNS: m, + } + err := r.reconcileLoadBalancerServiceDNS(ctx, svc) + require.ErrorIs(t, err, infraErr) + + got := &v1.Service{} + require.NoError(t, r.Client.Get(ctx, types.NamespacedName{Namespace: "ns1", Name: "lb"}, got)) + c := meta.FindStatusCondition(got.Status.Conditions, serviceDNSReadyConditionType) + require.NotNil(t, c, "Ready condition must be set even for non-DNSZoneValidationError") + assert.Equal(t, metav1.ConditionFalse, c.Status) + assert.Equal(t, reasonServiceDNSRecordFailed, c.Reason) + assert.Contains(t, c.Message, "vpc service unavailable") +} + +func TestServiceLbReconciler_updateServiceDNSReadyCondition_table(t *testing.T) { + ctx := context.Background() + scheme := serviceLbTestScheme(t) + + tests := []struct { + name string + err error + wantStatus metav1.ConditionStatus + wantReason string + msgSub string + }{ + { + name: "success_sets_true", + err: nil, + wantStatus: metav1.ConditionTrue, + wantReason: reasonServiceDNSRecordConfigured, + }, + { + name: "error_sets_false", + err: fmt.Errorf("zone validation failed"), + wantStatus: metav1.ConditionFalse, + wantReason: reasonServiceDNSRecordFailed, + msgSub: "zone validation failed", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := &v1.Service{ + ObjectMeta: metav1.ObjectMeta{Namespace: "ns1", Name: "lb", Generation: 3}, + Spec: v1.ServiceSpec{Type: v1.ServiceTypeLoadBalancer}, + } + r := &ServiceLbReconciler{Client: serviceLbFakeClient(scheme, true, svc)} + nn := types.NamespacedName{Namespace: "ns1", Name: "lb"} + require.NoError(t, r.updateServiceDNSReadyCondition(ctx, nn, tt.err)) + got := &v1.Service{} + require.NoError(t, r.Client.Get(ctx, nn, got)) + c := meta.FindStatusCondition(got.Status.Conditions, serviceDNSReadyConditionType) + require.NotNil(t, c) + assert.Equal(t, tt.wantStatus, c.Status) + assert.Equal(t, tt.wantReason, c.Reason) + if tt.msgSub != "" { + assert.Contains(t, c.Message, tt.msgSub) + } + assert.Equal(t, int64(3), c.ObservedGeneration) + }) + } +} + +func TestServiceLbReconciler_removeServiceDNSReadyCondition(t *testing.T) { + ctx := context.Background() + scheme := serviceLbTestScheme(t) + svc := &v1.Service{ + ObjectMeta: metav1.ObjectMeta{Namespace: "ns1", Name: "lb"}, + Spec: v1.ServiceSpec{Type: v1.ServiceTypeLoadBalancer}, + Status: v1.ServiceStatus{ + Conditions: []metav1.Condition{{ + Type: serviceDNSReadyConditionType, Status: metav1.ConditionTrue, Reason: reasonServiceDNSRecordConfigured, + }}, + }, + } + r := &ServiceLbReconciler{Client: serviceLbFakeClient(scheme, true, svc)} + nn := types.NamespacedName{Namespace: "ns1", Name: "lb"} + require.NoError(t, r.removeServiceDNSReadyCondition(ctx, nn)) + got := &v1.Service{} + require.NoError(t, r.Client.Get(ctx, nn, got)) + assert.Nil(t, meta.FindStatusCondition(got.Status.Conditions, serviceDNSReadyConditionType)) +} diff --git a/pkg/nsx/services/common/types.go b/pkg/nsx/services/common/types.go index e153d3ca8..5b059cff7 100644 --- a/pkg/nsx/services/common/types.go +++ b/pkg/nsx/services/common/types.go @@ -118,6 +118,9 @@ const ( TagValueDNSRecordForGRPCRoute string = "grpcroute" TagValueDNSRecordForTLSRoute string = "tlsroute" TagValueDNSRecordForService string = "service" + AnnotationDNSHostnameKey string = "nsx.vmware.com/hostname" + AnnotationDNSHostnameSourceKey string = "nsx.vmware.com/gateway-hostname-source" + AnnotationsDNSSkip string = "nsx.vmware.com/skip" // TagScopePodIndex is the NSX tag scope for Pod label apps.kubernetes.io/pod-index when synced onto the port (not set in BuildBasicTags). TagScopePodIndex string = "apps.kubernetes.io/pod-index" diff --git a/pkg/third_party/externaldns/annotations/doc.go b/pkg/third_party/externaldns/annotations/doc.go new file mode 100644 index 000000000..9c9a90b5d --- /dev/null +++ b/pkg/third_party/externaldns/annotations/doc.go @@ -0,0 +1,20 @@ +// Package annotations provides a small subset of ExternalDNS source annotation helpers. +// Upstream: sigs.k8s.io/external-dns/source/annotations (notably processors.go for hostname splitting/list parsing; +// upstream also defines fixed annotation key constants used by the full Gateway/Ingress sources). +// +// # Direct copy from external-dns (same logic; same exported names where applicable) +// +// SplitHostnameAnnotation — same comma-separated hostname tokenization as upstream processors. +// HostnamesFromAnnotations(input, hostnameKey) — same parsing as upstream after resolving the key; upstream +// overload reads a package-level hostname key constant. +// +// # Modified from external-dns +// +// HostnamesFromAnnotations — returns nil if hostnameKey is "" or input is nil (defensive); upstream resolves +// from a fixed key and may not short-circuit the same way. +// +// # nsx-operator / subset +// +// This package does **not** vendor upstream annotation **key** constants. Callers (e.g. gateway controller + +// source.RouteHostnames) pass key strings—typically from pkg/nsx/services/common—or any compatible key for tests. +package annotations diff --git a/pkg/third_party/externaldns/annotations/hostnames.go b/pkg/third_party/externaldns/annotations/hostnames.go new file mode 100644 index 000000000..80725c273 --- /dev/null +++ b/pkg/third_party/externaldns/annotations/hostnames.go @@ -0,0 +1,34 @@ +// Copyright 2025 The Kubernetes Authors. +// Copyright 2026 Broadcom, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Derived from sigs.k8s.io/external-dns/source/annotations/processors.go (hostname helpers). +// Attribution: see package doc.go. + +package annotations + +import ( + "strings" +) + +// SplitHostnameAnnotation splits a comma-separated hostname annotation string into a slice of hostnames. +func SplitHostnameAnnotation(input string) []string { + return strings.Split(strings.TrimSpace(strings.ReplaceAll(input, " ", "")), ",") +} + +// HostnamesFromAnnotations extracts hostnames from the annotation identified by hostnameKey +// (e.g. nsx.vmware.com/hostname or external-dns.alpha.kubernetes.io/hostname). +func HostnamesFromAnnotations(input map[string]string, hostnameKey string) []string { + if hostnameKey == "" { + return nil + } + if input == nil { + return nil + } + annotation, ok := input[hostnameKey] + if !ok { + return nil + } + return SplitHostnameAnnotation(annotation) +} diff --git a/pkg/third_party/externaldns/source/doc.go b/pkg/third_party/externaldns/source/doc.go new file mode 100644 index 000000000..178fe8f39 --- /dev/null +++ b/pkg/third_party/externaldns/source/doc.go @@ -0,0 +1,47 @@ +// Package source implements Gateway API hostname and status checks aligned with ExternalDNS’s +// Gateway route source. Primary upstream reference: sigs.k8s.io/external-dns/source/gateway.go +// (gatewayRoute interface, (*gatewayRouteResolver).hosts, matchRouteToListener, gwMatchingHost, gwHost, +// isIPAddr, listener / ListenerSet handling) and source/gateway_hostname.go (ASCII lower-case). +// This package is **not** a port of the full Gateway source: there are no informers, no +// templateEngine.ExecFQDN implementation here—callers pass fqdn-template outputs into +// RouteHostnamesForRoute when needed. +// +// # Direct copy or same logic as upstream (names may differ) +// +// ToLowerCaseASCII — upstream toLowerCaseASCII in source/gateway_hostname.go (exported here). +// GwMatchingHost — upstream gwMatchingHost (gateway_host_matching.go). +// GatewayCanonicalHost — upstream gwHost (exported); private DNS1123 / isAlphaNum helpers same idea as gateway.go (gateway_host_matching.go). +// isGatewayHostIP — upstream isIPAddr; uses this repo’s endpoint.SuitableType instead of upstream endpoint. +// conditionTrue — same idea as upstream conditionStatusIsTrue on Route parent conditions (renamed). +// +// # Factored from upstream gateway.go (same rules, different packaging) +// +// ParentRefMatchesGateway — group/kind/name/namespace checks for a ParentReference to a Gateway +// (namespaced ref); equivalent to the ref checks embedded in the upstream resolver, not one named upstream func. +// HTTPRouteParentReadyForGateway, GRPCRouteParentReadyForGateway, TLSRouteParentReadyForGateway — +// return true when the route’s status.parents entry for the given Gateway has RouteConditionAccepted=True +// (same parent gate style as ExternalDNS for the HTTP path; see upstream gateway route readiness checks). +// CollectAdmissionHostnameFilters — listener hostname allow-list from Gateway + ListenerSet spec listeners +// (nil Hostname → ""); deduped; mirrors how upstream builds listener-side host filters for matchRouteToListener. +// RouteHostnames, RouteHostnamesForRoute, mergeGatewayRouteHostnameAnnotations, appendAnnotationHostsDefault — +// same gateway-hostname-source modes and hostname annotation merge as (*gatewayRouteResolver).hosts(); callers pass +// gatewayHostnameSourceAnnoKey and hostnameAnnoKey strings (upstream hard-codes annotation keys in annotations package). +// Invalid gateway-hostname-source values: upstream logs with logrus; here log/slog Default logger Warn. +// RouteHostnames* return []string only (no error); upstream hosts() returns ([]string, error) mainly for FQDN template errors. +// normalizeHostnamesList, NormalizeHostnameStrings — local helpers. +// RouteSpecHostnames — HTTPRoute/GRPCRoute/TLSRoute Spec.Hostnames to trimmed strings. +// +// # nsx-operator admission (Gateway API DNS scope; upstream matchRouteToListener + gwMatchingHost) +// +// RouteHostnamesMatchingAdmission — maps raw route hostname tokens through GwMatchingHost against allowed filters; +// skips empty+empty filter/route pairs; empty route host with multiple listener filters yields multiple names; +// wildcard DNS names require a non-empty hostname annotation at hostnameAnnoKey. Signature includes error for API +// symmetry; current implementation always returns a nil error. +// admissionMatchesForRouteHost, hostnameMoreSpecific — local helpers. +// +// # nsx-operator direct Gateway / ListenerSet DNS (not in upstream as named APIs) +// +// ClaimGwMatchingDNSName (in gateway_host_matching.go) — ordered hostname precedence using GwMatchingHost when +// merging annotation-derived DNS batches (Gateway before ListenerSet). Upstream builds endpoints in one Gateway +// source pass and does not expose this batch-dedupe step. +package source diff --git a/pkg/third_party/externaldns/source/route_hostnames.go b/pkg/third_party/externaldns/source/route_hostnames.go new file mode 100644 index 000000000..2d8d4da53 --- /dev/null +++ b/pkg/third_party/externaldns/source/route_hostnames.go @@ -0,0 +1,92 @@ +/* Copyright © 2026 Broadcom, Inc. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 */ + +// Route hostname merging follows source/gateway.go (*gatewayRouteResolver).hosts(): +// spec hostnames, optional FQDN-template hosts, then gateway-hostname-source + hostname annotations. +// Attribution: see package doc.go. + +package source + +import ( + "log/slog" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + extann "github.com/vmware-tanzu/nsx-operator/pkg/third_party/externaldns/annotations" +) + +const ( + // GatewayHostnameSourceAnnotationOnly matches external-dns gatewayHostnameSourceAnnotationOnlyValue. + GatewayHostnameSourceAnnotationOnly = "annotation-only" + // GatewayHostnameSourceDefinedHostsOnly matches external-dns gatewayHostnameSourceDefinedHostsOnlyValue. + GatewayHostnameSourceDefinedHostsOnly = "defined-hosts-only" +) + +// RouteHostnames is RouteHostnamesForRoute with no FQDN-template hostnames (ExternalDNS gateway-hostname-source rules). +func RouteHostnames(meta *metav1.ObjectMeta, specHostnames []string, gatewayHostnameSourceAnnoKey, hostnameAnnoKey string, ignoreHostnameAnnotation bool) []string { + return RouteHostnamesForRoute(meta, specHostnames, nil, gatewayHostnameSourceAnnoKey, hostnameAnnoKey, ignoreHostnameAnnotation) +} + +// RouteHostnamesForRoute merges spec + optional fqdnTemplate hostnames, then gateway-hostname-source + hostname annotations (see gateway.go hosts()). +// routeSpecHostnamesEmpty must match len(specHostnames)==0 before templates (ExternalDNS empty-hostname branch). +func RouteHostnamesForRoute(meta *metav1.ObjectMeta, specHostnames, fqdnTemplateHostnames []string, gatewayHostnameSourceAnnoKey, hostnameAnnoKey string, ignoreHostnameAnnotation bool) []string { + hostnames := append([]string(nil), specHostnames...) + if len(fqdnTemplateHostnames) > 0 { + hostnames = append(hostnames, fqdnTemplateHostnames...) + } + routeSpecHostnamesEmpty := len(specHostnames) == 0 + return mergeGatewayRouteHostnameAnnotations(meta, hostnames, routeSpecHostnamesEmpty, ignoreHostnameAnnotation, gatewayHostnameSourceAnnoKey, hostnameAnnoKey) +} + +func mergeGatewayRouteHostnameAnnotations(meta *metav1.ObjectMeta, hostnames []string, routeSpecHostnamesEmpty bool, ignoreHostnameAnnotation bool, gatewayHostnameSourceAnnoKey, hostnameAnnoKey string) []string { + if meta == nil { + return hostnames + } + ann := meta.GetAnnotations() + if ann == nil { + ann = map[string]string{} + } + raw, hasKey := ann[gatewayHostnameSourceAnnoKey] + if !hasKey { + return appendAnnotationHostsDefault(hostnames, ann, routeSpecHostnamesEmpty, ignoreHostnameAnnotation, hostnameAnnoKey) + } + switch strings.ToLower(strings.TrimSpace(raw)) { + case GatewayHostnameSourceAnnotationOnly: + if ignoreHostnameAnnotation { + return []string{} + } + h := extann.HostnamesFromAnnotations(ann, hostnameAnnoKey) + if h == nil { + return []string{} + } + return h + case GatewayHostnameSourceDefinedHostsOnly: + return hostnames + default: + slog.Default().Warn("invalid gateway-hostname-source, falling back to default behavior", + "annotation", gatewayHostnameSourceAnnoKey, + "namespace", meta.Namespace, + "name", meta.Name, + "value", raw, + ) + return appendAnnotationHostsDefault(hostnames, ann, routeSpecHostnamesEmpty, ignoreHostnameAnnotation, hostnameAnnoKey) + } +} + +// appendAnnotationHostsDefault merges hostname annotation values (caller-selected hostnameKey) with +// spec/template hostnames for the default gateway-hostname-source path (same merge order semantics as +// ExternalDNS hosts() default branch; upstream uses fixed annotation key constants). +// Non-empty annotation hostnames are prepended so DNS admission and record generation prefer explicit +// operator overrides over route spec names (nsx-operator; upstream appends annotations after spec). +func appendAnnotationHostsDefault(hostnames []string, ann map[string]string, routeSpecHostnamesEmpty, ignoreHostnameAnnotation bool, hostnameKey string) []string { + var out []string + if !ignoreHostnameAnnotation { + out = append(out, extann.HostnamesFromAnnotations(ann, hostnameKey)...) + } + if routeSpecHostnamesEmpty { + out = append(out, "") + } + out = append(out, hostnames...) + return out +} diff --git a/pkg/third_party/externaldns/source/route_hostnames_test.go b/pkg/third_party/externaldns/source/route_hostnames_test.go new file mode 100644 index 000000000..e6e7ad10c --- /dev/null +++ b/pkg/third_party/externaldns/source/route_hostnames_test.go @@ -0,0 +1,84 @@ +/* Copyright © 2026 Broadcom, Inc. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 */ + +package source + +import ( + "bytes" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ( + testDNSRouteKeyGatewayHostnameSource = "external-dns.alpha.kubernetes.io/gateway-hostname-source" + testDNSRouteKeyHostname = "external-dns.alpha.kubernetes.io/hostname" +) + +func TestRouteHostnames_andForRoute_table(t *testing.T) { + type mode int + const ( + hostnames mode = iota + forRoute + ) + tests := []struct { + mode mode + name string + ann map[string]string + spec, templates []string + wantEqual []string + wantContains []string + wantFirst string + wantMinLen int + }{ + {hostnames, "defaultWithAnnotation", map[string]string{testDNSRouteKeyHostname: "ann.example.com"}, nil, nil, nil, []string{"", "ann.example.com"}, "ann.example.com", 0}, + {hostnames, "defaultAnnotationPrependedBeforeSpec", map[string]string{testDNSRouteKeyHostname: "ann.example.com"}, []string{"spec.example.com"}, nil, nil, []string{"ann.example.com", "spec.example.com"}, "ann.example.com", 2}, + {hostnames, "definedHostsOnlyIgnoresHostnameAnnotation", map[string]string{testDNSRouteKeyGatewayHostnameSource: GatewayHostnameSourceDefinedHostsOnly, testDNSRouteKeyHostname: "ignored.example.com"}, []string{"spec.example.com"}, nil, []string{"spec.example.com"}, nil, "", 0}, + {hostnames, "annotationOnly", map[string]string{testDNSRouteKeyGatewayHostnameSource: GatewayHostnameSourceAnnotationOnly, testDNSRouteKeyHostname: "only.example.com"}, []string{"spec.example.com"}, nil, []string{"only.example.com"}, nil, "", 0}, + {forRoute, "templateAppendsBeforeSource", map[string]string{testDNSRouteKeyGatewayHostnameSource: GatewayHostnameSourceDefinedHostsOnly}, []string{"spec.example.com"}, []string{"tpl.example.com"}, []string{"spec.example.com", "tpl.example.com"}, nil, "", 0}, + {forRoute, "emptySpecPlaceholderUsesSpecOnly", map[string]string{testDNSRouteKeyHostname: "ann.example.com"}, nil, []string{"from.template.example.com"}, nil, []string{"", "ann.example.com", "from.template.example.com"}, "", 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + meta := &metav1.ObjectMeta{Namespace: "ns1", Name: "r1", Annotations: tt.ann} + var h []string + if tt.mode == hostnames { + h = RouteHostnames(meta, tt.spec, testDNSRouteKeyGatewayHostnameSource, testDNSRouteKeyHostname, false) + } else { + h = RouteHostnamesForRoute(meta, tt.spec, tt.templates, testDNSRouteKeyGatewayHostnameSource, testDNSRouteKeyHostname, false) + } + if len(tt.wantEqual) > 0 { + assert.Equal(t, tt.wantEqual, h) + return + } + for _, w := range tt.wantContains { + assert.Contains(t, h, w) + } + if tt.wantMinLen > 0 { + require.GreaterOrEqual(t, len(h), tt.wantMinLen) + } + if tt.wantFirst != "" { + require.NotEmpty(t, h) + assert.Equal(t, tt.wantFirst, h[0]) + } + }) + } +} + +func TestRouteHostnames_invalidGatewayHostnameSourceLogsAndFallsBack(t *testing.T) { + var buf bytes.Buffer + prev := slog.Default() + t.Cleanup(func() { slog.SetDefault(prev) }) + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))) + meta := &metav1.ObjectMeta{Namespace: "ns1", Name: "r1", Annotations: map[string]string{ + testDNSRouteKeyGatewayHostnameSource: "not-a-valid-mode", testDNSRouteKeyHostname: "ann.example.com", + }} + h := RouteHostnames(meta, []string{"spec.example.com"}, testDNSRouteKeyGatewayHostnameSource, testDNSRouteKeyHostname, false) + assert.Contains(t, h, "spec.example.com") + assert.Contains(t, h, "ann.example.com") + assert.Contains(t, buf.String(), "invalid gateway-hostname-source") + assert.Contains(t, buf.String(), "not-a-valid-mode") +}