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..3660318b2 --- /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" + + model "github.com/vmware/vsphere-automation-sdk-go/services/nsxt/model" + gomock "go.uber.org/mock/gomock" +) + +// 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..37f7baf0d --- /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" + + model "github.com/vmware/vsphere-automation-sdk-go/services/nsxt/model" + gomock "go.uber.org/mock/gomock" +) + +// 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..61f10db62 --- /dev/null +++ b/pkg/nsx/services/dns/recordservice.go @@ -0,0 +1,436 @@ +/* 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" + nsxutil "github.com/vmware-tanzu/nsx-operator/pkg/nsx/util" + extdns "github.com/vmware-tanzu/nsx-operator/pkg/third_party/externaldns/endpoint" +) + +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 + } + 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 + } + 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)})