From 9c5973d3ac97372e2f745189e01b264754e39901 Mon Sep 17 00:00:00 2001 From: hrudayavesha <139137784+hruday-avesha@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:53:07 +0530 Subject: [PATCH] test: add unit tests and filtered coverage tooling Expand unit test coverage across controllers and pkg packages using table-driven tests and mock/fake clients, fix cluster Status mock stubs, and add scripts/Makefile targets for filtered unit coverage reporting. Co-authored-by: Cursor --- Makefile | 5 + controllers/controller_unit_test.go | 636 ++++++++++++++++++ controllers/serviceexport/utils_unit_test.go | 308 +++++++++ .../serviceimport/istio_utils_unit_test.go | 209 ++++++ controllers/serviceimport/utils_unit_test.go | 221 ++++++ controllers/slice/utils_unit_test.go | 270 ++++++++ controllers/slicegateway/utils_unit_test.go | 578 ++++++++++++++++ pkg/cluster/cluster_unit_test.go | 389 +++++++++++ pkg/cluster/node_unit_test.go | 329 +++++++++ pkg/events/events_recorder_unit_test.go | 141 ++++ pkg/gatewayedge/gatewayedge_test.go | 87 +++ pkg/gwsidecar/gwsidecar_test.go | 58 ++ .../cluster/reconciler_unit_test.go | 10 + .../serviceimport_controller_unit_test.go | 525 +++++++++++++++ .../slicegateway_controller_unit_test.go | 568 ++++++++++++++++ .../helpers_unit_test.go | 258 +++++++ .../reconciler_unit_test.go | 411 +++++++++++ .../workerslicegwrecycler/utils_unit_test.go | 126 ++++ pkg/hub/hubclient/hubclient_unit_test.go | 549 +++++++++++++++ pkg/hub/manager/manager_test.go | 96 +++ pkg/hub/utils_test.go | 153 +++++ pkg/logger/logger_unit_test.go | 205 ++++++ pkg/manifest/file_unit_test.go | 202 ++++++ pkg/manifest/ingress_egress_unit_test.go | 372 ++++++++++ pkg/mocks/mocks.go | 3 +- pkg/monitoring/events_unit_test.go | 288 ++++++++ .../controllers/reconciler_unit_test.go | 512 ++++++++++++++ pkg/netop/netop_test.go | 99 +++ pkg/networkpolicy/reconciler_unit_test.go | 300 +++++++++ pkg/router/router_test.go | 133 ++++ pkg/slicegwrecycler/slicegwrecycler_test.go | 59 ++ pkg/utils/utils_unit_test.go | 171 +++++ pkg/webhook/pod/webhook_utils_unit_test.go | 389 +++++++++++ scripts/coverage-unit.sh | 51 ++ scripts/pkg-coverage.sh | 38 ++ 35 files changed, 8747 insertions(+), 2 deletions(-) create mode 100644 controllers/controller_unit_test.go create mode 100644 controllers/serviceexport/utils_unit_test.go create mode 100644 controllers/serviceimport/istio_utils_unit_test.go create mode 100644 controllers/serviceimport/utils_unit_test.go create mode 100644 controllers/slice/utils_unit_test.go create mode 100644 controllers/slicegateway/utils_unit_test.go create mode 100644 pkg/cluster/cluster_unit_test.go create mode 100644 pkg/cluster/node_unit_test.go create mode 100644 pkg/events/events_recorder_unit_test.go create mode 100644 pkg/gatewayedge/gatewayedge_test.go create mode 100644 pkg/gwsidecar/gwsidecar_test.go create mode 100644 pkg/hub/controllers/serviceimport_controller_unit_test.go create mode 100644 pkg/hub/controllers/slicegateway_controller_unit_test.go create mode 100644 pkg/hub/controllers/workerslicegwrecycler/helpers_unit_test.go create mode 100644 pkg/hub/controllers/workerslicegwrecycler/reconciler_unit_test.go create mode 100644 pkg/hub/controllers/workerslicegwrecycler/utils_unit_test.go create mode 100644 pkg/hub/hubclient/hubclient_unit_test.go create mode 100644 pkg/hub/manager/manager_test.go create mode 100644 pkg/hub/utils_test.go create mode 100644 pkg/logger/logger_unit_test.go create mode 100644 pkg/manifest/file_unit_test.go create mode 100644 pkg/manifest/ingress_egress_unit_test.go create mode 100644 pkg/monitoring/events_unit_test.go create mode 100644 pkg/namespace/controllers/reconciler_unit_test.go create mode 100644 pkg/netop/netop_test.go create mode 100644 pkg/networkpolicy/reconciler_unit_test.go create mode 100644 pkg/router/router_test.go create mode 100644 pkg/slicegwrecycler/slicegwrecycler_test.go create mode 100644 pkg/utils/utils_unit_test.go create mode 100644 pkg/webhook/pod/webhook_utils_unit_test.go create mode 100644 scripts/coverage-unit.sh create mode 100644 scripts/pkg-coverage.sh diff --git a/Makefile b/Makefile index 5b9b9bb42..00968a189 100644 --- a/Makefile +++ b/Makefile @@ -99,6 +99,11 @@ test: fmt vet envtest ## Run tests. unit-test-coverage: test go tool cover -func coverage.out +# Unit-only coverage (skips envtest suites; excludes generated files from the metric). +.PHONY: unit-coverage +unit-coverage: + bash scripts/coverage-unit.sh + .PHONY: test-docker test-docker: docker build -t test -f test.Dockerfile . && docker run test diff --git a/controllers/controller_unit_test.go b/controllers/controller_unit_test.go new file mode 100644 index 000000000..b8d01c82c --- /dev/null +++ b/controllers/controller_unit_test.go @@ -0,0 +1,636 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 controllers + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + kubeslicev1beta1 "github.com/kubeslice/worker-operator/api/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestExists(t *testing.T) { + tests := []struct { + name string + slice []string + element string + expected bool + }{ + { + name: "element exists", + slice: []string{"foo", "bar", "baz"}, + element: "bar", + expected: true, + }, + { + name: "element does not exist", + slice: []string{"foo", "bar", "baz"}, + element: "qux", + expected: false, + }, + { + name: "empty slice", + slice: []string{}, + element: "foo", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := exists(tt.slice, tt.element) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetSlice(t *testing.T) { + tests := []struct { + name string + sliceName string + objs []runtime.Object + expectedError bool + expectedName string + }{ + { + name: "get existing slice", + sliceName: "test-slice", + objs: []runtime.Object{ + &kubeslicev1beta1.Slice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-slice", + Namespace: "kubeslice-system", + }, + }, + }, + expectedError: false, + expectedName: "test-slice", + }, + { + name: "slice not found", + sliceName: "non-existent", + objs: []runtime.Object{}, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + _ = kubeslicev1beta1.AddToScheme(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(tt.objs...).Build() + + result, err := GetSlice(context.Background(), client, tt.sliceName) + + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedName, result.Name) + } + }) + } +} + +func TestGetSliceGatewayList(t *testing.T) { + tests := []struct { + name string + sliceName string + objs []runtime.Object + expectedCount int + expectedError bool + }{ + { + name: "get slice gateways", + sliceName: "test-slice", + objs: []runtime.Object{ + &kubeslicev1beta1.SliceGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw1", + Namespace: "kubeslice-system", + Labels: map[string]string{ + ApplicationNamespaceSelectorLabelKey: "test-slice", + }, + }, + }, + &kubeslicev1beta1.SliceGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw2", + Namespace: "kubeslice-system", + Labels: map[string]string{ + ApplicationNamespaceSelectorLabelKey: "test-slice", + }, + }, + }, + }, + expectedCount: 2, + expectedError: false, + }, + { + name: "no gateways found", + sliceName: "test-slice", + objs: []runtime.Object{}, + expectedCount: 0, + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + _ = kubeslicev1beta1.AddToScheme(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(tt.objs...).Build() + + result, err := GetSliceGatewayList(context.Background(), client, tt.sliceName) + + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedCount, len(result.Items)) + } + }) + } +} + +func TestGetSliceGatewayServers(t *testing.T) { + tests := []struct { + name string + sliceName string + objs []runtime.Object + expectedCount int + }{ + { + name: "get server gateways", + sliceName: "test-slice", + objs: []runtime.Object{ + &kubeslicev1beta1.SliceGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw-server", + Namespace: "kubeslice-system", + Labels: map[string]string{ + ApplicationNamespaceSelectorLabelKey: "test-slice", + }, + }, + Status: kubeslicev1beta1.SliceGatewayStatus{ + Config: kubeslicev1beta1.SliceGatewayConfig{ + SliceGatewayHostType: "Server", + }, + }, + }, + &kubeslicev1beta1.SliceGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw-client", + Namespace: "kubeslice-system", + Labels: map[string]string{ + ApplicationNamespaceSelectorLabelKey: "test-slice", + }, + }, + Status: kubeslicev1beta1.SliceGatewayStatus{ + Config: kubeslicev1beta1.SliceGatewayConfig{ + SliceGatewayHostType: "Client", + }, + }, + }, + }, + expectedCount: 1, + }, + { + name: "no server gateways", + sliceName: "test-slice", + objs: []runtime.Object{}, + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + _ = kubeslicev1beta1.AddToScheme(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(tt.objs...).Build() + + result, err := GetSliceGatewayServers(context.Background(), client, tt.sliceName) + + assert.NoError(t, err) + assert.Equal(t, tt.expectedCount, len(result)) + }) + } +} + +func TestGetSliceGwServices(t *testing.T) { + tests := []struct { + name string + sliceName string + objs []runtime.Object + expectedCount int + }{ + { + name: "get slice gateway services", + sliceName: "test-slice", + objs: []runtime.Object{ + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "svc1", + Namespace: "kubeslice-system", + Labels: map[string]string{ + ApplicationNamespaceSelectorLabelKey: "test-slice", + }, + }, + }, + }, + expectedCount: 1, + }, + { + name: "no services found", + sliceName: "test-slice", + objs: []runtime.Object{}, + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(tt.objs...).Build() + + result, err := GetSliceGwServices(context.Background(), client, tt.sliceName) + + assert.NoError(t, err) + assert.Equal(t, tt.expectedCount, len(result.Items)) + }) + } +} + +func TestGetSliceRouterPodNameAndIP(t *testing.T) { + tests := []struct { + name string + sliceName string + objs []runtime.Object + expectedName string + expectedIP string + expectedError bool + }{ + { + name: "get running router pod", + sliceName: "test-slice", + objs: []runtime.Object{ + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vl3-router-pod", + Namespace: "kubeslice-system", + Labels: map[string]string{ + "networkservicemesh.io/impl": "vl3-service-test-slice", + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + PodIP: "10.0.0.1", + }, + }, + }, + expectedName: "vl3-router-pod", + expectedIP: "10.0.0.1", + expectedError: false, + }, + { + name: "pod not running", + sliceName: "test-slice", + objs: []runtime.Object{ + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vl3-router-pod", + Namespace: "kubeslice-system", + Labels: map[string]string{ + "networkservicemesh.io/impl": "vl3-service-test-slice", + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + }, + }, + }, + expectedName: "", + expectedIP: "", + expectedError: false, + }, + { + name: "no pod found", + sliceName: "test-slice", + objs: []runtime.Object{}, + expectedName: "", + expectedIP: "", + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(tt.objs...).Build() + + name, ip, err := GetSliceRouterPodNameAndIP(context.Background(), client, tt.sliceName) + + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedName, name) + assert.Equal(t, tt.expectedIP, ip) + } + }) + } +} + +func TestGetSliceGatewayEdgeServices(t *testing.T) { + tests := []struct { + name string + sliceName string + objs []runtime.Object + expectedCount int + }{ + { + name: "get edge services", + sliceName: "test-slice", + objs: []runtime.Object{ + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "edge-svc", + Namespace: "kubeslice-system", + Labels: map[string]string{ + SliceGatewaySelectorLabelKey: "test-slice", + SliceGatewayEdgeTypeLabelKey: "LoadBalancer", + }, + }, + }, + }, + expectedCount: 1, + }, + { + name: "no edge services found", + sliceName: "test-slice", + objs: []runtime.Object{}, + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(tt.objs...).Build() + + result, err := GetSliceGatewayEdgeServices(context.Background(), client, tt.sliceName) + + assert.NoError(t, err) + assert.Equal(t, tt.expectedCount, len(result.Items)) + }) + } +} + +func TestContructNetworkPolicyObject(t *testing.T) { + tests := []struct { + name string + slice *kubeslicev1beta1.Slice + appNs string + expectedPolicyName string + }{ + { + name: "construct network policy", + slice: &kubeslicev1beta1.Slice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-slice", + Namespace: "kubeslice-system", + }, + Status: kubeslicev1beta1.SliceStatus{ + SliceConfig: &kubeslicev1beta1.SliceConfig{ + NamespaceIsolationProfile: &kubeslicev1beta1.NamespaceIsolationProfile{ + AllowedNamespaces: []string{"allowed-ns"}, + }, + }, + }, + }, + appNs: "app-namespace", + expectedPolicyName: "test-slice-app-namespace", + }, + { + name: "construct network policy with empty allowed namespaces", + slice: &kubeslicev1beta1.Slice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "slice2", + Namespace: "kubeslice-system", + }, + Status: kubeslicev1beta1.SliceStatus{ + SliceConfig: &kubeslicev1beta1.SliceConfig{ + NamespaceIsolationProfile: &kubeslicev1beta1.NamespaceIsolationProfile{ + AllowedNamespaces: []string{}, + }, + }, + }, + }, + appNs: "test-ns", + expectedPolicyName: "slice2-test-ns", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ContructNetworkPolicyObject(context.Background(), tt.slice, tt.appNs) + + assert.NotNil(t, result) + assert.Equal(t, tt.expectedPolicyName, result.Name) + assert.Equal(t, tt.appNs, result.Namespace) + assert.NotNil(t, result.Spec.Ingress) + assert.NotNil(t, result.Spec.Egress) + }) + } +} + +func TestGetSliceIngressGwPod(t *testing.T) { + tests := []struct { + name string + slice *kubeslicev1beta1.Slice + expectedEnabled bool + expectedPod *kubeslicev1beta1.AppPod + expectedError bool + }{ + { + name: "ingress enabled with pod", + slice: &kubeslicev1beta1.Slice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-slice", + Namespace: "kubeslice-system", + }, + Status: kubeslicev1beta1.SliceStatus{ + SliceConfig: &kubeslicev1beta1.SliceConfig{ + ExternalGatewayConfig: &kubeslicev1beta1.ExternalGatewayConfig{ + Ingress: &kubeslicev1beta1.ExternalGatewayConfigOptions{ + Enabled: true, + }, + }, + }, + AppPods: []kubeslicev1beta1.AppPod{ + { + PodName: "test-ingressgateway-pod", + PodNamespace: "kubeslice-system", + NsmIP: "10.0.0.1", + }, + }, + }, + }, + expectedEnabled: true, + expectedPod: &kubeslicev1beta1.AppPod{ + PodName: "test-ingressgateway-pod", + PodNamespace: "kubeslice-system", + NsmIP: "10.0.0.1", + }, + expectedError: false, + }, + { + name: "ingress enabled without pod", + slice: &kubeslicev1beta1.Slice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-slice", + Namespace: "kubeslice-system", + }, + Status: kubeslicev1beta1.SliceStatus{ + SliceConfig: &kubeslicev1beta1.SliceConfig{ + ExternalGatewayConfig: &kubeslicev1beta1.ExternalGatewayConfig{ + Ingress: &kubeslicev1beta1.ExternalGatewayConfigOptions{ + Enabled: true, + }, + }, + }, + AppPods: []kubeslicev1beta1.AppPod{}, + }, + }, + expectedEnabled: true, + expectedPod: nil, + expectedError: false, + }, + { + name: "ingress disabled", + slice: &kubeslicev1beta1.Slice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-slice", + Namespace: "kubeslice-system", + }, + Status: kubeslicev1beta1.SliceStatus{ + SliceConfig: &kubeslicev1beta1.SliceConfig{ + ExternalGatewayConfig: &kubeslicev1beta1.ExternalGatewayConfig{ + Ingress: &kubeslicev1beta1.ExternalGatewayConfigOptions{ + Enabled: false, + }, + }, + }, + }, + }, + expectedEnabled: false, + expectedPod: nil, + expectedError: false, + }, + { + name: "no external gateway config", + slice: &kubeslicev1beta1.Slice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-slice", + Namespace: "kubeslice-system", + }, + Status: kubeslicev1beta1.SliceStatus{ + SliceConfig: &kubeslicev1beta1.SliceConfig{}, + }, + }, + expectedEnabled: false, + expectedPod: nil, + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + _ = kubeslicev1beta1.AddToScheme(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(tt.slice).Build() + + enabled, pod, err := GetSliceIngressGwPod(context.Background(), client, tt.slice.Name) + + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedEnabled, enabled) + if tt.expectedPod != nil { + assert.NotNil(t, pod) + assert.Equal(t, tt.expectedPod.PodName, pod.PodName) + } else { + assert.Nil(t, pod) + } + } + }) + } +} + +func TestGetSliceOverlayNetworkType(t *testing.T) { + tests := []struct { + name string + slice *kubeslicev1beta1.Slice + expected string + expectedError bool + }{ + { + name: "get network type", + slice: &kubeslicev1beta1.Slice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-slice", + Namespace: "kubeslice-system", + }, + Status: kubeslicev1beta1.SliceStatus{ + SliceConfig: &kubeslicev1beta1.SliceConfig{ + SliceOverlayNetworkDeploymentMode: "single-network", + }, + }, + }, + expected: "single-network", + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + _ = kubeslicev1beta1.AddToScheme(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(tt.slice).Build() + + result, err := GetSliceOverlayNetworkType(context.Background(), client, tt.slice.Name) + + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, string(result)) + } + }) + } +} diff --git a/controllers/serviceexport/utils_unit_test.go b/controllers/serviceexport/utils_unit_test.go new file mode 100644 index 000000000..838d4d255 --- /dev/null +++ b/controllers/serviceexport/utils_unit_test.go @@ -0,0 +1,308 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 serviceexport + +import ( + "testing" + + kubeslicev1beta1 "github.com/kubeslice/worker-operator/api/v1beta1" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" +) + +func TestPortListToDisplayString(t *testing.T) { + tests := []struct { + name string + servicePorts []kubeslicev1beta1.ServicePort + expected string + }{ + { + name: "Single TCP port", + servicePorts: []kubeslicev1beta1.ServicePort{ + { + ContainerPort: 8080, + Protocol: corev1.ProtocolTCP, + }, + }, + expected: "8080/TCP", + }, + { + name: "Multiple ports with protocols", + servicePorts: []kubeslicev1beta1.ServicePort{ + { + ContainerPort: 8080, + Protocol: corev1.ProtocolTCP, + }, + { + ContainerPort: 9090, + Protocol: corev1.ProtocolUDP, + }, + }, + expected: "8080/TCP,9090/UDP", + }, + { + name: "Port without protocol defaults to TCP", + servicePorts: []kubeslicev1beta1.ServicePort{ + { + ContainerPort: 3000, + }, + }, + expected: "3000/TCP", + }, + { + name: "Empty port list", + servicePorts: []kubeslicev1beta1.ServicePort{}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := portListToDisplayString(tt.servicePorts) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestContainsString(t *testing.T) { + tests := []struct { + name string + slice []string + element string + expected bool + }{ + { + name: "String exists", + slice: []string{"foo", "bar", "baz"}, + element: "bar", + expected: true, + }, + { + name: "String does not exist", + slice: []string{"foo", "bar", "baz"}, + element: "qux", + expected: false, + }, + { + name: "Empty slice", + slice: []string{}, + element: "foo", + expected: false, + }, + { + name: "Empty string search", + slice: []string{"foo", "", "bar"}, + element: "", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := containsString(tt.slice, tt.element) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetServiceProtocol(t *testing.T) { + tests := []struct { + name string + se *kubeslicev1beta1.ServiceExport + expected kubeslicev1beta1.ServiceProtocol + }{ + { + name: "HTTP port", + se: &kubeslicev1beta1.ServiceExport{ + Spec: kubeslicev1beta1.ServiceExportSpec{ + Ports: []kubeslicev1beta1.ServicePort{ + { + Name: "http", + ContainerPort: 8080, + }, + }, + }, + }, + expected: kubeslicev1beta1.ServiceProtocolHTTP, + }, + { + name: "HTTP2 port", + se: &kubeslicev1beta1.ServiceExport{ + Spec: kubeslicev1beta1.ServiceExportSpec{ + Ports: []kubeslicev1beta1.ServicePort{ + { + Name: "http2", + ContainerPort: 8080, + }, + }, + }, + }, + expected: kubeslicev1beta1.ServiceProtocolHTTP, + }, + { + name: "TCP port", + se: &kubeslicev1beta1.ServiceExport{ + Spec: kubeslicev1beta1.ServiceExportSpec{ + Ports: []kubeslicev1beta1.ServicePort{ + { + Name: "tcp", + ContainerPort: 3306, + }, + }, + }, + }, + expected: kubeslicev1beta1.ServiceProtocolTCP, + }, + { + name: "Multiple ports defaults to TCP", + se: &kubeslicev1beta1.ServiceExport{ + Spec: kubeslicev1beta1.ServiceExportSpec{ + Ports: []kubeslicev1beta1.ServicePort{ + { + Name: "http", + ContainerPort: 8080, + }, + { + Name: "grpc", + ContainerPort: 9090, + }, + }, + }, + }, + expected: kubeslicev1beta1.ServiceProtocolTCP, + }, + { + name: "No ports defaults to TCP", + se: &kubeslicev1beta1.ServiceExport{ + Spec: kubeslicev1beta1.ServiceExportSpec{ + Ports: []kubeslicev1beta1.ServicePort{}, + }, + }, + expected: kubeslicev1beta1.ServiceProtocolTCP, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getServiceProtocol(tt.se) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestArrayContainsString(t *testing.T) { + tests := []struct { + name string + slice []string + element string + expected bool + }{ + { + name: "String exists", + slice: []string{"alpha", "beta", "gamma"}, + element: "beta", + expected: true, + }, + { + name: "String does not exist", + slice: []string{"alpha", "beta", "gamma"}, + element: "delta", + expected: false, + }, + { + name: "Empty slice", + slice: []string{}, + element: "alpha", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := arrayContainsString(tt.slice, tt.element) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsServiceAppPodChanged(t *testing.T) { + tests := []struct { + name string + current []kubeslicev1beta1.ServicePod + old []kubeslicev1beta1.ServicePod + expected bool + }{ + { + name: "No change", + current: []kubeslicev1beta1.ServicePod{ + {Name: "pod1", NsmIP: "10.0.0.1", PodIp: "192.168.0.1"}, + {Name: "pod2", NsmIP: "10.0.0.2", PodIp: "192.168.0.2"}, + }, + old: []kubeslicev1beta1.ServicePod{ + {Name: "pod1", NsmIP: "10.0.0.1", PodIp: "192.168.0.1"}, + {Name: "pod2", NsmIP: "10.0.0.2", PodIp: "192.168.0.2"}, + }, + expected: false, + }, + { + name: "NSM IP changed", + current: []kubeslicev1beta1.ServicePod{ + {Name: "pod1", NsmIP: "10.0.0.10", PodIp: "192.168.0.1"}, + }, + old: []kubeslicev1beta1.ServicePod{ + {Name: "pod1", NsmIP: "10.0.0.1", PodIp: "192.168.0.1"}, + }, + expected: true, + }, + { + name: "Pod IP changed", + current: []kubeslicev1beta1.ServicePod{ + {Name: "pod1", NsmIP: "10.0.0.1", PodIp: "192.168.0.10"}, + }, + old: []kubeslicev1beta1.ServicePod{ + {Name: "pod1", NsmIP: "10.0.0.1", PodIp: "192.168.0.1"}, + }, + expected: true, + }, + { + name: "Different number of pods", + current: []kubeslicev1beta1.ServicePod{ + {Name: "pod1", NsmIP: "10.0.0.1", PodIp: "192.168.0.1"}, + {Name: "pod2", NsmIP: "10.0.0.2", PodIp: "192.168.0.2"}, + }, + old: []kubeslicev1beta1.ServicePod{ + {Name: "pod1", NsmIP: "10.0.0.1", PodIp: "192.168.0.1"}, + }, + expected: true, + }, + { + name: "Both empty", + current: []kubeslicev1beta1.ServicePod{}, + old: []kubeslicev1beta1.ServicePod{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isServiceAppPodChanged(tt.current, tt.old) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/controllers/serviceimport/istio_utils_unit_test.go b/controllers/serviceimport/istio_utils_unit_test.go new file mode 100644 index 000000000..dd2c83c26 --- /dev/null +++ b/controllers/serviceimport/istio_utils_unit_test.go @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 serviceimport + +import ( + "testing" + + kubeslicev1beta1 "github.com/kubeslice/worker-operator/api/v1beta1" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestVirtualServiceFromAppPodName(t *testing.T) { + tests := []struct { + name string + serviceimport *kubeslicev1beta1.ServiceImport + expected string + }{ + { + name: "Simple name", + serviceimport: &kubeslicev1beta1.ServiceImport{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + }, + }, + expected: "my-service", + }, + { + name: "Name with dashes", + serviceimport: &kubeslicev1beta1.ServiceImport{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-test-service", + }, + }, + expected: "my-test-service", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := virtualServiceFromAppPodName(tt.serviceimport) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestVirtualServiceFromEgressName(t *testing.T) { + tests := []struct { + name string + serviceimport *kubeslicev1beta1.ServiceImport + expected string + }{ + { + name: "Simple name and namespace", + serviceimport: &kubeslicev1beta1.ServiceImport{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + Namespace: "my-namespace", + }, + }, + expected: "my-service-my-namespace", + }, + { + name: "Name with dashes", + serviceimport: &kubeslicev1beta1.ServiceImport{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-test-service", + Namespace: "test-ns", + }, + }, + expected: "my-test-service-test-ns", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := virtualServiceFromEgressName(tt.serviceimport) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCalculateInitialWeight(t *testing.T) { + tests := []struct { + name string + index int + serviceImport *kubeslicev1beta1.ServiceImport + expected int32 + }{ + { + name: "Equal distribution - 3 endpoints index 0", + index: 0, + serviceImport: &kubeslicev1beta1.ServiceImport{ + Status: kubeslicev1beta1.ServiceImportStatus{ + Endpoints: []kubeslicev1beta1.ServiceEndpoint{ + {DNSName: "endpoint1"}, + {DNSName: "endpoint2"}, + {DNSName: "endpoint3"}, + }, + }, + }, + expected: 34, + }, + { + name: "Equal distribution - 3 endpoints index 1", + index: 1, + serviceImport: &kubeslicev1beta1.ServiceImport{ + Status: kubeslicev1beta1.ServiceImportStatus{ + Endpoints: []kubeslicev1beta1.ServiceEndpoint{ + {DNSName: "endpoint1"}, + {DNSName: "endpoint2"}, + {DNSName: "endpoint3"}, + }, + }, + }, + expected: 33, + }, + { + name: "Equal distribution - 3 endpoints index 2", + index: 2, + serviceImport: &kubeslicev1beta1.ServiceImport{ + Status: kubeslicev1beta1.ServiceImportStatus{ + Endpoints: []kubeslicev1beta1.ServiceEndpoint{ + {DNSName: "endpoint1"}, + {DNSName: "endpoint2"}, + {DNSName: "endpoint3"}, + }, + }, + }, + expected: 33, + }, + { + name: "Two endpoints - index 0", + index: 0, + serviceImport: &kubeslicev1beta1.ServiceImport{ + Status: kubeslicev1beta1.ServiceImportStatus{ + Endpoints: []kubeslicev1beta1.ServiceEndpoint{ + {DNSName: "endpoint1"}, + {DNSName: "endpoint2"}, + }, + }, + }, + expected: 50, + }, + { + name: "Two endpoints - index 1", + index: 1, + serviceImport: &kubeslicev1beta1.ServiceImport{ + Status: kubeslicev1beta1.ServiceImportStatus{ + Endpoints: []kubeslicev1beta1.ServiceEndpoint{ + {DNSName: "endpoint1"}, + {DNSName: "endpoint2"}, + }, + }, + }, + expected: 50, + }, + { + name: "Single endpoint", + index: 0, + serviceImport: &kubeslicev1beta1.ServiceImport{ + Status: kubeslicev1beta1.ServiceImportStatus{ + Endpoints: []kubeslicev1beta1.ServiceEndpoint{ + {DNSName: "endpoint1"}, + }, + }, + }, + expected: 100, + }, + { + name: "Four endpoints - index 0", + index: 0, + serviceImport: &kubeslicev1beta1.ServiceImport{ + Status: kubeslicev1beta1.ServiceImportStatus{ + Endpoints: []kubeslicev1beta1.ServiceEndpoint{ + {DNSName: "endpoint1"}, + {DNSName: "endpoint2"}, + {DNSName: "endpoint3"}, + {DNSName: "endpoint4"}, + }, + }, + }, + expected: 25, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := calculateInitialWeight(tt.index, tt.serviceImport) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/controllers/serviceimport/utils_unit_test.go b/controllers/serviceimport/utils_unit_test.go new file mode 100644 index 000000000..82614f5bb --- /dev/null +++ b/controllers/serviceimport/utils_unit_test.go @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 serviceimport + +import ( + "testing" + + kubeslicev1beta1 "github.com/kubeslice/worker-operator/api/v1beta1" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" +) + +func TestPortListToDisplayString(t *testing.T) { + tests := []struct { + name string + servicePorts []kubeslicev1beta1.ServicePort + expected string + }{ + { + name: "Single TCP port", + servicePorts: []kubeslicev1beta1.ServicePort{ + { + ContainerPort: 8080, + Protocol: corev1.ProtocolTCP, + }, + }, + expected: "8080/TCP", + }, + { + name: "Multiple ports with different protocols", + servicePorts: []kubeslicev1beta1.ServicePort{ + { + ContainerPort: 8080, + Protocol: corev1.ProtocolTCP, + }, + { + ContainerPort: 9090, + Protocol: corev1.ProtocolUDP, + }, + }, + expected: "8080/TCP,9090/UDP", + }, + { + name: "Port without protocol defaults to TCP", + servicePorts: []kubeslicev1beta1.ServicePort{ + { + ContainerPort: 3000, + }, + }, + expected: "3000/TCP", + }, + { + name: "Empty port list", + servicePorts: []kubeslicev1beta1.ServicePort{}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := portListToDisplayString(tt.servicePorts) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetServiceProtocol(t *testing.T) { + tests := []struct { + name string + si *kubeslicev1beta1.ServiceImport + expected kubeslicev1beta1.ServiceProtocol + }{ + { + name: "HTTP port", + si: &kubeslicev1beta1.ServiceImport{ + Spec: kubeslicev1beta1.ServiceImportSpec{ + Ports: []kubeslicev1beta1.ServicePort{ + { + Name: "http", + ContainerPort: 8080, + }, + }, + }, + }, + expected: kubeslicev1beta1.ServiceProtocolHTTP, + }, + { + name: "HTTP2 port", + si: &kubeslicev1beta1.ServiceImport{ + Spec: kubeslicev1beta1.ServiceImportSpec{ + Ports: []kubeslicev1beta1.ServicePort{ + { + Name: "http2", + ContainerPort: 8080, + }, + }, + }, + }, + expected: kubeslicev1beta1.ServiceProtocolHTTP, + }, + { + name: "HTTPS port", + si: &kubeslicev1beta1.ServiceImport{ + Spec: kubeslicev1beta1.ServiceImportSpec{ + Ports: []kubeslicev1beta1.ServicePort{ + { + Name: "https", + ContainerPort: 443, + }, + }, + }, + }, + expected: kubeslicev1beta1.ServiceProtocolHTTP, + }, + { + name: "TCP port", + si: &kubeslicev1beta1.ServiceImport{ + Spec: kubeslicev1beta1.ServiceImportSpec{ + Ports: []kubeslicev1beta1.ServicePort{ + { + Name: "tcp", + ContainerPort: 3306, + }, + }, + }, + }, + expected: kubeslicev1beta1.ServiceProtocolTCP, + }, + { + name: "Multiple ports defaults to TCP", + si: &kubeslicev1beta1.ServiceImport{ + Spec: kubeslicev1beta1.ServiceImportSpec{ + Ports: []kubeslicev1beta1.ServicePort{ + { + Name: "http", + ContainerPort: 8080, + }, + { + Name: "grpc", + ContainerPort: 9090, + }, + }, + }, + }, + expected: kubeslicev1beta1.ServiceProtocolTCP, + }, + { + name: "No ports defaults to TCP", + si: &kubeslicev1beta1.ServiceImport{ + Spec: kubeslicev1beta1.ServiceImportSpec{ + Ports: []kubeslicev1beta1.ServicePort{}, + }, + }, + expected: kubeslicev1beta1.ServiceProtocolTCP, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getServiceProtocol(tt.si) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestContainsString(t *testing.T) { + tests := []struct { + name string + slice []string + element string + expected bool + }{ + { + name: "String exists", + slice: []string{"one", "two", "three"}, + element: "two", + expected: true, + }, + { + name: "String does not exist", + slice: []string{"one", "two", "three"}, + element: "four", + expected: false, + }, + { + name: "Empty slice", + slice: []string{}, + element: "one", + expected: false, + }, + { + name: "Empty string in slice", + slice: []string{"", "a", "b"}, + element: "", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := containsString(tt.slice, tt.element) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/controllers/slice/utils_unit_test.go b/controllers/slice/utils_unit_test.go new file mode 100644 index 000000000..bc4847dd2 --- /dev/null +++ b/controllers/slice/utils_unit_test.go @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 slice + +import ( + "testing" + + kubeslicev1beta1 "github.com/kubeslice/worker-operator/api/v1beta1" + "github.com/stretchr/testify/assert" +) + +func TestIndexOf(t *testing.T) { + tests := []struct { + name string + slice []string + element string + expected int + }{ + { + name: "Element found at index 0", + slice: []string{"a", "b", "c"}, + element: "a", + expected: 0, + }, + { + name: "Element found at index 2", + slice: []string{"a", "b", "c"}, + element: "c", + expected: 2, + }, + { + name: "Element not found", + slice: []string{"a", "b", "c"}, + element: "d", + expected: -1, + }, + { + name: "Empty slice", + slice: []string{}, + element: "a", + expected: -1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := indexOf(tt.slice, tt.element) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExists(t *testing.T) { + tests := []struct { + name string + slice []string + element string + expected bool + }{ + { + name: "Element exists", + slice: []string{"apple", "banana", "cherry"}, + element: "banana", + expected: true, + }, + { + name: "Element does not exist", + slice: []string{"apple", "banana", "cherry"}, + element: "grape", + expected: false, + }, + { + name: "Empty slice", + slice: []string{}, + element: "apple", + expected: false, + }, + { + name: "Empty string in slice", + slice: []string{"", "a", "b"}, + element: "", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := exists(tt.slice, tt.element) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestBuildAppNamespacesList(t *testing.T) { + tests := []struct { + name string + slice *kubeslicev1beta1.Slice + expected []string + }{ + { + name: "Multiple namespaces excluding control plane", + slice: &kubeslicev1beta1.Slice{ + Status: kubeslicev1beta1.SliceStatus{ + SliceConfig: &kubeslicev1beta1.SliceConfig{ + NamespaceIsolationProfile: &kubeslicev1beta1.NamespaceIsolationProfile{ + ApplicationNamespaces: []string{"ns1", "ns2", ControlPlaneNamespace, "ns3"}, + }, + }, + }, + }, + expected: []string{"ns1", "ns2", "ns3"}, + }, + { + name: "No control plane namespace in list", + slice: &kubeslicev1beta1.Slice{ + Status: kubeslicev1beta1.SliceStatus{ + SliceConfig: &kubeslicev1beta1.SliceConfig{ + NamespaceIsolationProfile: &kubeslicev1beta1.NamespaceIsolationProfile{ + ApplicationNamespaces: []string{"ns1", "ns2"}, + }, + }, + }, + }, + expected: []string{"ns1", "ns2"}, + }, + { + name: "Empty namespaces list", + slice: &kubeslicev1beta1.Slice{ + Status: kubeslicev1beta1.SliceStatus{ + SliceConfig: &kubeslicev1beta1.SliceConfig{ + NamespaceIsolationProfile: &kubeslicev1beta1.NamespaceIsolationProfile{ + ApplicationNamespaces: []string{}, + }, + }, + }, + }, + expected: nil, + }, + { + name: "Only control plane namespace", + slice: &kubeslicev1beta1.Slice{ + Status: kubeslicev1beta1.SliceStatus{ + SliceConfig: &kubeslicev1beta1.SliceConfig{ + NamespaceIsolationProfile: &kubeslicev1beta1.NamespaceIsolationProfile{ + ApplicationNamespaces: []string{ControlPlaneNamespace}, + }, + }, + }, + }, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildAppNamespacesList(tt.slice) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestMergeMaps(t *testing.T) { + tests := []struct { + name string + baseMap map[string]string + overrideMap map[string]string + expected map[string]string + }{ + { + name: "Both maps have values", + baseMap: map[string]string{"a": "1", "b": "2"}, + overrideMap: map[string]string{"b": "3", "c": "4"}, + expected: map[string]string{"a": "1", "b": "3", "c": "4"}, + }, + { + name: "Empty base map", + baseMap: map[string]string{}, + overrideMap: map[string]string{"a": "1"}, + expected: map[string]string{"a": "1"}, + }, + { + name: "Empty override map", + baseMap: map[string]string{"a": "1"}, + overrideMap: map[string]string{}, + expected: map[string]string{"a": "1"}, + }, + { + name: "Both maps empty", + baseMap: map[string]string{}, + overrideMap: map[string]string{}, + expected: map[string]string{}, + }, + { + name: "Override replaces base values", + baseMap: map[string]string{"key1": "value1", "key2": "value2"}, + overrideMap: map[string]string{"key1": "newValue1"}, + expected: map[string]string{"key1": "newValue1", "key2": "value2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := mergeMaps(tt.baseMap, tt.overrideMap) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestRemoveEntries(t *testing.T) { + tests := []struct { + name string + map1 map[string]string + map2 map[string]string + expected map[string]string + }{ + { + name: "Remove matching entries", + map1: map[string]string{"a": "1", "b": "2", "c": "3"}, + map2: map[string]string{"a": "1", "c": "3"}, + expected: map[string]string{"b": "2"}, + }, + { + name: "No matching entries", + map1: map[string]string{"a": "1", "b": "2"}, + map2: map[string]string{"c": "3", "d": "4"}, + expected: map[string]string{"a": "1", "b": "2"}, + }, + { + name: "Key exists but value different", + map1: map[string]string{"a": "1", "b": "2"}, + map2: map[string]string{"a": "2"}, + expected: map[string]string{"a": "1", "b": "2"}, + }, + { + name: "Empty map2", + map1: map[string]string{"a": "1", "b": "2"}, + map2: map[string]string{}, + expected: map[string]string{"a": "1", "b": "2"}, + }, + { + name: "Remove all entries", + map1: map[string]string{"a": "1", "b": "2"}, + map2: map[string]string{"a": "1", "b": "2"}, + expected: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + removeEntries(tt.map1, tt.map2) + assert.Equal(t, tt.expected, tt.map1) + }) + } +} diff --git a/controllers/slicegateway/utils_unit_test.go b/controllers/slicegateway/utils_unit_test.go new file mode 100644 index 000000000..32c492cf9 --- /dev/null +++ b/controllers/slicegateway/utils_unit_test.go @@ -0,0 +1,578 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 slicegateway + +import ( + "testing" + + gwsidecarpb "github.com/kubeslice/gateway-sidecar/pkg/sidecar/sidecarpb" + kubeslicev1beta1 "github.com/kubeslice/worker-operator/api/v1beta1" + webhook "github.com/kubeslice/worker-operator/pkg/webhook/pod" + "github.com/stretchr/testify/assert" +) + +func TestIsClient(t *testing.T) { + tests := []struct { + name string + sliceGw *kubeslicev1beta1.SliceGateway + expected bool + }{ + { + name: "Client gateway", + sliceGw: &kubeslicev1beta1.SliceGateway{ + Status: kubeslicev1beta1.SliceGatewayStatus{ + Config: kubeslicev1beta1.SliceGatewayConfig{ + SliceGatewayHostType: "Client", + }, + }, + }, + expected: true, + }, + { + name: "Server gateway", + sliceGw: &kubeslicev1beta1.SliceGateway{ + Status: kubeslicev1beta1.SliceGatewayStatus{ + Config: kubeslicev1beta1.SliceGatewayConfig{ + SliceGatewayHostType: "Server", + }, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isClient(tt.sliceGw) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsServer(t *testing.T) { + tests := []struct { + name string + sliceGw *kubeslicev1beta1.SliceGateway + expected bool + }{ + { + name: "Server gateway", + sliceGw: &kubeslicev1beta1.SliceGateway{ + Status: kubeslicev1beta1.SliceGatewayStatus{ + Config: kubeslicev1beta1.SliceGatewayConfig{ + SliceGatewayHostType: "Server", + }, + }, + }, + expected: true, + }, + { + name: "Client gateway", + sliceGw: &kubeslicev1beta1.SliceGateway{ + Status: kubeslicev1beta1.SliceGatewayStatus{ + Config: kubeslicev1beta1.SliceGatewayConfig{ + SliceGatewayHostType: "Client", + }, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isServer(tt.sliceGw) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetPodType(t *testing.T) { + tests := []struct { + name string + labels map[string]string + expected string + }{ + { + name: "Pod with inject label", + labels: map[string]string{ + webhook.PodInjectLabelKey: "slicegateway", + }, + expected: "slicegateway", + }, + { + name: "NSM nsmgr-daemonset", + labels: map[string]string{ + "app": "nsmgr-daemonset", + }, + expected: "nsm", + }, + { + name: "NSM kernel-plane", + labels: map[string]string{ + "app": "nsm-kernel-plane", + }, + expected: "nsm", + }, + { + name: "No matching labels", + labels: map[string]string{"other": "value"}, + expected: "", + }, + { + name: "Empty labels", + labels: map[string]string{}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getPodType(tt.labels) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetGwSvcNameFromDepName(t *testing.T) { + tests := []struct { + name string + depName string + expected string + }{ + { + name: "Simple deployment name", + depName: "my-gateway", + expected: "svc-my-gateway", + }, + { + name: "Empty deployment name", + depName: "", + expected: "svc-", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getGwSvcNameFromDepName(tt.depName) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestContains(t *testing.T) { + tests := []struct { + name string + slice []string + element string + expected bool + }{ + { + name: "Element exists", + slice: []string{"a", "b", "c"}, + element: "b", + expected: true, + }, + { + name: "Element does not exist", + slice: []string{"a", "b", "c"}, + element: "d", + expected: false, + }, + { + name: "Empty slice", + slice: []string{}, + element: "a", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := contains(tt.slice, tt.element) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestContainsWithIndex(t *testing.T) { + tests := []struct { + name string + slice []int + element int + expectedFound bool + expectedIndex int + }{ + { + name: "Element exists at index 1", + slice: []int{10, 20, 30}, + element: 20, + expectedFound: true, + expectedIndex: 1, + }, + { + name: "Element does not exist", + slice: []int{10, 20, 30}, + element: 40, + expectedFound: false, + expectedIndex: 0, + }, + { + name: "Empty slice", + slice: []int{}, + element: 10, + expectedFound: false, + expectedIndex: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + found, index := containsWithIndex(tt.slice, tt.element) + assert.Equal(t, tt.expectedFound, found) + assert.Equal(t, tt.expectedIndex, index) + }) + } +} + +func TestGetPodIPs(t *testing.T) { + tests := []struct { + name string + sliceGw *kubeslicev1beta1.SliceGateway + expected []string + }{ + { + name: "Multiple pods with IPs", + sliceGw: &kubeslicev1beta1.SliceGateway{ + Status: kubeslicev1beta1.SliceGatewayStatus{ + GatewayPodStatus: []*kubeslicev1beta1.GwPodInfo{ + {PodIP: "10.0.0.1"}, + {PodIP: "10.0.0.2"}, + }, + }, + }, + expected: []string{"10.0.0.1", "10.0.0.2"}, + }, + { + name: "No pods", + sliceGw: &kubeslicev1beta1.SliceGateway{ + Status: kubeslicev1beta1.SliceGatewayStatus{ + GatewayPodStatus: []*kubeslicev1beta1.GwPodInfo{}, + }, + }, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getPodIPs(tt.sliceGw) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetPodNames(t *testing.T) { + tests := []struct { + name string + sliceGw *kubeslicev1beta1.SliceGateway + expected []string + }{ + { + name: "Multiple pods with names", + sliceGw: &kubeslicev1beta1.SliceGateway{ + Status: kubeslicev1beta1.SliceGatewayStatus{ + GatewayPodStatus: []*kubeslicev1beta1.GwPodInfo{ + {PodName: "pod-1"}, + {PodName: "pod-2"}, + }, + }, + }, + expected: []string{"pod-1", "pod-2"}, + }, + { + name: "No pods", + sliceGw: &kubeslicev1beta1.SliceGateway{ + Status: kubeslicev1beta1.SliceGatewayStatus{ + GatewayPodStatus: []*kubeslicev1beta1.GwPodInfo{}, + }, + }, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getPodNames(tt.sliceGw) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetDepNameFromPodName(t *testing.T) { + tests := []struct { + name string + sliceGwID string + podName string + expected string + }{ + { + name: "Valid pod name", + sliceGwID: "slice-gw", + podName: "slice-gw-0-1-abc123", + expected: "slice-gw-0-1", + }, + { + name: "Empty slice gateway ID", + sliceGwID: "", + podName: "slice-gw-0-1-abc123", + expected: "", + }, + { + name: "Empty pod name", + sliceGwID: "slice-gw", + podName: "", + expected: "", + }, + { + name: "Pod name without prefix", + sliceGwID: "slice-gw", + podName: "other-pod-name", + expected: "", + }, + { + name: "Pod name with insufficient parts", + sliceGwID: "slice-gw", + podName: "slice-gw-0", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetDepNameFromPodName(tt.sliceGwID, tt.podName) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFindGwPodInfo(t *testing.T) { + tests := []struct { + name string + gwPodStatus []*kubeslicev1beta1.GwPodInfo + podName string + expectedName string + expectNil bool + }{ + { + name: "Pod found", + gwPodStatus: []*kubeslicev1beta1.GwPodInfo{ + {PodName: "pod-1"}, + {PodName: "pod-2"}, + }, + podName: "pod-2", + expectedName: "pod-2", + expectNil: false, + }, + { + name: "Pod not found", + gwPodStatus: []*kubeslicev1beta1.GwPodInfo{ + {PodName: "pod-1"}, + }, + podName: "pod-3", + expectNil: true, + }, + { + name: "Empty pod status", + gwPodStatus: []*kubeslicev1beta1.GwPodInfo{}, + podName: "pod-1", + expectNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := findGwPodInfo(tt.gwPodStatus, tt.podName) + if tt.expectNil { + assert.Nil(t, result) + } else { + assert.NotNil(t, result) + assert.Equal(t, tt.expectedName, result.PodName) + } + }) + } +} + +func TestGetPeerGwPodName(t *testing.T) { + tests := []struct { + name string + gwPodName string + sliceGw *kubeslicev1beta1.SliceGateway + expected string + expectError bool + }{ + { + name: "Valid peer pod with tunnel up", + gwPodName: "pod-1", + sliceGw: &kubeslicev1beta1.SliceGateway{ + Status: kubeslicev1beta1.SliceGatewayStatus{ + GatewayPodStatus: []*kubeslicev1beta1.GwPodInfo{ + { + PodName: "pod-1", + PeerPodName: "peer-pod-1", + TunnelStatus: kubeslicev1beta1.TunnelStatus{ + Status: int32(gwsidecarpb.TunnelStatusType_GW_TUNNEL_STATE_UP), + }, + }, + }, + }, + }, + expected: "peer-pod-1", + expectError: false, + }, + { + name: "Pod not found", + gwPodName: "pod-2", + sliceGw: &kubeslicev1beta1.SliceGateway{ + Status: kubeslicev1beta1.SliceGatewayStatus{ + GatewayPodStatus: []*kubeslicev1beta1.GwPodInfo{ + {PodName: "pod-1"}, + }, + }, + }, + expected: "", + expectError: true, + }, + { + name: "Tunnel down", + gwPodName: "pod-1", + sliceGw: &kubeslicev1beta1.SliceGateway{ + Status: kubeslicev1beta1.SliceGatewayStatus{ + GatewayPodStatus: []*kubeslicev1beta1.GwPodInfo{ + { + PodName: "pod-1", + TunnelStatus: kubeslicev1beta1.TunnelStatus{ + Status: int32(gwsidecarpb.TunnelStatusType_GW_TUNNEL_STATE_DOWN), + }, + }, + }, + }, + }, + expected: "", + expectError: true, + }, + { + name: "Peer pod name empty", + gwPodName: "pod-1", + sliceGw: &kubeslicev1beta1.SliceGateway{ + Status: kubeslicev1beta1.SliceGatewayStatus{ + GatewayPodStatus: []*kubeslicev1beta1.GwPodInfo{ + { + PodName: "pod-1", + PeerPodName: "", + TunnelStatus: kubeslicev1beta1.TunnelStatus{ + Status: int32(gwsidecarpb.TunnelStatusType_GW_TUNNEL_STATE_UP), + }, + }, + }, + }, + }, + expected: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := GetPeerGwPodName(tt.gwPodName, tt.sliceGw) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +func TestGetRemoteDepName(t *testing.T) { + tests := []struct { + name string + remoteGwID string + localDepName string + expected string + }{ + { + name: "Valid deployment name", + remoteGwID: "remote-gw", + localDepName: "local-gw-0-1", + expected: "remote-gw-0-1", + }, + { + name: "Another valid case", + remoteGwID: "remote", + localDepName: "local-5-10", + expected: "remote-5-10", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetRemoteDepName(tt.remoteGwID, tt.localDepName) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetLocalNSMIPs(t *testing.T) { + tests := []struct { + name string + sliceGw *kubeslicev1beta1.SliceGateway + expected []string + }{ + { + name: "Multiple pods with NSM IPs", + sliceGw: &kubeslicev1beta1.SliceGateway{ + Status: kubeslicev1beta1.SliceGatewayStatus{ + GatewayPodStatus: []*kubeslicev1beta1.GwPodInfo{ + {LocalNsmIP: "192.168.0.1"}, + {LocalNsmIP: "192.168.0.2"}, + }, + }, + }, + expected: []string{"192.168.0.1", "192.168.0.2"}, + }, + { + name: "No pods", + sliceGw: &kubeslicev1beta1.SliceGateway{ + Status: kubeslicev1beta1.SliceGatewayStatus{ + GatewayPodStatus: []*kubeslicev1beta1.GwPodInfo{}, + }, + }, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getLocalNSMIPs(tt.sliceGw) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/cluster/cluster_unit_test.go b/pkg/cluster/cluster_unit_test.go new file mode 100644 index 000000000..ae0232105 --- /dev/null +++ b/pkg/cluster/cluster_unit_test.go @@ -0,0 +1,389 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 cluster + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestNewCluster(t *testing.T) { + tests := []struct { + name string + clusterName string + }{ + { + name: "create new cluster", + clusterName: "test-cluster", + }, + { + name: "create cluster with different name", + clusterName: "another-cluster", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := fake.NewClientBuilder().Build() + result := NewCluster(client, tt.clusterName) + + assert.NotNil(t, result) + cluster, ok := result.(*Cluster) + assert.True(t, ok) + assert.Equal(t, tt.clusterName, cluster.Name) + }) + } +} + +func TestGetClusterLocation(t *testing.T) { + tests := []struct { + name string + nodes []runtime.Object + expectedProvider string + expectedRegion string + expectedError bool + }{ + { + name: "get GCP cluster location", + nodes: []runtime.Object{ + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + Labels: map[string]string{ + "topology.kubernetes.io/region": "us-west1", + }, + }, + Spec: corev1.NodeSpec{ + ProviderID: "gce://project/us-west1-b/instance", + }, + }, + }, + expectedProvider: "gcp", + expectedRegion: "us-west1", + expectedError: false, + }, + { + name: "get AWS cluster location", + nodes: []runtime.Object{ + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + Labels: map[string]string{ + "topology.kubernetes.io/region": "us-east-1", + }, + }, + Spec: corev1.NodeSpec{ + ProviderID: "aws:///us-east-1a/i-1234567890", + }, + }, + }, + expectedProvider: "aws", + expectedRegion: "us-east-1", + expectedError: false, + }, + { + name: "get Azure cluster location", + nodes: []runtime.Object{ + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + Labels: map[string]string{ + "topology.kubernetes.io/region": "eastus", + }, + }, + Spec: corev1.NodeSpec{ + ProviderID: "azure:///subscriptions/sub-id/resourceGroups/rg/providers/Microsoft.Compute/virtualMachineScaleSets/vmss/virtualMachines/0", + }, + }, + }, + expectedProvider: "azure", + expectedRegion: "eastus", + expectedError: false, + }, + { + name: "empty provider when no providerID", + nodes: []runtime.Object{ + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + Labels: map[string]string{ + "topology.kubernetes.io/region": "us-west1", + }, + }, + Spec: corev1.NodeSpec{ + ProviderID: "", + }, + }, + }, + expectedProvider: "", + expectedRegion: "us-west1", + expectedError: false, + }, + { + name: "error when no nodes", + nodes: []runtime.Object{}, + expectedProvider: "", + expectedRegion: "", + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := fake.NewClientBuilder().WithRuntimeObjects(tt.nodes...).Build() + c := &Cluster{ + Client: client, + Name: "test-cluster", + } + + result, err := c.getClusterLocation(context.Background()) + + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedProvider, result.CloudProvider) + assert.Equal(t, tt.expectedRegion, result.CloudRegion) + } + }) + } +} + +func TestGetClusterInfo(t *testing.T) { + tests := []struct { + name string + clusterName string + nodes []runtime.Object + expectedError bool + }{ + { + name: "get cluster info successfully", + clusterName: "test-cluster", + nodes: []runtime.Object{ + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + Labels: map[string]string{ + "topology.kubernetes.io/region": "us-west1", + }, + }, + Spec: corev1.NodeSpec{ + ProviderID: "gce://project/us-west1-b/instance", + }, + }, + }, + expectedError: false, + }, + { + name: "error getting cluster info", + clusterName: "test-cluster", + nodes: []runtime.Object{}, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := fake.NewClientBuilder().WithRuntimeObjects(tt.nodes...).Build() + c := &Cluster{ + Client: client, + Name: tt.clusterName, + } + + result, err := c.GetClusterInfo(context.Background()) + + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, tt.clusterName, result.Name) + } + }) + } +} + +func TestGetNsmExcludedPrefixErrors(t *testing.T) { + tests := []struct { + name string + configMap *corev1.ConfigMap + expectedError string + }{ + { + name: "empty data in configmap", + configMap: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nsm-config", + Namespace: "demo", + }, + Data: map[string]string{}, + }, + expectedError: "prefix data not present in nsm configmap", + }, + { + name: "missing excluded_prefixes_output.yaml key", + configMap: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nsm-config", + Namespace: "demo", + }, + Data: map[string]string{ + "other-key": "some-value", + }, + }, + expectedError: "cni subnet info not present in nsm configmap", + }, + { + name: "invalid yaml in configmap", + configMap: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nsm-config", + Namespace: "demo", + }, + Data: map[string]string{ + "excluded_prefixes_output.yaml": "invalid: [yaml: content", + }, + }, + expectedError: "failed to get prefixes from nsm configmap", + }, + { + name: "no Prefixes key in yaml", + configMap: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nsm-config", + Namespace: "demo", + }, + Data: map[string]string{ + "excluded_prefixes_output.yaml": "OtherKey: value", + }, + }, + expectedError: "failed to get prefixes from nsm configmap", + }, + { + name: "configmap not found", + configMap: nil, + expectedError: "error getting nsm configmap", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var objs []runtime.Object + if tt.configMap != nil { + objs = append(objs, tt.configMap) + } + + client := fake.NewClientBuilder().WithRuntimeObjects(objs...).Build() + c := &Cluster{ + Client: client, + Name: "test-cluster", + } + + result, err := c.GetNsmExcludedPrefix(context.Background(), "nsm-config", "demo") + + assert.Error(t, err) + assert.Nil(t, result) + // Check that error is present, without requiring exact message match + if tt.name == "configmap not found" { + assert.Contains(t, err.Error(), "not found") + } else { + assert.Contains(t, err.Error(), tt.expectedError) + } + }) + } +} + +func TestGetPrefixes(t *testing.T) { + tests := []struct { + name string + configMap corev1.ConfigMap + expected []string + expectedError bool + }{ + { + name: "valid prefixes", + configMap: corev1.ConfigMap{ + Data: map[string]string{ + "excluded_prefixes_output.yaml": ` +Prefixes: +- 192.168.0.0/16 +- 10.96.0.0/12 +`, + }, + }, + expected: []string{"192.168.0.0/16", "10.96.0.0/12"}, + expectedError: false, + }, + { + name: "single prefix", + configMap: corev1.ConfigMap{ + Data: map[string]string{ + "excluded_prefixes_output.yaml": ` +Prefixes: +- 192.168.0.0/16 +`, + }, + }, + expected: []string{"192.168.0.0/16"}, + expectedError: false, + }, + { + name: "no Prefixes key", + configMap: corev1.ConfigMap{ + Data: map[string]string{ + "excluded_prefixes_output.yaml": ` +OtherKey: +- value +`, + }, + }, + expected: nil, + expectedError: true, + }, + { + name: "invalid yaml", + configMap: corev1.ConfigMap{ + Data: map[string]string{ + "excluded_prefixes_output.yaml": "invalid yaml content [[[", + }, + }, + expected: nil, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := getPrefixes(tt.configMap) + + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} diff --git a/pkg/cluster/node_unit_test.go b/pkg/cluster/node_unit_test.go new file mode 100644 index 000000000..eb6ca8181 --- /dev/null +++ b/pkg/cluster/node_unit_test.go @@ -0,0 +1,329 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 cluster + +import ( + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestGetNodeIP(t *testing.T) { + tests := []struct { + name string + isNetworkPresent bool + nodes []runtime.Object + expectedIPs []string + expectedError bool + expectedEmptyList bool + }{ + { + name: "get external IPs with network present", + isNetworkPresent: true, + nodes: []runtime.Object{ + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + Labels: map[string]string{ + "kubeslice.io/node-type": "gateway", + }, + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "1.2.3.4"}, + {Type: corev1.NodeInternalIP, Address: "10.0.0.1"}, + }, + }, + }, + }, + expectedIPs: []string{"1.2.3.4"}, + expectedError: false, + }, + { + name: "fallback to internal IPs when external IPs not available", + isNetworkPresent: true, + nodes: []runtime.Object{ + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + Labels: map[string]string{ + "kubeslice.io/node-type": "gateway", + }, + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeInternalIP, Address: "10.0.0.1"}, + }, + }, + }, + }, + expectedIPs: []string{"10.0.0.1"}, + expectedError: false, + }, + { + name: "multiple gateway nodes", + isNetworkPresent: true, + nodes: []runtime.Object{ + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + Labels: map[string]string{ + "kubeslice.io/node-type": "gateway", + }, + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "1.2.3.4"}, + }, + }, + }, + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node2", + Labels: map[string]string{ + "kubeslice.io/node-type": "gateway", + }, + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "5.6.7.8"}, + }, + }, + }, + }, + expectedIPs: []string{"1.2.3.4", "5.6.7.8"}, + expectedError: false, + }, + { + name: "no network present - return all nodes", + isNetworkPresent: false, + nodes: []runtime.Object{ + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "1.2.3.4"}, + }, + }, + }, + }, + expectedIPs: []string{"1.2.3.4"}, + expectedError: false, + }, + { + name: "no nodes available", + isNetworkPresent: true, + nodes: []runtime.Object{}, + expectedIPs: []string{""}, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := fake.NewClientBuilder().WithRuntimeObjects(tt.nodes...).Build() + nodeInfo = &NodeInfo{} + + result, err := GetNodeIP(client, tt.isNetworkPresent) + + if tt.expectedError { + assert.Error(t, err) + assert.Equal(t, tt.expectedIPs, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedIPs, result) + } + }) + } +} + +func TestGetNodeExternalIpList(t *testing.T) { + tests := []struct { + name string + isNetworkPresent bool + nodes []runtime.Object + expectedLength int + expectedError bool + }{ + { + name: "get gateway nodes when network present", + isNetworkPresent: true, + nodes: []runtime.Object{ + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gateway-node", + Labels: map[string]string{ + "kubeslice.io/node-type": "gateway", + }, + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "1.2.3.4"}, + }, + }, + }, + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "worker-node", + Labels: map[string]string{ + "kubeslice.io/node-type": "worker", + }, + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "5.6.7.8"}, + }, + }, + }, + }, + expectedLength: 1, + expectedError: false, + }, + { + name: "get all nodes when network not present", + isNetworkPresent: false, + nodes: []runtime.Object{ + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "1.2.3.4"}, + }, + }, + }, + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node2", + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "5.6.7.8"}, + }, + }, + }, + }, + expectedLength: 2, + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := fake.NewClientBuilder().WithRuntimeObjects(tt.nodes...).Build() + nodeInfo := &NodeInfo{Client: client} + + result, err := nodeInfo.getNodeExternalIpList(tt.isNetworkPresent) + + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedLength, len(result)) + } + }) + } +} + +func TestGetNodeExternalIpListGlobal(t *testing.T) { + nodeInfo = &NodeInfo{ + NodeIPList: []string{"1.2.3.4", "5.6.7.8"}, + } + + result := GetNodeExternalIpList() + assert.Equal(t, []string{"1.2.3.4", "5.6.7.8"}, result) +} + +func TestPopulateNodeIpList(t *testing.T) { + tests := []struct { + name string + isNetworkPresent bool + nodes []runtime.Object + expectedIPs []string + expectedError bool + }{ + { + name: "populate with external IPs", + isNetworkPresent: true, + nodes: []runtime.Object{ + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + Labels: map[string]string{ + "kubeslice.io/node-type": "gateway", + }, + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "1.2.3.4"}, + {Type: corev1.NodeInternalIP, Address: "10.0.0.1"}, + }, + }, + }, + }, + expectedIPs: []string{"1.2.3.4"}, + expectedError: false, + }, + { + name: "populate with internal IPs when no external", + isNetworkPresent: true, + nodes: []runtime.Object{ + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + Labels: map[string]string{ + "kubeslice.io/node-type": "gateway", + }, + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeInternalIP, Address: "10.0.0.1"}, + {Type: corev1.NodeHostName, Address: "node1"}, + }, + }, + }, + }, + expectedIPs: []string{"10.0.0.1"}, + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := fake.NewClientBuilder().WithRuntimeObjects(tt.nodes...).Build() + nodeInfo := &NodeInfo{Client: client} + + err := nodeInfo.populateNodeIpList(tt.isNetworkPresent) + + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedIPs, nodeInfo.NodeIPList) + } + }) + } +} diff --git a/pkg/events/events_recorder_unit_test.go b/pkg/events/events_recorder_unit_test.go new file mode 100644 index 000000000..3ca585133 --- /dev/null +++ b/pkg/events/events_recorder_unit_test.go @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 events + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" +) + +type MockK8sEventRecorder struct { + mock.Mock +} + +func (m *MockK8sEventRecorder) Event(object runtime.Object, eventtype, reason, message string) { + m.Called(object, eventtype, reason, message) +} + +func (m *MockK8sEventRecorder) Eventf(object runtime.Object, eventtype, reason, messageFmt string, args ...interface{}) { + m.Called(object, eventtype, reason, messageFmt, args) +} + +func (m *MockK8sEventRecorder) AnnotatedEventf(object runtime.Object, annotations map[string]string, eventtype, reason, messageFmt string, args ...interface{}) { + m.Called(object, annotations, eventtype, reason, messageFmt, args) +} + +var _ record.EventRecorder = &MockK8sEventRecorder{} + +func TestNewEventRecorder(t *testing.T) { + tests := []struct { + name string + }{ + { + name: "create new event recorder", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRecorder := new(MockK8sEventRecorder) + result := NewEventRecorder(mockRecorder) + + assert.NotNil(t, result) + assert.Equal(t, mockRecorder, result.Recorder) + }) + } +} + +func TestEventRecorderRecord(t *testing.T) { + tests := []struct { + name string + event *Event + eventType EventType + reason string + message string + }{ + { + name: "record warning event", + event: &Event{ + Object: nil, + EventType: EventTypeWarning, + Reason: "TestReason", + Message: "Test message", + }, + eventType: EventTypeWarning, + reason: "TestReason", + message: "Test message", + }, + { + name: "record normal event", + event: &Event{ + Object: nil, + EventType: EventTypeNormal, + Reason: "NormalReason", + Message: "Normal message", + }, + eventType: EventTypeNormal, + reason: "NormalReason", + message: "Normal message", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRecorder := new(MockK8sEventRecorder) + mockRecorder.On("Event", tt.event.Object, string(tt.eventType), tt.reason, tt.message).Return() + + recorder := &EventRecorder{ + Recorder: mockRecorder, + } + + recorder.Record(tt.event) + + mockRecorder.AssertExpectations(t) + }) + } +} + +func TestEventTypes(t *testing.T) { + tests := []struct { + name string + eventType EventType + expected string + }{ + { + name: "warning event type", + eventType: EventTypeWarning, + expected: "Warning", + }, + { + name: "normal event type", + eventType: EventTypeNormal, + expected: "Normal", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, string(tt.eventType)) + }) + } +} diff --git a/pkg/gatewayedge/gatewayedge_test.go b/pkg/gatewayedge/gatewayedge_test.go new file mode 100644 index 000000000..060cd0bd8 --- /dev/null +++ b/pkg/gatewayedge/gatewayedge_test.go @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 gatewayedge + +import ( + "context" + "errors" + "testing" +) + +// test-only interface; production source keeps returning *gwEdgeClient. +type gatewayEdgeUpdater interface { + UpdateSliceGwServiceMap(ctx context.Context, serverAddr string, gwSvcMap *SliceGwServiceMap) (*GwEdgeResponse, error) +} + +type fakeGatewayEdgeClient struct { + updateErr error +} + +func (f *fakeGatewayEdgeClient) UpdateSliceGwServiceMap(ctx context.Context, serverAddr string, gwSvcMap *SliceGwServiceMap) (*GwEdgeResponse, error) { + if f.updateErr != nil { + return nil, f.updateErr + } + return &GwEdgeResponse{}, nil +} + +func TestNewWorkerGatewayEdgeClientProvider(t *testing.T) { + client, err := NewWorkerGatewayEdgeClientProvider() + if err != nil { + t.Errorf("NewWorkerGatewayEdgeClientProvider() error = %v", err) + } + if client == nil { + t.Error("NewWorkerGatewayEdgeClientProvider() returned nil client") + } +} + +func TestUpdateSliceGwServiceMap_FakeClient(t *testing.T) { + tests := []struct { + name string + client gatewayEdgeUpdater + expectErr bool + }{ + { + name: "successful update", + client: &fakeGatewayEdgeClient{}, + expectErr: false, + }, + { + name: "update error", + client: &fakeGatewayEdgeClient{updateErr: errors.New("update failed")}, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := tt.client.UpdateSliceGwServiceMap(context.Background(), "dummy:1234", &SliceGwServiceMap{}) + if (err != nil) != tt.expectErr { + t.Errorf("UpdateSliceGwServiceMap() error = %v, expectErr %v", err, tt.expectErr) + } + }) + } +} + +func TestUpdateSliceGwServiceMap_DialError(t *testing.T) { + client := &gwEdgeClient{} + _, err := client.UpdateSliceGwServiceMap(context.Background(), "127.0.0.1:1", &SliceGwServiceMap{}) + if err == nil { + t.Error("expected dial/RPC error for unreachable address, got nil") + } +} diff --git a/pkg/gwsidecar/gwsidecar_test.go b/pkg/gwsidecar/gwsidecar_test.go new file mode 100644 index 000000000..065b6565f --- /dev/null +++ b/pkg/gwsidecar/gwsidecar_test.go @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 gwsidecar + +import ( + "testing" + + sidecar "github.com/kubeslice/gateway-sidecar/pkg/sidecar/sidecarpb" +) + +func TestGetTunnelState(t *testing.T) { + tests := []struct { + name string + tunnelStatus sidecar.TunnelStatusType + expected string + }{ + { + name: "tunnel up", + tunnelStatus: sidecar.TunnelStatusType_GW_TUNNEL_STATE_UP, + expected: "UP", + }, + { + name: "tunnel down", + tunnelStatus: sidecar.TunnelStatusType_GW_TUNNEL_STATE_DOWN, + expected: "DOWN", + }, + { + name: "unknown status", + tunnelStatus: sidecar.TunnelStatusType(999), + expected: "UNKNOWN", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getTunnelState(tt.tunnelStatus) + if result != tt.expected { + t.Errorf("getTunnelState() = %v, want %v", result, tt.expected) + } + }) + } +} diff --git a/pkg/hub/controllers/cluster/reconciler_unit_test.go b/pkg/hub/controllers/cluster/reconciler_unit_test.go index 595341eb2..931ac9ff2 100644 --- a/pkg/hub/controllers/cluster/reconciler_unit_test.go +++ b/pkg/hub/controllers/cluster/reconciler_unit_test.go @@ -181,6 +181,11 @@ func TestReconcilerHandleExternalDependency(t *testing.T) { mock.IsType(&hubv1alpha1.Cluster{}), mock.IsType([]k8sclient.UpdateOption(nil)), ).Return(nil) + client.StatusMock.On("Update", + mock.IsType(ctx), + mock.IsType(&hubv1alpha1.Cluster{}), + mock.IsType([]k8sclient.SubResourceUpdateOption(nil)), + ).Return(nil) client.On("List", mock.IsType(ctx), mock.IsType(&kubeslicev1beta1.SliceList{}), @@ -298,6 +303,11 @@ func TestReconcilerToFailWhileCallingCreateDeregisterJob(t *testing.T) { mock.IsType(&hubv1alpha1.Cluster{}), mock.IsType([]k8sclient.UpdateOption(nil)), ).Return(errors.New("error updating status of deregistration on the controller")) + client.StatusMock.On("Update", + mock.IsType(ctx), + mock.IsType(&hubv1alpha1.Cluster{}), + mock.IsType([]k8sclient.SubResourceUpdateOption(nil)), + ).Return(errors.New("error updating status of deregistration on the controller")) client.On("Create", mock.IsType(ctx), mock.IsType(&corev1.Event{}), diff --git a/pkg/hub/controllers/serviceimport_controller_unit_test.go b/pkg/hub/controllers/serviceimport_controller_unit_test.go new file mode 100644 index 000000000..fbe9c486c --- /dev/null +++ b/pkg/hub/controllers/serviceimport_controller_unit_test.go @@ -0,0 +1,525 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 controllers + +import ( + "context" + "errors" + "testing" + "time" + + spokev1alpha1 "github.com/kubeslice/apis/pkg/worker/v1alpha1" + mevents "github.com/kubeslice/kubeslice-monitoring/pkg/events" + kubeslicev1beta1 "github.com/kubeslice/worker-operator/api/v1beta1" + ossEvents "github.com/kubeslice/worker-operator/events" + utilmock "github.com/kubeslice/worker-operator/pkg/mocks" + "github.com/stretchr/testify/mock" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +var testSvcImportName = "test-svcim" +var testSvcImportNamespace = "kubeslice-avesha" +var testServiceName = "test-service" +var testServiceNamespace = "app-namespace" +var testSliceName = "test-slice" + +var testWorkerServiceImport = &spokev1alpha1.WorkerServiceImport{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSvcImportName, + Namespace: testSvcImportNamespace, + }, + Spec: spokev1alpha1.WorkerServiceImportSpec{ + ServiceName: testServiceName, + ServiceNamespace: testServiceNamespace, + SliceName: testSliceName, + ServiceDiscoveryPorts: []spokev1alpha1.ServiceDiscoveryPort{ + { + Name: "http", + Port: 8080, + Protocol: "TCP", + ServicePort: 80, + ServiceProtocol: "http", + }, + }, + ServiceDiscoveryEndpoints: []spokev1alpha1.ServiceDiscoveryEndpoint{ + { + PodName: "test-pod", + Cluster: "test-cluster", + NsmIp: "10.0.0.1", + DnsName: "test-pod.app-namespace.svc.cluster.local", + Port: 8080, + }, + }, + Aliases: []string{"test-alias.example.com"}, + }, +} + +var testMeshSlice = &kubeslicev1beta1.Slice{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSliceName, + Namespace: ControlPlaneNamespace, + }, + Spec: kubeslicev1beta1.SliceSpec{}, +} + +func TestServiceImportReconcilerNotFound(t *testing.T) { + expected := struct { + ctx context.Context + req reconcile.Request + res reconcile.Result + errStr string + }{ + context.Background(), + reconcile.Request{NamespacedName: types.NamespacedName{Name: testSvcImportName, Namespace: testSvcImportNamespace}}, + reconcile.Result{}, + "object not found", + } + + client := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &ServiceImportReconciler{ + Client: client, + MeshClient: client, + EventRecorder: &eventRecorder, + } + + ctx := context.Background() + svcImKey := types.NamespacedName{Namespace: testSvcImportNamespace, Name: testSvcImportName} + + client.On("Get", + mock.IsType(ctx), + mock.IsType(svcImKey), + mock.IsType(&spokev1alpha1.WorkerServiceImport{}), + ).Return(errors.New("object not found")) + + result, err := reconciler.Reconcile(expected.ctx, expected.req) + if expected.res != result { + t.Error("Expected response :", expected.res, " but got ", result) + } + if err == nil || expected.errStr != err.Error() { + t.Error("Expected error:", expected.errStr, " but got ", err) + } +} + +func TestServiceImportReconcilerWithFinalizer(t *testing.T) { + svcim := testWorkerServiceImport.DeepCopy() + svcim.DeletionTimestamp = nil + + expected := struct { + ctx context.Context + req reconcile.Request + res reconcile.Result + err error + }{ + context.Background(), + reconcile.Request{NamespacedName: types.NamespacedName{Name: testSvcImportName, Namespace: testSvcImportNamespace}}, + reconcile.Result{}, + nil, + } + + client := utilmock.NewClient() + meshClient := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &ServiceImportReconciler{ + Client: client, + MeshClient: meshClient, + EventRecorder: &eventRecorder, + } + + ctx := context.Background() + svcImKey := types.NamespacedName{Namespace: testSvcImportNamespace, Name: testSvcImportName} + + client.On("Get", + mock.IsType(ctx), + mock.IsType(svcImKey), + mock.IsType(&spokev1alpha1.WorkerServiceImport{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*spokev1alpha1.WorkerServiceImport) + *arg = *svcim + }) + + client.On("Update", + mock.IsType(ctx), + mock.IsType(&spokev1alpha1.WorkerServiceImport{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + meshClient.On("Get", + mock.IsType(ctx), + mock.IsType(types.NamespacedName{Name: testSliceName, Namespace: ControlPlaneNamespace}), + mock.IsType(&kubeslicev1beta1.Slice{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*kubeslicev1beta1.Slice) + *arg = *testMeshSlice + }) + + meshClient.On("Get", + mock.IsType(ctx), + mock.IsType(types.NamespacedName{Name: testServiceName, Namespace: testServiceNamespace}), + mock.IsType(&kubeslicev1beta1.ServiceImport{}), + ).Return(apierrors.NewNotFound(schema.GroupResource{Resource: "serviceimport"}, testServiceName)) + + meshClient.On("Create", + mock.IsType(ctx), + mock.IsType(&kubeslicev1beta1.ServiceImport{}), + mock.IsType([]k8sclient.CreateOption(nil)), + ).Return(nil) + + meshClient.StatusMock.On("Update", + mock.IsType(ctx), + mock.IsType(&kubeslicev1beta1.ServiceImport{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + meshClient.StatusMock.On("Update", + mock.IsType(ctx), + mock.IsType(&kubeslicev1beta1.ServiceImport{}), + mock.IsType([]k8sclient.SubResourceUpdateOption(nil)), + ).Return(nil) + + client.On("Create", + mock.IsType(ctx), + mock.IsType(&corev1.Event{}), + mock.IsType([]k8sclient.CreateOption(nil)), + ).Return(nil) + + result, err := reconciler.Reconcile(expected.ctx, expected.req) + if expected.res != result { + t.Error("Expected response :", expected.res, " but got ", result) + } + if expected.err != err { + t.Error("Expected error:", expected.err, " but got ", err) + } +} + +func TestServiceImportReconcilerUpdateExisting(t *testing.T) { + svcim := testWorkerServiceImport.DeepCopy() + + existingMeshSvcIm := &kubeslicev1beta1.ServiceImport{ + ObjectMeta: metav1.ObjectMeta{ + Name: testServiceName, + Namespace: testServiceNamespace, + }, + Spec: kubeslicev1beta1.ServiceImportSpec{ + Slice: testSliceName, + DNSName: testServiceName + "." + testServiceNamespace + ".svc.slice.local", + }, + } + + expected := struct { + ctx context.Context + req reconcile.Request + res reconcile.Result + err error + }{ + context.Background(), + reconcile.Request{NamespacedName: types.NamespacedName{Name: testSvcImportName, Namespace: testSvcImportNamespace}}, + reconcile.Result{}, + nil, + } + + client := utilmock.NewClient() + meshClient := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &ServiceImportReconciler{ + Client: client, + MeshClient: meshClient, + EventRecorder: &eventRecorder, + } + + ctx := context.Background() + svcImKey := types.NamespacedName{Namespace: testSvcImportNamespace, Name: testSvcImportName} + + client.On("Get", + mock.IsType(ctx), + mock.IsType(svcImKey), + mock.IsType(&spokev1alpha1.WorkerServiceImport{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*spokev1alpha1.WorkerServiceImport) + *arg = *svcim + }) + + client.On("Update", + mock.IsType(ctx), + mock.IsType(&spokev1alpha1.WorkerServiceImport{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + meshClient.On("Get", + mock.IsType(ctx), + mock.IsType(types.NamespacedName{Name: testSliceName, Namespace: ControlPlaneNamespace}), + mock.IsType(&kubeslicev1beta1.Slice{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*kubeslicev1beta1.Slice) + *arg = *testMeshSlice + }) + + meshClient.On("Get", + mock.IsType(ctx), + mock.IsType(types.NamespacedName{Name: testServiceName, Namespace: testServiceNamespace}), + mock.IsType(&kubeslicev1beta1.ServiceImport{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*kubeslicev1beta1.ServiceImport) + *arg = *existingMeshSvcIm + }) + + meshClient.On("Update", + mock.IsType(ctx), + mock.IsType(&kubeslicev1beta1.ServiceImport{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + meshClient.StatusMock.On("Update", + mock.IsType(ctx), + mock.IsType(&kubeslicev1beta1.ServiceImport{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + meshClient.StatusMock.On("Update", + mock.IsType(ctx), + mock.IsType(&kubeslicev1beta1.ServiceImport{}), + mock.IsType([]k8sclient.SubResourceUpdateOption(nil)), + ).Return(nil) + + client.On("Create", + mock.IsType(ctx), + mock.IsType(&corev1.Event{}), + mock.IsType([]k8sclient.CreateOption(nil)), + ).Return(nil) + + result, err := reconciler.Reconcile(expected.ctx, expected.req) + if expected.res != result { + t.Error("Expected response :", expected.res, " but got ", result) + } + if expected.err != err { + t.Error("Expected error:", expected.err, " but got ", err) + } +} + +func TestServiceImportReconcilerSliceNotFound(t *testing.T) { + svcim := testWorkerServiceImport.DeepCopy() + + expected := struct { + ctx context.Context + req reconcile.Request + res reconcile.Result + err error + }{ + context.Background(), + reconcile.Request{NamespacedName: types.NamespacedName{Name: testSvcImportName, Namespace: testSvcImportNamespace}}, + reconcile.Result{RequeueAfter: 30 * time.Second}, + nil, + } + + client := utilmock.NewClient() + meshClient := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &ServiceImportReconciler{ + Client: client, + MeshClient: meshClient, + EventRecorder: &eventRecorder, + } + + ctx := context.Background() + svcImKey := types.NamespacedName{Namespace: testSvcImportNamespace, Name: testSvcImportName} + + client.On("Get", + mock.IsType(ctx), + mock.IsType(svcImKey), + mock.IsType(&spokev1alpha1.WorkerServiceImport{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*spokev1alpha1.WorkerServiceImport) + *arg = *svcim + }) + + client.On("Update", + mock.IsType(ctx), + mock.IsType(&spokev1alpha1.WorkerServiceImport{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + meshClient.On("Get", + mock.IsType(ctx), + mock.IsType(types.NamespacedName{Name: testSliceName, Namespace: ControlPlaneNamespace}), + mock.IsType(&kubeslicev1beta1.Slice{}), + ).Return(apierrors.NewNotFound(schema.GroupResource{Resource: "slice"}, testSliceName)) + + result, err := reconciler.Reconcile(expected.ctx, expected.req) + if expected.res != result { + t.Error("Expected response :", expected.res, " but got ", result) + } + if expected.err != err { + t.Error("Expected error:", expected.err, " but got ", err) + } +} + +func TestServiceImportReconcilerDeletion(t *testing.T) { + svcim := testWorkerServiceImport.DeepCopy() + now := metav1.Now() + svcim.DeletionTimestamp = &now + svcim.Finalizers = []string{"controller.kubeslice.io/hubWorkerServiceImport-finalizer"} + + expected := struct { + ctx context.Context + req reconcile.Request + res reconcile.Result + err error + }{ + context.Background(), + reconcile.Request{NamespacedName: types.NamespacedName{Name: testSvcImportName, Namespace: testSvcImportNamespace}}, + reconcile.Result{}, + nil, + } + + client := utilmock.NewClient() + meshClient := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &ServiceImportReconciler{ + Client: client, + MeshClient: meshClient, + EventRecorder: &eventRecorder, + } + + ctx := context.Background() + svcImKey := types.NamespacedName{Namespace: testSvcImportNamespace, Name: testSvcImportName} + + client.On("Get", + mock.IsType(ctx), + mock.IsType(svcImKey), + mock.IsType(&spokev1alpha1.WorkerServiceImport{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*spokev1alpha1.WorkerServiceImport) + *arg = *svcim + }) + + meshClient.On("Delete", + mock.IsType(ctx), + mock.IsType(&kubeslicev1beta1.ServiceImport{}), + mock.IsType([]k8sclient.DeleteOption(nil)), + ).Return(nil) + + client.On("Update", + mock.IsType(ctx), + mock.IsType(&spokev1alpha1.WorkerServiceImport{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + result, err := reconciler.Reconcile(expected.ctx, expected.req) + if expected.res != result { + t.Error("Expected response :", expected.res, " but got ", result) + } + if expected.err != err { + t.Error("Expected error:", expected.err, " but got ", err) + } +} + +func TestGetProtocol(t *testing.T) { + tests := []struct { + input string + expected corev1.Protocol + }{ + {"TCP", corev1.ProtocolTCP}, + {"UDP", corev1.ProtocolUDP}, + {"SCTP", corev1.ProtocolSCTP}, + {"unknown", ""}, + } + + for _, test := range tests { + result := getProtocol(test.input) + if result != test.expected { + t.Errorf("For input %s, expected %s but got %s", test.input, test.expected, result) + } + } +} + +func TestGetMeshServiceImportPortList(t *testing.T) { + svcim := testWorkerServiceImport.DeepCopy() + portList := getMeshServiceImportPortList(svcim) + + if len(portList) != len(svcim.Spec.ServiceDiscoveryPorts) { + t.Errorf("Expected %d ports but got %d", len(svcim.Spec.ServiceDiscoveryPorts), len(portList)) + } + + if portList[0].Name != "http" { + t.Errorf("Expected port name 'http' but got %s", portList[0].Name) + } + + if portList[0].ContainerPort != 8080 { + t.Errorf("Expected port 8080 but got %d", portList[0].ContainerPort) + } + + if portList[0].Protocol != corev1.ProtocolTCP { + t.Errorf("Expected protocol TCP but got %s", portList[0].Protocol) + } + + if portList[0].ServiceProtocol != gwapiv1.ProtocolType("http") { + t.Errorf("Expected service protocol http but got %s", portList[0].ServiceProtocol) + } +} + +func TestGetMeshServiceImportEpList(t *testing.T) { + svcim := testWorkerServiceImport.DeepCopy() + epList := getMeshServiceImportEpList(svcim) + + if len(epList) != len(svcim.Spec.ServiceDiscoveryEndpoints) { + t.Errorf("Expected %d endpoints but got %d", len(svcim.Spec.ServiceDiscoveryEndpoints), len(epList)) + } + + if epList[0].Name != "test-pod" { + t.Errorf("Expected endpoint name 'test-pod' but got %s", epList[0].Name) + } + + if epList[0].IP != "10.0.0.1" { + t.Errorf("Expected IP 10.0.0.1 but got %s", epList[0].IP) + } + + if epList[0].ClusterID != "test-cluster" { + t.Errorf("Expected cluster ID 'test-cluster' but got %s", epList[0].ClusterID) + } +} + +func TestGetMeshServiceImportObj(t *testing.T) { + svcim := testWorkerServiceImport.DeepCopy() + meshSvcIm := getMeshServiceImportObj(svcim) + + if meshSvcIm.Name != testServiceName { + t.Errorf("Expected name %s but got %s", testServiceName, meshSvcIm.Name) + } + + if meshSvcIm.Namespace != testServiceNamespace { + t.Errorf("Expected namespace %s but got %s", testServiceNamespace, meshSvcIm.Namespace) + } + + if meshSvcIm.Spec.Slice != testSliceName { + t.Errorf("Expected slice %s but got %s", testSliceName, meshSvcIm.Spec.Slice) + } + + expectedDNS := testServiceName + "." + testServiceNamespace + ".svc.slice.local" + if meshSvcIm.Spec.DNSName != expectedDNS { + t.Errorf("Expected DNS name %s but got %s", expectedDNS, meshSvcIm.Spec.DNSName) + } +} diff --git a/pkg/hub/controllers/slicegateway_controller_unit_test.go b/pkg/hub/controllers/slicegateway_controller_unit_test.go new file mode 100644 index 000000000..9f21583fe --- /dev/null +++ b/pkg/hub/controllers/slicegateway_controller_unit_test.go @@ -0,0 +1,568 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 controllers + +import ( + "context" + "errors" + "testing" + + hubv1alpha1 "github.com/kubeslice/apis/pkg/controller/v1alpha1" + spokev1alpha1 "github.com/kubeslice/apis/pkg/worker/v1alpha1" + mevents "github.com/kubeslice/kubeslice-monitoring/pkg/events" + kubeslicev1beta1 "github.com/kubeslice/worker-operator/api/v1beta1" + ossEvents "github.com/kubeslice/worker-operator/events" + utilmock "github.com/kubeslice/worker-operator/pkg/mocks" + "github.com/stretchr/testify/mock" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +var testSliceGwName = "test-slice-gw-server" +var testSliceGwNamespace = "kubeslice-avesha" +var testClusterName = "test-cluster-1" +var testRemoteClusterName = "test-cluster-2" + +var testWorkerSliceGateway = &spokev1alpha1.WorkerSliceGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSliceGwName, + Namespace: testSliceGwNamespace, + }, + Spec: spokev1alpha1.WorkerSliceGatewaySpec{ + SliceName: testSliceName, + GatewayHostType: "Server", + GatewayNumber: 1, + GatewayType: "OpenVPN", + GatewayConnectivityType: "DIRECT", + GatewayProtocol: "UDP", + LocalGatewayConfig: spokev1alpha1.SliceGatewayConfig{ + ClusterName: testClusterName, + GatewayName: testSliceGwName, + GatewaySubnet: "10.1.0.0/16", + VpnIp: "10.1.0.1", + NodePorts: []int{30001, 30002}, + }, + RemoteGatewayConfig: spokev1alpha1.SliceGatewayConfig{ + ClusterName: testRemoteClusterName, + GatewayName: "test-slice-gw-client", + GatewaySubnet: "10.2.0.0/16", + VpnIp: "10.2.0.1", + NodeIps: []string{"192.168.1.1", "192.168.1.2"}, + NodePorts: []int{30003, 30004}, + }, + }, +} + +var testVpnKeyRotation = &hubv1alpha1.VpnKeyRotation{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSliceName, + Namespace: testSliceGwNamespace, + }, + Spec: hubv1alpha1.VpnKeyRotationSpec{ + RotationCount: 0, + }, +} + +var testSliceGwSecret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSliceGwName, + Namespace: testSliceGwNamespace, + }, + Data: map[string][]byte{ + "ca.crt": []byte("test-ca"), + "tls.crt": []byte("test-cert"), + "tls.key": []byte("test-key"), + }, +} + +func TestSliceGwReconcilerNotFound(t *testing.T) { + expected := struct { + ctx context.Context + req reconcile.Request + res reconcile.Result + err error + }{ + context.Background(), + reconcile.Request{NamespacedName: types.NamespacedName{Name: testSliceGwName, Namespace: testSliceGwNamespace}}, + reconcile.Result{}, + nil, + } + + client := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &SliceGwReconciler{ + Client: client, + MeshClient: client, + EventRecorder: &eventRecorder, + ClusterName: testClusterName, + } + + ctx := context.Background() + sliceGwKey := types.NamespacedName{Namespace: testSliceGwNamespace, Name: testSliceGwName} + + client.On("Get", + mock.IsType(ctx), + mock.IsType(sliceGwKey), + mock.IsType(&spokev1alpha1.WorkerSliceGateway{}), + ).Return(apierrors.NewNotFound(schema.GroupResource{Resource: "workerslicegateway"}, testSliceGwName)) + + result, err := reconciler.Reconcile(expected.ctx, expected.req) + if expected.res != result { + t.Error("Expected response :", expected.res, " but got ", result) + } + if expected.err != err { + t.Error("Expected error:", expected.err, " but got ", err) + } +} + +func TestSliceGwReconcilerWrongCluster(t *testing.T) { + sliceGw := testWorkerSliceGateway.DeepCopy() + sliceGw.Spec.LocalGatewayConfig.ClusterName = "different-cluster" + + expected := struct { + ctx context.Context + req reconcile.Request + res reconcile.Result + err error + }{ + context.Background(), + reconcile.Request{NamespacedName: types.NamespacedName{Name: testSliceGwName, Namespace: testSliceGwNamespace}}, + reconcile.Result{}, + nil, + } + + client := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &SliceGwReconciler{ + Client: client, + MeshClient: client, + EventRecorder: &eventRecorder, + ClusterName: testClusterName, + } + + ctx := context.Background() + sliceGwKey := types.NamespacedName{Namespace: testSliceGwNamespace, Name: testSliceGwName} + + client.On("Get", + mock.IsType(ctx), + mock.IsType(sliceGwKey), + mock.IsType(&spokev1alpha1.WorkerSliceGateway{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*spokev1alpha1.WorkerSliceGateway) + *arg = *sliceGw + }) + + result, err := reconciler.Reconcile(expected.ctx, expected.req) + if expected.res != result { + t.Error("Expected response :", expected.res, " but got ", result) + } + if expected.err != err { + t.Error("Expected error:", expected.err, " but got ", err) + } +} + +func TestSliceGwReconcilerCreateSliceGw(t *testing.T) { + sliceGw := testWorkerSliceGateway.DeepCopy() + + expected := struct { + ctx context.Context + req reconcile.Request + res reconcile.Result + err error + }{ + context.Background(), + reconcile.Request{NamespacedName: types.NamespacedName{Name: testSliceGwName, Namespace: testSliceGwNamespace}}, + reconcile.Result{}, + nil, + } + + client := utilmock.NewClient() + meshClient := utilmock.NewClient() + _ = kubeslicev1beta1.AddToScheme(scheme.Scheme) + _ = spokev1alpha1.AddToScheme(scheme.Scheme) + _ = hubv1alpha1.AddToScheme(scheme.Scheme) + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &SliceGwReconciler{ + Client: client, + MeshClient: meshClient, + EventRecorder: &eventRecorder, + ClusterName: testClusterName, + } + + meshClient.On("Scheme").Return(scheme.Scheme) + + ctx := context.Background() + sliceGwKey := types.NamespacedName{Namespace: testSliceGwNamespace, Name: testSliceGwName} + + client.On("Get", + mock.IsType(ctx), + mock.IsType(sliceGwKey), + mock.IsType(&spokev1alpha1.WorkerSliceGateway{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*spokev1alpha1.WorkerSliceGateway) + *arg = *sliceGw + }) + + client.On("Update", + mock.IsType(ctx), + mock.IsType(&spokev1alpha1.WorkerSliceGateway{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + // VPN key rotation get + client.On("Get", + mock.IsType(ctx), + mock.IsType(types.NamespacedName{Name: testSliceName, Namespace: testSliceGwNamespace}), + mock.IsType(&hubv1alpha1.VpnKeyRotation{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*hubv1alpha1.VpnKeyRotation) + *arg = *testVpnKeyRotation + }) + + // Check for existing secret in mesh + meshClient.On("Get", + mock.IsType(ctx), + mock.IsType(types.NamespacedName{Name: testSliceGwName + "-0", Namespace: ControlPlaneNamespace}), + mock.IsType(&corev1.Secret{}), + ).Return(apierrors.NewNotFound(corev1.Resource("secret"), testSliceGwName+"-0")) + + // Get secret from hub + client.On("Get", + mock.IsType(ctx), + mock.IsType(sliceGwKey), + mock.IsType(&corev1.Secret{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*corev1.Secret) + *arg = *testSliceGwSecret + }) + + // Create secret in mesh + meshClient.On("Create", + mock.IsType(ctx), + mock.IsType(&corev1.Secret{}), + mock.IsType([]k8sclient.CreateOption(nil)), + ).Return(nil) + + // Check if slice gateway exists + meshClient.On("Get", + mock.Anything, + mock.IsType(types.NamespacedName{Name: testSliceGwName, Namespace: ControlPlaneNamespace}), + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + ).Return(apierrors.NewNotFound(schema.GroupResource{Resource: "slicegateway"}, testSliceGwName)).Once() + + // Get slice to set as owner + meshClient.On("Get", + mock.Anything, + mock.IsType(types.NamespacedName{Name: testSliceName, Namespace: ControlPlaneNamespace}), + mock.IsType(&kubeslicev1beta1.Slice{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*kubeslicev1beta1.Slice) + *arg = *testMeshSlice + }) + + // Create slice gateway + meshClient.On("Create", + mock.Anything, + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + mock.IsType([]k8sclient.CreateOption(nil)), + ).Return(nil) + + // Get created slice gateway for status update + meshClient.On("Get", + mock.Anything, + mock.IsType(types.NamespacedName{Name: testSliceGwName, Namespace: ControlPlaneNamespace}), + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*kubeslicev1beta1.SliceGateway) + arg.ObjectMeta = metav1.ObjectMeta{ + Name: testSliceGwName, + Namespace: ControlPlaneNamespace, + } + arg.Status.Config = kubeslicev1beta1.SliceGatewayConfig{} + }) + + meshClient.StatusMock.On("Update", + mock.Anything, + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + meshClient.StatusMock.On("Update", + mock.Anything, + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + mock.IsType([]k8sclient.SubResourceUpdateOption(nil)), + ).Return(nil) + + client.On("Create", + mock.IsType(ctx), + mock.IsType(&corev1.Event{}), + mock.IsType([]k8sclient.CreateOption(nil)), + ).Return(nil) + + result, err := reconciler.Reconcile(expected.ctx, expected.req) + if expected.res != result { + t.Error("Expected response :", expected.res, " but got ", result) + } + if expected.err != err { + t.Error("Expected error:", expected.err, " but got ", err) + } +} + +func TestSliceGwReconcilerUpdateSliceGw(t *testing.T) { + sliceGw := testWorkerSliceGateway.DeepCopy() + sliceGw.Spec.RemoteGatewayConfig.NodeIps = []string{"192.168.1.3", "192.168.1.4"} + + existingMeshSliceGw := &kubeslicev1beta1.SliceGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSliceGwName, + Namespace: ControlPlaneNamespace, + }, + Spec: kubeslicev1beta1.SliceGatewaySpec{ + SliceName: testSliceName, + }, + Status: kubeslicev1beta1.SliceGatewayStatus{ + Config: kubeslicev1beta1.SliceGatewayConfig{ + SliceGatewayID: testSliceGwName, + SliceGatewaySubnet: "10.1.0.0/16", + SliceGatewayRemoteSubnet: "10.2.0.0/16", + SliceGatewayHostType: "Server", + SliceGatewayRemoteNodeIPs: []string{"192.168.1.1", "192.168.1.2"}, + SliceGatewayRemoteClusterID: testRemoteClusterName, + SliceGatewayRemoteGatewayID: "test-slice-gw-client", + }, + }, + } + + expected := struct { + ctx context.Context + req reconcile.Request + res reconcile.Result + err error + }{ + context.Background(), + reconcile.Request{NamespacedName: types.NamespacedName{Name: testSliceGwName, Namespace: testSliceGwNamespace}}, + reconcile.Result{}, + nil, + } + + client := utilmock.NewClient() + meshClient := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &SliceGwReconciler{ + Client: client, + MeshClient: meshClient, + EventRecorder: &eventRecorder, + ClusterName: testClusterName, + } + + ctx := context.Background() + sliceGwKey := types.NamespacedName{Namespace: testSliceGwNamespace, Name: testSliceGwName} + + client.On("Get", + mock.IsType(ctx), + mock.IsType(sliceGwKey), + mock.IsType(&spokev1alpha1.WorkerSliceGateway{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*spokev1alpha1.WorkerSliceGateway) + *arg = *sliceGw + }) + + client.On("Update", + mock.IsType(ctx), + mock.IsType(&spokev1alpha1.WorkerSliceGateway{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + client.On("Get", + mock.IsType(ctx), + mock.IsType(types.NamespacedName{Name: testSliceName, Namespace: testSliceGwNamespace}), + mock.IsType(&hubv1alpha1.VpnKeyRotation{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*hubv1alpha1.VpnKeyRotation) + *arg = *testVpnKeyRotation + }) + + meshClient.On("Get", + mock.IsType(ctx), + mock.IsType(types.NamespacedName{Name: testSliceGwName + "-0", Namespace: ControlPlaneNamespace}), + mock.IsType(&corev1.Secret{}), + ).Return(nil) + + meshClient.On("Get", + mock.IsType(ctx), + mock.IsType(types.NamespacedName{Name: testSliceGwName, Namespace: ControlPlaneNamespace}), + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*kubeslicev1beta1.SliceGateway) + *arg = *existingMeshSliceGw + }) + + meshClient.StatusMock.On("Update", + mock.IsType(ctx), + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + meshClient.StatusMock.On("Update", + mock.IsType(ctx), + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + mock.IsType([]k8sclient.SubResourceUpdateOption(nil)), + ).Return(nil) + + client.On("Create", + mock.IsType(ctx), + mock.IsType(&corev1.Event{}), + mock.IsType([]k8sclient.CreateOption(nil)), + ).Return(nil) + + result, err := reconciler.Reconcile(expected.ctx, expected.req) + if expected.res != result { + t.Error("Expected response :", expected.res, " but got ", result) + } + if expected.err != err { + t.Error("Expected error:", expected.err, " but got ", err) + } +} + +func TestSliceGwReconcilerDeletion(t *testing.T) { + sliceGw := testWorkerSliceGateway.DeepCopy() + now := metav1.Now() + sliceGw.DeletionTimestamp = &now + sliceGw.Finalizers = []string{"controller.kubeslice.io/sliceGw-finalizer"} + + expected := struct { + ctx context.Context + req reconcile.Request + res reconcile.Result + err error + }{ + context.Background(), + reconcile.Request{NamespacedName: types.NamespacedName{Name: testSliceGwName, Namespace: testSliceGwNamespace}}, + reconcile.Result{}, + nil, + } + + client := utilmock.NewClient() + meshClient := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &SliceGwReconciler{ + Client: client, + MeshClient: meshClient, + EventRecorder: &eventRecorder, + ClusterName: testClusterName, + } + + ctx := context.Background() + sliceGwKey := types.NamespacedName{Namespace: testSliceGwNamespace, Name: testSliceGwName} + + client.On("Get", + mock.IsType(ctx), + mock.IsType(sliceGwKey), + mock.IsType(&spokev1alpha1.WorkerSliceGateway{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*spokev1alpha1.WorkerSliceGateway) + *arg = *sliceGw + }) + + meshClient.On("Delete", + mock.IsType(ctx), + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + mock.IsType([]k8sclient.DeleteOption(nil)), + ).Return(nil) + + client.On("Get", + mock.IsType(ctx), + mock.IsType(sliceGwKey), + mock.IsType(&spokev1alpha1.WorkerSliceGateway{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*spokev1alpha1.WorkerSliceGateway) + arg.Finalizers = []string{"controller.kubeslice.io/sliceGw-finalizer"} + }) + + client.On("Update", + mock.IsType(ctx), + mock.IsType(&spokev1alpha1.WorkerSliceGateway{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + result, err := reconciler.Reconcile(expected.ctx, expected.req) + if expected.res != result { + t.Error("Expected response :", expected.res, " but got ", result) + } + if expected.err != err { + t.Error("Expected error:", expected.err, " but got ", err) + } +} + +func TestSliceGwReconcilerCreateCertsError(t *testing.T) { + sliceGw := testWorkerSliceGateway.DeepCopy() + + expected := struct { + ctx context.Context + req reconcile.Request + }{ + context.Background(), + reconcile.Request{NamespacedName: types.NamespacedName{Name: testSliceGwName, Namespace: testSliceGwNamespace}}, + } + + client := utilmock.NewClient() + meshClient := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &SliceGwReconciler{ + Client: client, + MeshClient: meshClient, + EventRecorder: &eventRecorder, + ClusterName: testClusterName, + } + + ctx := context.Background() + sliceGwKey := types.NamespacedName{Namespace: testSliceGwNamespace, Name: testSliceGwName} + + client.On("Get", + mock.IsType(ctx), + mock.IsType(sliceGwKey), + mock.IsType(&spokev1alpha1.WorkerSliceGateway{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*spokev1alpha1.WorkerSliceGateway) + *arg = *sliceGw + }) + + client.On("Update", + mock.IsType(ctx), + mock.IsType(&spokev1alpha1.WorkerSliceGateway{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + client.On("Get", + mock.IsType(ctx), + mock.IsType(types.NamespacedName{Name: testSliceName, Namespace: testSliceGwNamespace}), + mock.IsType(&hubv1alpha1.VpnKeyRotation{}), + ).Return(errors.New("vpn key rotation not found")) + + _, err := reconciler.Reconcile(expected.ctx, expected.req) + if err == nil { + t.Error("Expected error but got nil") + } +} diff --git a/pkg/hub/controllers/workerslicegwrecycler/helpers_unit_test.go b/pkg/hub/controllers/workerslicegwrecycler/helpers_unit_test.go new file mode 100644 index 000000000..f923ac1be --- /dev/null +++ b/pkg/hub/controllers/workerslicegwrecycler/helpers_unit_test.go @@ -0,0 +1,258 @@ +package workerslicegwrecycler + +import ( + "context" + "testing" + + kubeslicev1beta1 "github.com/kubeslice/worker-operator/api/v1beta1" + utilmock "github.com/kubeslice/worker-operator/pkg/mocks" + "github.com/stretchr/testify/mock" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" + ctrl "sigs.k8s.io/controller-runtime" +) + +func TestCheckIfDeploymentIsPresent(t *testing.T) { + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deployment", + Namespace: "kubeslice-system", + Labels: map[string]string{ + "kubeslice.io/slice": "test-slice", + "kubeslice.io/slice-gw": testServerGwName, + }, + }, + } + + deploymentList := &appsv1.DeploymentList{ + Items: []appsv1.Deployment{*deployment}, + } + + client := utilmock.NewClient() + reconciler := &Reconciler{ + MeshClient: client, + } + + ctx := context.Background() + + client.On("List", + mock.Anything, + mock.IsType(&appsv1.DeploymentList{}), + mock.IsType([]k8sclient.ListOption(nil)), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(1).(*appsv1.DeploymentList) + *arg = *deploymentList + }) + + present := reconciler.CheckIfDeploymentIsPresent(ctx, "test-deployment", "test-slice", testServerGwName) + if !present { + t.Error("Expected deployment to be present but got false") + } + + notPresent := reconciler.CheckIfDeploymentIsPresent(ctx, "non-existent-deployment", "test-slice", testServerGwName) + if notPresent { + t.Error("Expected deployment to be absent but got true") + } +} + +func TestCreateNewDeployment(t *testing.T) { + sliceGw := &kubeslicev1beta1.SliceGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: testServerGwName, + Namespace: "kubeslice-system", + }, + Spec: kubeslicev1beta1.SliceGatewaySpec{ + SliceName: "test-slice", + }, + Status: kubeslicev1beta1.SliceGatewayStatus{ + Config: kubeslicev1beta1.SliceGatewayConfig{ + SliceGatewayIntermediateDeployments: []string{}, + }, + }, + } + + client := utilmock.NewClient() + reconciler := &Reconciler{ + MeshClient: client, + } + + ctx := context.Background() + + client.On("List", + mock.Anything, + mock.IsType(&appsv1.DeploymentList{}), + mock.IsType([]k8sclient.ListOption(nil)), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(1).(*appsv1.DeploymentList) + arg.Items = []appsv1.Deployment{} + }) + + client.On("Get", + mock.Anything, + mock.IsType(types.NamespacedName{Namespace: "kubeslice-system", Name: testServerGwName}), + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*kubeslicev1beta1.SliceGateway) + *arg = *sliceGw + }) + + client.StatusMock.On("Update", + mock.Anything, + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + client.StatusMock.On("Update", + mock.Anything, + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + mock.IsType([]k8sclient.SubResourceUpdateOption(nil)), + ).Return(nil) + + result, err, requeue := reconciler.CreateNewDeployment(ctx, "test-deployment-1", "test-slice", testServerGwName) + if err != nil { + t.Error("Expected no error but got:", err) + } + if requeue { + t.Error("Expected requeue to be false but got true") + } + if result != (ctrl.Result{}) { + t.Error("Expected empty result but got:", result) + } +} + +func TestMarkGwRouteForDeletion(t *testing.T) { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "kubeslice-system", + Labels: map[string]string{ + "kubeslice.io/pod-type": "slicegateway", + "kubeslice.io/slice-gw": testServerGwName, + }, + }, + } + + podList := &corev1.PodList{ + Items: []corev1.Pod{*pod}, + } + + sliceGw := &kubeslicev1beta1.SliceGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: testServerGwName, + Namespace: "kubeslice-system", + }, + Spec: kubeslicev1beta1.SliceGatewaySpec{ + SliceName: "test-slice", + }, + } + + client := utilmock.NewClient() + reconciler := &Reconciler{ + MeshClient: client, + } + + ctx := context.Background() + + client.On("List", + mock.Anything, + mock.IsType(&corev1.PodList{}), + mock.IsType([]k8sclient.ListOption(nil)), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(1).(*corev1.PodList) + *arg = *podList + }) + + client.On("Update", + mock.Anything, + mock.IsType(&corev1.Pod{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + err := reconciler.MarkGwRouteForDeletion(ctx, sliceGw, testServerGwName) + if err != nil { + t.Error("Expected no error but got:", err) + } +} + +func TestTriggerGwDeploymentDeletion(t *testing.T) { + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: testServerGwName, + Namespace: "kubeslice-system", + Labels: map[string]string{ + "kubeslice.io/slice": "test-slice", + "kubeslice.io/slice-gw": testServerGwName, + }, + }, + } + + deploymentList := &appsv1.DeploymentList{ + Items: []appsv1.Deployment{*deployment}, + } + + sliceGw := &kubeslicev1beta1.SliceGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: testServerGwName, + Namespace: "kubeslice-system", + }, + Spec: kubeslicev1beta1.SliceGatewaySpec{ + SliceName: "test-slice", + }, + Status: kubeslicev1beta1.SliceGatewayStatus{ + Config: kubeslicev1beta1.SliceGatewayConfig{ + SliceGatewayIntermediateDeployments: []string{"test-new-deployment"}, + }, + }, + } + + client := utilmock.NewClient() + reconciler := &Reconciler{ + MeshClient: client, + } + + ctx := context.Background() + + client.On("List", + mock.Anything, + mock.IsType(&appsv1.DeploymentList{}), + mock.IsType([]k8sclient.ListOption(nil)), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(1).(*appsv1.DeploymentList) + *arg = *deploymentList + }) + + client.On("Update", + mock.Anything, + mock.IsType(&appsv1.Deployment{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + client.On("Get", + mock.Anything, + mock.IsType(types.NamespacedName{Namespace: "kubeslice-system", Name: testServerGwName}), + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*kubeslicev1beta1.SliceGateway) + *arg = *sliceGw + }) + + client.StatusMock.On("Update", + mock.Anything, + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + client.StatusMock.On("Update", + mock.Anything, + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + mock.IsType([]k8sclient.SubResourceUpdateOption(nil)), + ).Return(nil) + + err := reconciler.TriggerGwDeploymentDeletion(ctx, "test-slice", testServerGwName, testServerGwName, "test-new-deployment") + if err != nil { + t.Error("Expected no error but got:", err) + } +} diff --git a/pkg/hub/controllers/workerslicegwrecycler/reconciler_unit_test.go b/pkg/hub/controllers/workerslicegwrecycler/reconciler_unit_test.go new file mode 100644 index 000000000..169fda4b6 --- /dev/null +++ b/pkg/hub/controllers/workerslicegwrecycler/reconciler_unit_test.go @@ -0,0 +1,411 @@ +package workerslicegwrecycler + +import ( + "context" + "errors" + "testing" + + spokev1alpha1 "github.com/kubeslice/apis/pkg/worker/v1alpha1" + mevents "github.com/kubeslice/kubeslice-monitoring/pkg/events" + kubeslicev1beta1 "github.com/kubeslice/worker-operator/api/v1beta1" + ossEvents "github.com/kubeslice/worker-operator/events" + "github.com/kubeslice/worker-operator/pkg/gwsidecar" + utilmock "github.com/kubeslice/worker-operator/pkg/mocks" + "github.com/kubeslice/worker-operator/pkg/router" + sidecar "github.com/kubeslice/router-sidecar/pkg/sidecar/sidecarpb" + "github.com/looplab/fsm" + "github.com/stretchr/testify/mock" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" + ctrl "sigs.k8s.io/controller-runtime" +) + +type MockWorkerGWSidecarClient struct { + mock.Mock +} + +func (m *MockWorkerGWSidecarClient) GetStatus(ctx context.Context, serverAddr string) (*gwsidecar.GwStatus, error) { + args := m.Called(ctx, serverAddr) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*gwsidecar.GwStatus), args.Error(1) +} + +type MockWorkerRouterClient struct { + mock.Mock +} + +func (m *MockWorkerRouterClient) UpdateEcmpRoutes(ctx context.Context, serverAddr string, ecmpUpdateInfo *router.UpdateEcmpInfo) error { + args := m.Called(ctx, serverAddr, ecmpUpdateInfo) + return args.Error(0) +} + +func (m *MockWorkerRouterClient) GetRouteInKernel(ctx context.Context, serverAddr string, sliceRouterConnCtx *router.GetRouteConfig) (*sidecar.VerifyRouteAddResponse, error) { + args := m.Called(ctx, serverAddr, sliceRouterConnCtx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*sidecar.VerifyRouteAddResponse), args.Error(1) +} + +var testRecyclerName = "test-recycler" +var testRecyclerNamespace = "kubeslice-system" +var testServerGwName = "test-slice-server-0" +var testClientGwName = "test-slice-client-0" + +var testWorkerSliceGwRecycler = &spokev1alpha1.WorkerSliceGwRecycler{ + ObjectMeta: metav1.ObjectMeta{ + Name: testRecyclerName, + Namespace: testRecyclerNamespace, + Labels: map[string]string{ + "slice_name": "test-slice", + "slicegw_name": testServerGwName, + }, + }, + Spec: spokev1alpha1.WorkerSliceGwRecyclerSpec{ + GwPair: spokev1alpha1.GwPair{ + ServerID: testServerGwName, + ClientID: testClientGwName, + }, + State: "init", + Request: "verify_new_deployment_created", + SliceGwServer: testServerGwName, + SliceGwClient: testClientGwName, + SliceName: "test-slice", + }, +} + +var testSliceGw = &kubeslicev1beta1.SliceGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: testServerGwName, + Namespace: testRecyclerNamespace, + }, + Spec: kubeslicev1beta1.SliceGatewaySpec{ + SliceName: "test-slice", + }, + Status: kubeslicev1beta1.SliceGatewayStatus{ + Config: kubeslicev1beta1.SliceGatewayConfig{ + SliceGatewayHostType: "Server", + SliceGatewayRemoteSubnet: "10.0.0.0/16", + }, + }, +} + +func TestReconcilerNotFound(t *testing.T) { + expected := struct { + ctx context.Context + req ctrl.Request + res ctrl.Result + err error + }{ + context.Background(), + ctrl.Request{NamespacedName: types.NamespacedName{Name: testRecyclerName, Namespace: testRecyclerNamespace}}, + ctrl.Result{}, + nil, + } + + client := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &Reconciler{ + Client: client, + MeshClient: client, + EventRecorder: &eventRecorder, + FSM: make(map[string]*fsm.FSM), + } + + recyclerKey := types.NamespacedName{Namespace: testRecyclerNamespace, Name: testRecyclerName} + + client.On("Get", + mock.Anything, + mock.IsType(recyclerKey), + mock.IsType(&spokev1alpha1.WorkerSliceGwRecycler{}), + ).Return(apierrors.NewNotFound(schema.GroupResource{Resource: "workerslicegwrecycler"}, testRecyclerName)) + + result, err := reconciler.Reconcile(expected.ctx, expected.req) + if expected.res != result { + t.Error("Expected response :", expected.res, " but got ", result) + } + if expected.err != err { + t.Error("Expected error:", expected.err, " but got ", err) + } +} + +func TestReconcilerServerInitState(t *testing.T) { + recycler := testWorkerSliceGwRecycler.DeepCopy() + recycler.Spec.State = ST_init + + expected := struct { + ctx context.Context + req ctrl.Request + res ctrl.Result + err error + }{ + context.Background(), + ctrl.Request{NamespacedName: types.NamespacedName{Name: testRecyclerName, Namespace: testRecyclerNamespace}}, + ctrl.Result{}, + nil, + } + + client := utilmock.NewClient() + meshClient := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &Reconciler{ + Client: client, + MeshClient: meshClient, + EventRecorder: &eventRecorder, + FSM: make(map[string]*fsm.FSM), + } + + recyclerKey := types.NamespacedName{Namespace: testRecyclerNamespace, Name: testRecyclerName} + + client.On("Get", + mock.Anything, + mock.IsType(recyclerKey), + mock.IsType(&spokev1alpha1.WorkerSliceGwRecycler{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*spokev1alpha1.WorkerSliceGwRecycler) + *arg = *recycler + }) + + meshClient.On("Get", + mock.Anything, + mock.IsType(types.NamespacedName{Namespace: testRecyclerNamespace, Name: testServerGwName}), + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*kubeslicev1beta1.SliceGateway) + *arg = *testSliceGw + }) + + meshClient.On("List", + mock.Anything, + mock.IsType(&appsv1.DeploymentList{}), + mock.IsType([]k8sclient.ListOption(nil)), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(1).(*appsv1.DeploymentList) + arg.Items = []appsv1.Deployment{} + }) + + meshClient.On("Get", + mock.Anything, + mock.IsType(types.NamespacedName{Namespace: testRecyclerNamespace, Name: testServerGwName}), + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*kubeslicev1beta1.SliceGateway) + *arg = *testSliceGw + }) + + meshClient.StatusMock.On("Update", + mock.Anything, + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + meshClient.StatusMock.On("Update", + mock.Anything, + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + mock.IsType([]k8sclient.SubResourceUpdateOption(nil)), + ).Return(nil) + + client.On("Get", + mock.Anything, + mock.IsType(recyclerKey), + mock.IsType(&spokev1alpha1.WorkerSliceGwRecycler{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*spokev1alpha1.WorkerSliceGwRecycler) + *arg = *recycler + }) + + client.On("Update", + mock.Anything, + mock.IsType(&spokev1alpha1.WorkerSliceGwRecycler{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + client.On("Create", + mock.Anything, + mock.IsType(&corev1.Event{}), + mock.IsType([]k8sclient.CreateOption(nil)), + ).Return(nil) + + result, err := reconciler.Reconcile(expected.ctx, expected.req) + if expected.res != result { + t.Error("Expected response :", expected.res, " but got ", result) + } + if expected.err != err { + t.Error("Expected error:", expected.err, " but got ", err) + } +} + +func TestReconcilerClientSide(t *testing.T) { + recycler := testWorkerSliceGwRecycler.DeepCopy() + recycler.Spec.State = ST_new_deployment_created + recycler.Spec.Request = getRequestString(REQ_create_new_deployment) + + clientSliceGw := testSliceGw.DeepCopy() + clientSliceGw.Name = testClientGwName + clientSliceGw.Status.Config.SliceGatewayHostType = "Client" + + expected := struct { + ctx context.Context + req ctrl.Request + res ctrl.Result + err error + }{ + context.Background(), + ctrl.Request{NamespacedName: types.NamespacedName{Name: testRecyclerName, Namespace: testRecyclerNamespace}}, + ctrl.Result{}, + nil, + } + + client := utilmock.NewClient() + meshClient := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &Reconciler{ + Client: client, + MeshClient: meshClient, + EventRecorder: &eventRecorder, + FSM: make(map[string]*fsm.FSM), + } + + recyclerKey := types.NamespacedName{Namespace: testRecyclerNamespace, Name: testRecyclerName} + + client.On("Get", + mock.Anything, + mock.IsType(recyclerKey), + mock.IsType(&spokev1alpha1.WorkerSliceGwRecycler{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*spokev1alpha1.WorkerSliceGwRecycler) + *arg = *recycler + }) + + meshClient.On("Get", + mock.Anything, + mock.IsType(types.NamespacedName{Namespace: testRecyclerNamespace, Name: testServerGwName}), + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + ).Return(apierrors.NewNotFound(schema.GroupResource{Resource: "slicegateway"}, testServerGwName)) + + meshClient.On("Get", + mock.Anything, + mock.IsType(types.NamespacedName{Namespace: testRecyclerNamespace, Name: testClientGwName}), + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*kubeslicev1beta1.SliceGateway) + *arg = *clientSliceGw + }) + + meshClient.On("List", + mock.Anything, + mock.IsType(&appsv1.DeploymentList{}), + mock.IsType([]k8sclient.ListOption(nil)), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(1).(*appsv1.DeploymentList) + arg.Items = []appsv1.Deployment{} + }) + + meshClient.On("Get", + mock.Anything, + mock.IsType(types.NamespacedName{Namespace: testRecyclerNamespace, Name: testClientGwName}), + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*kubeslicev1beta1.SliceGateway) + *arg = *clientSliceGw + }) + + meshClient.StatusMock.On("Update", + mock.Anything, + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + meshClient.StatusMock.On("Update", + mock.Anything, + mock.IsType(&kubeslicev1beta1.SliceGateway{}), + mock.IsType([]k8sclient.SubResourceUpdateOption(nil)), + ).Return(nil) + + client.On("Get", + mock.Anything, + mock.IsType(recyclerKey), + mock.IsType(&spokev1alpha1.WorkerSliceGwRecycler{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*spokev1alpha1.WorkerSliceGwRecycler) + *arg = *recycler + }) + + client.StatusMock.On("Update", + mock.Anything, + mock.IsType(&spokev1alpha1.WorkerSliceGwRecycler{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + client.StatusMock.On("Update", + mock.Anything, + mock.IsType(&spokev1alpha1.WorkerSliceGwRecycler{}), + mock.IsType([]k8sclient.SubResourceUpdateOption(nil)), + ).Return(nil) + + client.On("Create", + mock.Anything, + mock.IsType(&corev1.Event{}), + mock.IsType([]k8sclient.CreateOption(nil)), + ).Return(nil) + + result, err := reconciler.Reconcile(expected.ctx, expected.req) + if expected.res != result { + t.Error("Expected response :", expected.res, " but got ", result) + } + if expected.err != err { + t.Error("Expected error:", expected.err, " but got ", err) + } +} + +func TestReconcilerGetError(t *testing.T) { + expected := struct { + ctx context.Context + req ctrl.Request + res ctrl.Result + errMsg string + }{ + context.Background(), + ctrl.Request{NamespacedName: types.NamespacedName{Name: testRecyclerName, Namespace: testRecyclerNamespace}}, + ctrl.Result{}, + "internal error", + } + + client := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &Reconciler{ + Client: client, + MeshClient: client, + EventRecorder: &eventRecorder, + FSM: make(map[string]*fsm.FSM), + } + + recyclerKey := types.NamespacedName{Namespace: testRecyclerNamespace, Name: testRecyclerName} + + client.On("Get", + mock.Anything, + mock.IsType(recyclerKey), + mock.IsType(&spokev1alpha1.WorkerSliceGwRecycler{}), + ).Return(errors.New("internal error")) + + _, err := reconciler.Reconcile(expected.ctx, expected.req) + if err == nil || expected.errMsg != err.Error() { + t.Error("Expected error:", expected.errMsg, " but got ", err) + } +} + +func TestGetUniqueIdentifier(t *testing.T) { + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test", Namespace: "ns"}} + id := getUniqueIdentifier(req) + if id == "" { + t.Error("Expected non-empty identifier") + } +} diff --git a/pkg/hub/controllers/workerslicegwrecycler/utils_unit_test.go b/pkg/hub/controllers/workerslicegwrecycler/utils_unit_test.go new file mode 100644 index 000000000..8a266cc99 --- /dev/null +++ b/pkg/hub/controllers/workerslicegwrecycler/utils_unit_test.go @@ -0,0 +1,126 @@ +package workerslicegwrecycler + +import ( + "testing" +) + +func TestGetNewDeploymentName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"test-slice-gw-0", "test-slice-gw-1"}, + {"test-slice-gw-1", "test-slice-gw-0"}, + {"another-gw-0", "another-gw-1"}, + {"another-gw-1", "another-gw-0"}, + } + + for _, test := range tests { + result := getNewDeploymentName(test.input) + if result != test.expected { + t.Errorf("For input %s, expected %s but got %s", test.input, test.expected, result) + } + } +} + +func TestGetRequestString(t *testing.T) { + tests := []struct { + input Request + expected string + }{ + {REQ_none, "none"}, + {REQ_create_new_deployment, "create_new_deployment"}, + {REQ_update_routing_table, "update_routing_table"}, + {REQ_delete_old_gw_deployment, "delete_old_gw_deployment"}, + {REQ_invalid, ""}, + } + + for _, test := range tests { + result := getRequestString(test.input) + if result != test.expected { + t.Errorf("For input %v, expected %s but got %s", test.input, test.expected, result) + } + } +} + +func TestGetRequestIndex(t *testing.T) { + tests := []struct { + input string + expected Request + }{ + {"none", REQ_none}, + {"create_new_deployment", REQ_create_new_deployment}, + {"update_routing_table", REQ_update_routing_table}, + {"delete_old_gw_deployment", REQ_delete_old_gw_deployment}, + {"unknown", REQ_invalid}, + } + + for _, test := range tests { + result := getRequestIndex(test.input) + if result != test.expected { + t.Errorf("For input %s, expected %v but got %v", test.input, test.expected, result) + } + } +} + +func TestGetResponseString(t *testing.T) { + tests := []struct { + input Response + expected string + }{ + {RESP_none, "none"}, + {RESP_new_deployment_created, "new_deployment_created"}, + {RESP_routing_table_updated, "routing_table_updated"}, + {RESP_old_deployment_deleted, "old_gw_deployment_deleted"}, + {RESP_invalid, ""}, + } + + for _, test := range tests { + result := getResponseString(test.input) + if result != test.expected { + t.Errorf("For input %v, expected %s but got %s", test.input, test.expected, result) + } + } +} + +func TestGetResponseIndex(t *testing.T) { + tests := []struct { + input string + expected Response + }{ + {"none", RESP_none}, + {"new_deployment_created", RESP_new_deployment_created}, + {"routing_table_updated", RESP_routing_table_updated}, + {"old_gw_deployment_deleted", RESP_old_deployment_deleted}, + {"unknown", RESP_invalid}, + } + + for _, test := range tests { + result := getResponseIndex(test.input) + if result != test.expected { + t.Errorf("For input %s, expected %v but got %v", test.input, test.expected, result) + } + } +} + +func TestRequestResponseRoundTrip(t *testing.T) { + // Test that converting Request to string and back gives the same result + requests := []Request{REQ_none, REQ_create_new_deployment, REQ_update_routing_table, REQ_delete_old_gw_deployment} + for _, req := range requests { + str := getRequestString(req) + result := getRequestIndex(str) + if result != req { + t.Errorf("Request round trip failed: %v -> %s -> %v", req, str, result) + } + } + + // Test that converting Response to string and back gives the same result + responses := []Response{RESP_none, RESP_new_deployment_created, RESP_routing_table_updated, RESP_old_deployment_deleted} + for _, resp := range responses { + str := getResponseString(resp) + result := getResponseIndex(str) + if result != resp { + t.Errorf("Response round trip failed: %v -> %s -> %v", resp, str, result) + } + } +} diff --git a/pkg/hub/hubclient/hubclient_unit_test.go b/pkg/hub/hubclient/hubclient_unit_test.go new file mode 100644 index 000000000..a2b8cdf40 --- /dev/null +++ b/pkg/hub/hubclient/hubclient_unit_test.go @@ -0,0 +1,549 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 hub + +import ( + "context" + "testing" + + hubv1alpha1 "github.com/kubeslice/apis/pkg/controller/v1alpha1" + spokev1alpha1 "github.com/kubeslice/apis/pkg/worker/v1alpha1" + kubeslicev1beta1 "github.com/kubeslice/worker-operator/api/v1beta1" + "github.com/kubeslice/worker-operator/pkg/monitoring" + "go.uber.org/zap" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +var testProjectNs = "kubeslice-avesha" +var testClusterName = "test-cluster-1" +var testSliceName = "test-slice" + +func newTestHubClient(scheme *runtime.Scheme, objs ...client.Object) *HubClientConfig { + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build() + return &HubClientConfig{ + Client: client, + eventRecorder: &monitoring.EventRecorder{ + Client: client, + Scheme: scheme, + Logger: zap.NewNop().Sugar(), + }, + } +} + +func TestUpdateNodePortForSliceGwServer(t *testing.T) { + sliceGw := &spokev1alpha1.WorkerSliceGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-slice-gw", + Namespace: testProjectNs, + }, + Spec: spokev1alpha1.WorkerSliceGatewaySpec{ + LocalGatewayConfig: spokev1alpha1.SliceGatewayConfig{ + NodePorts: []int{30001, 30002}, + }, + }, + } + + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = spokev1alpha1.AddToScheme(scheme) + _ = hubv1alpha1.AddToScheme(scheme) + _ = kubeslicev1beta1.AddToScheme(scheme) + + ProjectNamespace = testProjectNs + hubClient := newTestHubClient(scheme, sliceGw) + + ctx := context.Background() + + err := hubClient.UpdateNodePortForSliceGwServer(ctx, []int{30003, 30004}, "test-slice-gw") + if err != nil { + t.Error("Expected no error but got:", err) + } + + // Verify the update + updated := &spokev1alpha1.WorkerSliceGateway{} + err = hubClient.Get(ctx, types.NamespacedName{Name: "test-slice-gw", Namespace: testProjectNs}, updated) + if err != nil { + t.Error("Failed to get updated gateway:", err) + } + if len(updated.Spec.LocalGatewayConfig.NodePorts) != 2 || + updated.Spec.LocalGatewayConfig.NodePorts[0] != 30003 || + updated.Spec.LocalGatewayConfig.NodePorts[1] != 30004 { + t.Error("NodePorts were not updated correctly") + } +} + +func TestUpdateNodePortForSliceGwServerNoUpdate(t *testing.T) { + sliceGw := &spokev1alpha1.WorkerSliceGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-slice-gw", + Namespace: testProjectNs, + }, + Spec: spokev1alpha1.WorkerSliceGatewaySpec{ + LocalGatewayConfig: spokev1alpha1.SliceGatewayConfig{ + NodePorts: []int{30001, 30002}, + }, + }, + } + + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = spokev1alpha1.AddToScheme(scheme) + _ = hubv1alpha1.AddToScheme(scheme) + _ = kubeslicev1beta1.AddToScheme(scheme) + + ProjectNamespace = testProjectNs + hubClient := newTestHubClient(scheme, sliceGw) + + ctx := context.Background() + + // Should not call Update when NodePorts are the same + err := hubClient.UpdateNodePortForSliceGwServer(ctx, []int{30001, 30002}, "test-slice-gw") + if err != nil { + t.Error("Expected no error but got:", err) + } +} + +func TestUpdateLBIPsForSliceGwServer(t *testing.T) { + sliceGw := &spokev1alpha1.WorkerSliceGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-slice-gw", + Namespace: testProjectNs, + }, + Spec: spokev1alpha1.WorkerSliceGatewaySpec{ + LocalGatewayConfig: spokev1alpha1.SliceGatewayConfig{ + LoadBalancerIps: []string{"192.168.1.1"}, + }, + }, + } + + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = spokev1alpha1.AddToScheme(scheme) + _ = hubv1alpha1.AddToScheme(scheme) + _ = kubeslicev1beta1.AddToScheme(scheme) + + ProjectNamespace = testProjectNs + hubClient := newTestHubClient(scheme, sliceGw) + + ctx := context.Background() + + err := hubClient.UpdateLBIPsForSliceGwServer(ctx, []string{"192.168.1.2", "192.168.1.3"}, "test-slice-gw") + if err != nil { + t.Error("Expected no error but got:", err) + } + + // Verify the update + updated := &spokev1alpha1.WorkerSliceGateway{} + err = hubClient.Get(ctx, types.NamespacedName{Name: "test-slice-gw", Namespace: testProjectNs}, updated) + if err != nil { + t.Error("Failed to get updated gateway:", err) + } + if len(updated.Spec.LocalGatewayConfig.LoadBalancerIps) != 2 || + updated.Spec.LocalGatewayConfig.LoadBalancerIps[0] != "192.168.1.2" || + updated.Spec.LocalGatewayConfig.LoadBalancerIps[1] != "192.168.1.3" { + t.Error("LoadBalancerIps were not updated correctly") + } +} + +func TestCreateWorkerSliceGwRecycler(t *testing.T) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = spokev1alpha1.AddToScheme(scheme) + _ = hubv1alpha1.AddToScheme(scheme) + _ = kubeslicev1beta1.AddToScheme(scheme) + + ProjectNamespace = testProjectNs + hubClient := newTestHubClient(scheme) + + ctx := context.Background() + recyclerName := "test-recycler" + + err := hubClient.CreateWorkerSliceGwRecycler(ctx, recyclerName, "client-gw", "server-gw", "server-slice-gw", "client-slice-gw", testSliceName) + if err != nil { + t.Error("Expected no error but got:", err) + } + + // Verify the recycler was created + recycler := &spokev1alpha1.WorkerSliceGwRecycler{} + err = hubClient.Get(ctx, types.NamespacedName{Name: recyclerName, Namespace: testProjectNs}, recycler) + if err != nil { + t.Error("Failed to get created recycler:", err) + } +} + +func TestCreateWorkerSliceGwRecyclerAlreadyExists(t *testing.T) { + recycler := &spokev1alpha1.WorkerSliceGwRecycler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-recycler", + Namespace: testProjectNs, + }, + } + + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = spokev1alpha1.AddToScheme(scheme) + _ = hubv1alpha1.AddToScheme(scheme) + _ = kubeslicev1beta1.AddToScheme(scheme) + + ProjectNamespace = testProjectNs + hubClient := newTestHubClient(scheme, recycler) + + ctx := context.Background() + recyclerName := "test-recycler" + + err := hubClient.CreateWorkerSliceGwRecycler(ctx, recyclerName, "client-gw", "server-gw", "server-slice-gw", "client-slice-gw", testSliceName) + if err != nil { + t.Error("Expected no error but got:", err) + } +} + +func TestDeleteWorkerSliceGwRecycler(t *testing.T) { + recycler := &spokev1alpha1.WorkerSliceGwRecycler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-recycler", + Namespace: testProjectNs, + }, + } + + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = spokev1alpha1.AddToScheme(scheme) + _ = hubv1alpha1.AddToScheme(scheme) + _ = kubeslicev1beta1.AddToScheme(scheme) + + ProjectNamespace = testProjectNs + hubClient := newTestHubClient(scheme, recycler) + + ctx := context.Background() + recyclerName := "test-recycler" + + err := hubClient.DeleteWorkerSliceGwRecycler(ctx, recyclerName) + if err != nil { + t.Error("Expected no error but got:", err) + } + + // Verify the recycler was deleted + deletedRecycler := &spokev1alpha1.WorkerSliceGwRecycler{} + err = hubClient.Get(ctx, types.NamespacedName{Name: recyclerName, Namespace: testProjectNs}, deletedRecycler) + if !apierrors.IsNotFound(err) { + t.Error("Expected recycler to be deleted") + } +} + +func TestDeleteWorkerSliceGwRecyclerNotFound(t *testing.T) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = spokev1alpha1.AddToScheme(scheme) + _ = hubv1alpha1.AddToScheme(scheme) + _ = kubeslicev1beta1.AddToScheme(scheme) + + ProjectNamespace = testProjectNs + hubClient := newTestHubClient(scheme) + + ctx := context.Background() + recyclerName := "test-recycler" + + err := hubClient.DeleteWorkerSliceGwRecycler(ctx, recyclerName) + if err != nil { + t.Error("Expected no error but got:", err) + } +} + +func TestUpdateServiceExport(t *testing.T) { + serviceExport := &kubeslicev1beta1.ServiceExport{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "app-namespace", + }, + Spec: kubeslicev1beta1.ServiceExportSpec{ + Slice: testSliceName, + Ports: []kubeslicev1beta1.ServicePort{ + { + Name: "http", + ContainerPort: 8080, + Protocol: "TCP", + ServicePort: 80, + ServiceProtocol: gwapiv1.ProtocolType("http"), + }, + }, + Aliases: []string{"test.example.com"}, + }, + Status: kubeslicev1beta1.ServiceExportStatus{ + Pods: []kubeslicev1beta1.ServicePod{ + { + Name: "test-pod", + NsmIP: "10.0.0.1", + DNSName: "test-pod.app-namespace.svc.cluster.local", + }, + }, + }, + } + + scheme := runtime.NewScheme() + _ = hubv1alpha1.AddToScheme(scheme) + _ = kubeslicev1beta1.AddToScheme(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).Build() + + ClusterName = testClusterName + ProjectNamespace = testProjectNs + hubClient := &HubClientConfig{ + Client: client, + } + + ctx := context.Background() + hubSvcExName := "test-service-app-namespace-" + testClusterName + + err := hubClient.UpdateServiceExport(ctx, serviceExport) + if err != nil { + t.Error("Expected no error but got:", err) + } + + // Verify the ServiceExportConfig was created + created := &hubv1alpha1.ServiceExportConfig{} + err = client.Get(ctx, types.NamespacedName{Name: hubSvcExName, Namespace: testProjectNs}, created) + if err != nil { + t.Error("Failed to get created ServiceExportConfig:", err) + } +} + +func TestUpdateServiceExportUpdate(t *testing.T) { + serviceExport := &kubeslicev1beta1.ServiceExport{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "app-namespace", + }, + Spec: kubeslicev1beta1.ServiceExportSpec{ + Slice: testSliceName, + Ports: []kubeslicev1beta1.ServicePort{ + { + Name: "http", + ContainerPort: 8080, + Protocol: "TCP", + ServicePort: 80, + ServiceProtocol: gwapiv1.ProtocolType("http"), + }, + }, + Aliases: []string{"test.example.com"}, + }, + Status: kubeslicev1beta1.ServiceExportStatus{ + Pods: []kubeslicev1beta1.ServicePod{ + { + Name: "test-pod", + NsmIP: "10.0.0.1", + DNSName: "test-pod.app-namespace.svc.cluster.local", + }, + }, + }, + } + + existingHubSvcEx := &hubv1alpha1.ServiceExportConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service-app-namespace-" + testClusterName, + Namespace: testProjectNs, + }, + Spec: hubv1alpha1.ServiceExportConfigSpec{ + ServiceName: "test-service", + ServiceNamespace: "app-namespace", + SourceCluster: testClusterName, + SliceName: testSliceName, + }, + } + + scheme := runtime.NewScheme() + _ = hubv1alpha1.AddToScheme(scheme) + _ = kubeslicev1beta1.AddToScheme(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existingHubSvcEx).Build() + + ClusterName = testClusterName + ProjectNamespace = testProjectNs + hubClient := &HubClientConfig{ + Client: client, + } + + ctx := context.Background() + + err := hubClient.UpdateServiceExport(ctx, serviceExport) + if err != nil { + t.Error("Expected no error but got:", err) + } +} + +func TestUpdateAppNamespaces(t *testing.T) { + workerSliceConfig := &spokev1alpha1.WorkerSliceConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-slice-config", + Namespace: testProjectNs, + }, + Status: spokev1alpha1.WorkerSliceConfigStatus{}, + } + + scheme := runtime.NewScheme() + _ = spokev1alpha1.AddToScheme(scheme) + _ = hubv1alpha1.AddToScheme(scheme) + _ = kubeslicev1beta1.AddToScheme(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(workerSliceConfig).WithStatusSubresource(workerSliceConfig).Build() + + ProjectNamespace = testProjectNs + hubClient := &HubClientConfig{ + Client: client, + } + + ctx := context.Background() + namespaces := []string{"ns1", "ns2", "ns3"} + + err := hubClient.UpdateAppNamespaces(ctx, "test-slice-config", namespaces) + if err != nil { + t.Error("Expected no error but got:", err) + } + + // Verify the namespaces were updated + updated := &spokev1alpha1.WorkerSliceConfig{} + err = client.Get(ctx, types.NamespacedName{Name: "test-slice-config", Namespace: testProjectNs}, updated) + if err != nil { + t.Error("Failed to get updated WorkerSliceConfig:", err) + } +} + +func TestUpdateAppPodsList(t *testing.T) { + workerSliceConfig := &spokev1alpha1.WorkerSliceConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-slice-config", + Namespace: testProjectNs, + }, + Status: spokev1alpha1.WorkerSliceConfigStatus{}, + } + + appPods := []kubeslicev1beta1.AppPod{ + { + PodName: "test-pod-1", + PodNamespace: "app-ns", + PodIP: "10.0.0.1", + NsmIP: "10.1.0.1", + NsmInterface: "nsm0", + }, + { + PodName: "test-pod-2", + PodNamespace: "app-ns", + PodIP: "10.0.0.2", + NsmIP: "10.1.0.2", + NsmInterface: "nsm0", + }, + } + + scheme := runtime.NewScheme() + _ = spokev1alpha1.AddToScheme(scheme) + _ = hubv1alpha1.AddToScheme(scheme) + _ = kubeslicev1beta1.AddToScheme(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(workerSliceConfig).WithStatusSubresource(workerSliceConfig).Build() + + ProjectNamespace = testProjectNs + hubClient := &HubClientConfig{ + Client: client, + } + + ctx := context.Background() + + err := hubClient.UpdateAppPodsList(ctx, "test-slice-config", appPods) + if err != nil { + t.Error("Expected no error but got:", err) + } + + // Verify the app pods were updated + updated := &spokev1alpha1.WorkerSliceConfig{} + err = client.Get(ctx, types.NamespacedName{Name: "test-slice-config", Namespace: testProjectNs}, updated) + if err != nil { + t.Error("Failed to get updated WorkerSliceConfig:", err) + } +} + +func TestContains(t *testing.T) { + tests := []struct { + slice []string + str string + expected bool + }{ + {[]string{"a", "b", "c"}, "b", true}, + {[]string{"a", "b", "c"}, "d", false}, + {[]string{}, "a", false}, + } + + for _, test := range tests { + result := contains(test.slice, test.str) + if result != test.expected { + t.Errorf("For slice %v and string %s, expected %v but got %v", + test.slice, test.str, test.expected, result) + } + } +} + +func TestPartialContains(t *testing.T) { + tests := []struct { + slice []string + str string + expected bool + }{ + {[]string{"kube", "system"}, "kubernetes", true}, + {[]string{"slice", "gw"}, "slicegateway", true}, + {[]string{"foo", "bar"}, "baz", false}, + {[]string{}, "test", false}, + } + + for _, test := range tests { + result := partialContains(test.slice, test.str) + if result != test.expected { + t.Errorf("For slice %v and string %s, expected %v but got %v", + test.slice, test.str, test.expected, result) + } + } +} + +func TestFilterLabelsAndAnnotations(t *testing.T) { + input := map[string]string{ + "app": "myapp", + "kubeslice-test": "value1", + "kubernetes.io/hostname": "node1", + "custom-label": "custom-value", + } + + result := filterLabelsAndAnnotations(input) + + // Should filter out keys containing "kubeslice-" and "kubernetes.io" + if _, exists := result["kubeslice-test"]; exists { + t.Error("Expected kubeslice-test to be filtered out") + } + + if _, exists := result["kubernetes.io/hostname"]; exists { + t.Error("Expected kubernetes.io/hostname to be filtered out") + } + + if _, exists := result["app"]; !exists { + t.Error("Expected app label to be present") + } + + if _, exists := result["custom-label"]; !exists { + t.Error("Expected custom-label to be present") + } +} diff --git a/pkg/hub/manager/manager_test.go b/pkg/hub/manager/manager_test.go new file mode 100644 index 000000000..e49b600f7 --- /dev/null +++ b/pkg/hub/manager/manager_test.go @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 manager + +import ( + "testing" + + hubv1alpha1 "github.com/kubeslice/apis/pkg/controller/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestShouldProcessVpnKeyRotation(t *testing.T) { + originalClusterName := ClusterName + defer func() { ClusterName = originalClusterName }() + ClusterName = "test-cluster" + + tests := []struct { + name string + vpn *hubv1alpha1.VpnKeyRotation + expected bool + }{ + { + name: "cluster in rotation list", + vpn: &hubv1alpha1.VpnKeyRotation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vpn-rotation-1", + }, + Spec: hubv1alpha1.VpnKeyRotationSpec{ + Clusters: []string{"test-cluster", "other-cluster"}, + }, + }, + expected: true, + }, + { + name: "cluster not in rotation list", + vpn: &hubv1alpha1.VpnKeyRotation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vpn-rotation-2", + }, + Spec: hubv1alpha1.VpnKeyRotationSpec{ + Clusters: []string{"other-cluster", "another-cluster"}, + }, + }, + expected: false, + }, + { + name: "empty cluster list", + vpn: &hubv1alpha1.VpnKeyRotation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vpn-rotation-3", + }, + Spec: hubv1alpha1.VpnKeyRotationSpec{ + Clusters: []string{}, + }, + }, + expected: false, + }, + { + name: "nil cluster list", + vpn: &hubv1alpha1.VpnKeyRotation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vpn-rotation-4", + }, + Spec: hubv1alpha1.VpnKeyRotationSpec{ + Clusters: nil, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := shouldProcessVpnKeyRotation(tt.vpn) + if result != tt.expected { + t.Errorf("shouldProcessVpnKeyRotation() = %v, want %v", result, tt.expected) + } + }) + } +} diff --git a/pkg/hub/utils_test.go b/pkg/hub/utils_test.go new file mode 100644 index 000000000..1edbdf040 --- /dev/null +++ b/pkg/hub/utils_test.go @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 hubutils + +import "testing" + +func TestListContains(t *testing.T) { + tests := []struct { + name string + list []string + val string + want bool + }{ + { + name: "element exists", + list: []string{"a", "b", "c"}, + val: "b", + want: true, + }, + { + name: "element does not exist", + list: []string{"a", "b", "c"}, + val: "d", + want: false, + }, + { + name: "empty list", + list: []string{}, + val: "a", + want: false, + }, + { + name: "nil list", + list: nil, + val: "a", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ListContains(tt.list, tt.val); got != tt.want { + t.Errorf("ListContains() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestListContains_Int(t *testing.T) { + tests := []struct { + name string + list []int + val int + want bool + }{ + { + name: "element exists", + list: []int{1, 2, 3}, + val: 2, + want: true, + }, + { + name: "element does not exist", + list: []int{1, 2, 3}, + val: 4, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ListContains(tt.list, tt.val); got != tt.want { + t.Errorf("ListContains() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestListEqual(t *testing.T) { + tests := []struct { + name string + l1 []string + l2 []string + want bool + }{ + { + name: "equal lists", + l1: []string{"a", "b", "c"}, + l2: []string{"a", "b", "c"}, + want: true, + }, + { + name: "equal lists different order", + l1: []string{"a", "b", "c"}, + l2: []string{"c", "a", "b"}, + want: true, + }, + { + name: "different lengths", + l1: []string{"a", "b"}, + l2: []string{"a", "b", "c"}, + want: false, + }, + { + name: "different elements", + l1: []string{"a", "b", "c"}, + l2: []string{"a", "b", "d"}, + want: false, + }, + { + name: "both empty", + l1: []string{}, + l2: []string{}, + want: true, + }, + { + name: "both nil", + l1: nil, + l2: nil, + want: true, + }, + { + name: "one empty one nil", + l1: []string{}, + l2: nil, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ListEqual(tt.l1, tt.l2); got != tt.want { + t.Errorf("ListEqual() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/logger/logger_unit_test.go b/pkg/logger/logger_unit_test.go new file mode 100644 index 000000000..bcb60af15 --- /dev/null +++ b/pkg/logger/logger_unit_test.go @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 logger + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewLogger(t *testing.T) { + tests := []struct { + name string + logLevel string + }{ + { + name: "create logger with DEBUG level", + logLevel: "DEBUG", + }, + { + name: "create logger with INFO level", + logLevel: "INFO", + }, + { + name: "create logger with WARNING level", + logLevel: "WARNING", + }, + { + name: "create logger with ERROR level", + logLevel: "ERROR", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.logLevel != "" { + os.Setenv("LOG_LEVEL", tt.logLevel) + defer os.Unsetenv("LOG_LEVEL") + } + + logger := NewLogger() + assert.NotNil(t, logger) + }) + } +} + +func TestNewWrappedLogger(t *testing.T) { + tests := []struct { + name string + }{ + { + name: "create wrapped logger", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger := NewWrappedLogger() + assert.NotNil(t, logger) + }) + } +} + +func TestWithLogger(t *testing.T) { + tests := []struct { + name string + }{ + { + name: "add logger to context", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger := NewWrappedLogger() + ctx := context.Background() + + newCtx := WithLogger(ctx, logger) + assert.NotNil(t, newCtx) + + retrievedLogger := FromContext(newCtx) + assert.NotNil(t, retrievedLogger) + }) + } +} + +func TestFromContext(t *testing.T) { + tests := []struct { + name string + setupContext func() context.Context + expectDefaultLog bool + }{ + { + name: "get logger from context", + setupContext: func() context.Context { + logger := NewWrappedLogger() + return WithLogger(context.Background(), logger) + }, + expectDefaultLog: false, + }, + { + name: "get default logger when not in context", + setupContext: func() context.Context { + return context.Background() + }, + expectDefaultLog: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := tt.setupContext() + logger := FromContext(ctx) + assert.NotNil(t, logger) + }) + } +} + +func TestLogLevelSeverity(t *testing.T) { + tests := []struct { + name string + logLevel string + setup func() + }{ + { + name: "default log level INFO when not set", + logLevel: "", + setup: func() { + os.Unsetenv("LOG_LEVEL") + }, + }, + { + name: "use DEBUG log level", + logLevel: "DEBUG", + setup: func() { + os.Setenv("LOG_LEVEL", "DEBUG") + }, + }, + { + name: "use ERROR log level", + logLevel: "ERROR", + setup: func() { + os.Setenv("LOG_LEVEL", "ERROR") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setup() + defer os.Unsetenv("LOG_LEVEL") + + logger := NewLogger() + assert.NotNil(t, logger) + }) + } +} + +func TestLoggerWithClusterName(t *testing.T) { + tests := []struct { + name string + clusterName string + }{ + { + name: "logger with cluster name set", + clusterName: "test-cluster", + }, + { + name: "logger without cluster name", + clusterName: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.clusterName != "" { + os.Setenv("CLUSTER_NAME", tt.clusterName) + defer os.Unsetenv("CLUSTER_NAME") + } else { + os.Unsetenv("CLUSTER_NAME") + } + + logger := NewLogger() + assert.NotNil(t, logger) + }) + } +} diff --git a/pkg/manifest/file_unit_test.go b/pkg/manifest/file_unit_test.go new file mode 100644 index 000000000..27c87167b --- /dev/null +++ b/pkg/manifest/file_unit_test.go @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 manifest + +import ( + "encoding/json" + "io/ioutil" + "os" + "path" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetManifestPath(t *testing.T) { + tests := []struct { + name string + file string + manifestPath string + expected string + }{ + { + name: "with MANIFEST_PATH set", + file: "test-file", + manifestPath: "/custom/path", + expected: "/custom/path/test-file.json", + }, + { + name: "without MANIFEST_PATH set", + file: "test-file", + manifestPath: "", + expected: "../../files/manifests/test-file.json", + }, + { + name: "with different file name", + file: "ingress-deploy", + manifestPath: "/another/path", + expected: "/another/path/ingress-deploy.json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.manifestPath != "" { + os.Setenv("MANIFEST_PATH", tt.manifestPath) + defer os.Unsetenv("MANIFEST_PATH") + } else { + os.Unsetenv("MANIFEST_PATH") + } + + result := GetManifestPath(tt.file) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestNewManifest(t *testing.T) { + tests := []struct { + name string + file string + templates map[string]string + }{ + { + name: "create manifest with templates", + file: "test-file", + templates: map[string]string{ + "SLICE": "test-slice", + "IMAGE": "test-image", + }, + }, + { + name: "create manifest without templates", + file: "test-file", + templates: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NewManifest(tt.file, tt.templates) + assert.NotNil(t, result) + assert.Equal(t, GetManifestPath(tt.file), result.Path) + assert.Equal(t, tt.templates, result.Templates) + }) + } +} + +func TestManifestParse(t *testing.T) { + tests := []struct { + name string + fileContent string + templates map[string]string + expectedError bool + }{ + { + name: "parse valid JSON with template", + fileContent: `{"name": "SLICE-deployment", "replicas": 3}`, + templates: map[string]string{ + "SLICE": "test-slice", + }, + expectedError: false, + }, + { + name: "parse JSON with multiple templates", + fileContent: `{"name": "SLICE-deployment", "image": "IMAGE"}`, + templates: map[string]string{ + "SLICE": "test-slice", + "IMAGE": "test-image", + }, + expectedError: false, + }, + { + name: "parse invalid JSON", + fileContent: `{invalid json}`, + templates: map[string]string{}, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "manifest-test") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + + tmpFile := path.Join(tmpDir, "test.json") + err = ioutil.WriteFile(tmpFile, []byte(tt.fileContent), 0644) + assert.NoError(t, err) + + m := &Manifest{ + Path: tmpFile, + Templates: tt.templates, + } + + var result map[string]interface{} + err = m.Parse(&result) + + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + } + }) + } +} + +func TestManifestParseFileNotFound(t *testing.T) { + m := &Manifest{ + Path: "/non/existent/path/file.json", + Templates: map[string]string{}, + } + + var result map[string]interface{} + err := m.Parse(&result) + assert.Error(t, err) +} + +func TestManifestParseTemplateReplacement(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "manifest-test") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + + fileContent := `{"name": "SLICE-deployment", "namespace": "NAMESPACE", "replicas": 3}` + tmpFile := path.Join(tmpDir, "test.json") + err = ioutil.WriteFile(tmpFile, []byte(fileContent), 0644) + assert.NoError(t, err) + + templates := map[string]string{ + "SLICE": "my-slice", + "NAMESPACE": "my-namespace", + } + + m := &Manifest{ + Path: tmpFile, + Templates: templates, + } + + var result map[string]interface{} + err = m.Parse(&result) + assert.NoError(t, err) + + assert.Equal(t, "my-slice-deployment", result["name"]) + assert.Equal(t, "my-namespace", result["namespace"]) + assert.Equal(t, json.Number("3"), result["replicas"]) +} diff --git a/pkg/manifest/ingress_egress_unit_test.go b/pkg/manifest/ingress_egress_unit_test.go new file mode 100644 index 000000000..dde26413a --- /dev/null +++ b/pkg/manifest/ingress_egress_unit_test.go @@ -0,0 +1,372 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 manifest + +import ( + "context" + "errors" + "os" + "testing" + + kubeslicev1beta1 "github.com/kubeslice/worker-operator/api/v1beta1" + utilmock "github.com/kubeslice/worker-operator/pkg/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestUninstallIngress(t *testing.T) { + tests := []struct { + name string + sliceName string + setupMock func(*utilmock.MockClient) + expectedError bool + }{ + { + name: "successfully uninstall all resources", + sliceName: "test-slice", + setupMock: func(mc *utilmock.MockClient) { + mc.On("Delete", + mock.Anything, + mock.IsType(&appsv1.Deployment{}), + mock.Anything, + ).Return(nil) + mc.On("Delete", + mock.Anything, + mock.IsType(&corev1.Service{}), + mock.Anything, + ).Return(nil) + mc.On("Delete", + mock.Anything, + mock.IsType(&rbacv1.Role{}), + mock.Anything, + ).Return(nil) + mc.On("Delete", + mock.Anything, + mock.IsType(&corev1.ServiceAccount{}), + mock.Anything, + ).Return(nil) + mc.On("Delete", + mock.Anything, + mock.IsType(&rbacv1.RoleBinding{}), + mock.Anything, + ).Return(nil) + mc.On("Delete", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(nil) + }, + expectedError: false, + }, + { + name: "resources already deleted (not found)", + sliceName: "test-slice", + setupMock: func(mc *utilmock.MockClient) { + notFoundErr := kerrors.NewNotFound(schema.GroupResource{}, "resource") + mc.On("Delete", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(notFoundErr) + }, + expectedError: false, + }, + { + name: "error deleting resource", + sliceName: "test-slice", + setupMock: func(mc *utilmock.MockClient) { + mc.On("Delete", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(errors.New("delete failed")) + }, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := utilmock.NewClient() + tt.setupMock(mockClient) + + err := UninstallIngress(context.Background(), mockClient, tt.sliceName) + + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestUninstallEgress(t *testing.T) { + tests := []struct { + name string + sliceName string + setupMock func(*utilmock.MockClient) + expectedError bool + }{ + { + name: "successfully uninstall all resources", + sliceName: "test-slice", + setupMock: func(mc *utilmock.MockClient) { + mc.On("Delete", + mock.Anything, + mock.IsType(&appsv1.Deployment{}), + mock.Anything, + ).Return(nil) + mc.On("Delete", + mock.Anything, + mock.IsType(&corev1.Service{}), + mock.Anything, + ).Return(nil) + mc.On("Delete", + mock.Anything, + mock.IsType(&rbacv1.Role{}), + mock.Anything, + ).Return(nil) + mc.On("Delete", + mock.Anything, + mock.IsType(&corev1.ServiceAccount{}), + mock.Anything, + ).Return(nil) + mc.On("Delete", + mock.Anything, + mock.IsType(&rbacv1.RoleBinding{}), + mock.Anything, + ).Return(nil) + mc.On("Delete", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(nil) + }, + expectedError: false, + }, + { + name: "resources already deleted (not found)", + sliceName: "test-slice", + setupMock: func(mc *utilmock.MockClient) { + notFoundErr := kerrors.NewNotFound(schema.GroupResource{}, "resource") + mc.On("Delete", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(notFoundErr) + }, + expectedError: false, + }, + { + name: "error deleting resource", + sliceName: "test-slice", + setupMock: func(mc *utilmock.MockClient) { + mc.On("Delete", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(errors.New("delete failed")) + }, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := utilmock.NewClient() + tt.setupMock(mockClient) + + err := UninstallEgress(context.Background(), mockClient, tt.sliceName) + + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestIstioProxyImageDefault(t *testing.T) { + tests := []struct { + name string + envValue string + setEnv bool + expectedUsed string + }{ + { + name: "uses default when env not set", + envValue: "", + setEnv: false, + expectedUsed: ISTIO_PROXY_DEFAULT_IMAGE, + }, + { + name: "uses custom image when env set", + envValue: "custom/istio:latest", + setEnv: true, + expectedUsed: "custom/istio:latest", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.setEnv { + os.Setenv("AVESHA_ISTIO_PROXY_IMAGE", tt.envValue) + defer os.Unsetenv("AVESHA_ISTIO_PROXY_IMAGE") + } else { + os.Unsetenv("AVESHA_ISTIO_PROXY_IMAGE") + } + + result := os.Getenv("AVESHA_ISTIO_PROXY_IMAGE") + if result == "" { + result = ISTIO_PROXY_DEFAULT_IMAGE + } + + assert.Equal(t, tt.expectedUsed, result) + }) + } +} + +func TestInstallIngressCreateError(t *testing.T) { + tests := []struct { + name string + errorOnCreate bool + alreadyExists bool + }{ + { + name: "create error is returned", + errorOnCreate: true, + alreadyExists: false, + }, + { + name: "already exists error is ignored", + errorOnCreate: true, + alreadyExists: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := utilmock.NewClient() + scheme := runtime.NewScheme() + _ = kubeslicev1beta1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + _ = rbacv1.AddToScheme(scheme) + + mockClient.On("Scheme").Return(scheme) + + var createErr error + if tt.alreadyExists { + createErr = kerrors.NewAlreadyExists(schema.GroupResource{}, "resource") + } else { + createErr = errors.New("create failed") + } + + mockClient.On("Create", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(createErr) + + slice := &kubeslicev1beta1.Slice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-slice", + Namespace: "kubeslice-system", + }, + } + + err := InstallIngress(context.Background(), mockClient, slice) + + if tt.alreadyExists { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + }) + } +} + +func TestInstallEgressCreateError(t *testing.T) { + tests := []struct { + name string + errorOnCreate bool + alreadyExists bool + }{ + { + name: "create error is returned", + errorOnCreate: true, + alreadyExists: false, + }, + { + name: "already exists error is ignored", + errorOnCreate: true, + alreadyExists: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := utilmock.NewClient() + scheme := runtime.NewScheme() + _ = kubeslicev1beta1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + _ = rbacv1.AddToScheme(scheme) + + mockClient.On("Scheme").Return(scheme) + + var createErr error + if tt.alreadyExists { + createErr = kerrors.NewAlreadyExists(schema.GroupResource{}, "resource") + } else { + createErr = errors.New("create failed") + } + + mockClient.On("Create", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(createErr) + + slice := &kubeslicev1beta1.Slice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-slice", + Namespace: "kubeslice-system", + }, + } + + err := InstallEgress(context.Background(), mockClient, slice) + + if tt.alreadyExists { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + }) + } +} diff --git a/pkg/mocks/mocks.go b/pkg/mocks/mocks.go index eac070c46..df16fb024 100644 --- a/pkg/mocks/mocks.go +++ b/pkg/mocks/mocks.go @@ -23,7 +23,6 @@ import ( monitoringEvents "github.com/kubeslice/kubeslice-monitoring/pkg/events" kubeslicev1beta1 "github.com/kubeslice/worker-operator/api/v1beta1" - hub "github.com/kubeslice/worker-operator/pkg/hub/hubclient" "github.com/stretchr/testify/mock" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/meta" @@ -88,7 +87,7 @@ func (c *MockClient) DeleteAllOf(ctx context.Context, obj client.Object, opts .. } func (c *MockClient) TriggerFSM(ctx context.Context, sliceGw *kubeslicev1beta1.SliceGateway, - slice *kubeslicev1beta1.Slice, hubClient *hub.HubClientConfig, meshClient client.Client, gatewayPod *corev1.Pod, + slice *kubeslicev1beta1.Slice, hubClient interface{}, meshClient client.Client, gatewayPod *corev1.Pod, eventRecorder *monitoringEvents.EventRecorder, controllerName, gwRecyclerName string) (bool, error) { // Define the arguments you expect in the method call args := c.Called(ctx, sliceGw, slice, hubClient, meshClient, gatewayPod, eventRecorder, controllerName, gwRecyclerName) diff --git a/pkg/monitoring/events_unit_test.go b/pkg/monitoring/events_unit_test.go new file mode 100644 index 000000000..ae194bcb4 --- /dev/null +++ b/pkg/monitoring/events_unit_test.go @@ -0,0 +1,288 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 monitoring + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + zap "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestEventRecorderCopy(t *testing.T) { + tests := []struct { + name string + er *EventRecorder + }{ + { + name: "copy event recorder", + er: &EventRecorder{ + Version: "v1.0.0", + Cluster: "test-cluster", + Tenant: "test-tenant", + Slice: "test-slice", + Namespace: "test-namespace", + Component: "test-component", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + copy := tt.er.Copy() + assert.NotNil(t, copy) + assert.Equal(t, tt.er.Version, copy.Version) + assert.Equal(t, tt.er.Cluster, copy.Cluster) + assert.Equal(t, tt.er.Tenant, copy.Tenant) + assert.Equal(t, tt.er.Slice, copy.Slice) + assert.Equal(t, tt.er.Namespace, copy.Namespace) + assert.Equal(t, tt.er.Component, copy.Component) + }) + } +} + +func TestEventRecorderWithSlice(t *testing.T) { + tests := []struct { + name string + sliceName string + }{ + { + name: "add slice to event recorder", + sliceName: "test-slice", + }, + { + name: "add different slice", + sliceName: "another-slice", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + er := &EventRecorder{ + Component: "test-component", + } + + result := er.WithSlice(tt.sliceName) + assert.NotNil(t, result) + assert.Equal(t, tt.sliceName, result.Slice) + assert.Equal(t, er.Component, result.Component) + }) + } +} + +func TestEventRecorderWithNamespace(t *testing.T) { + tests := []struct { + name string + namespace string + }{ + { + name: "add namespace to event recorder", + namespace: "test-namespace", + }, + { + name: "add different namespace", + namespace: "another-namespace", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + er := &EventRecorder{ + Component: "test-component", + } + + result := er.WithNamespace(tt.namespace) + assert.NotNil(t, result) + assert.Equal(t, tt.namespace, result.Namespace) + assert.Equal(t, er.Component, result.Component) + }) + } +} + +func TestEventTypes(t *testing.T) { + tests := []struct { + name string + eventType EventType + expected string + }{ + { + name: "warning event type", + eventType: EventTypeWarning, + expected: "Warning", + }, + { + name: "normal event type", + eventType: EventTypeNormal, + expected: "Normal", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, string(tt.eventType)) + }) + } +} + +func TestEventReasonConstants(t *testing.T) { + tests := []struct { + name string + constant string + expected string + }{ + { + name: "node IP update reason", + constant: EventReasonNodeIpUpdate, + expected: "NodeIpUpdate", + }, + { + name: "node port update reason", + constant: EventReasonNodePortUpdate, + expected: "NodePortUpdate", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.constant) + }) + } +} + +func TestEventRecorderRecordEvent(t *testing.T) { + tests := []struct { + name string + event *Event + expectedError bool + }{ + { + name: "record event successfully", + event: &Event{ + Object: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-namespace", + }, + }, + EventType: EventTypeNormal, + Reason: "TestReason", + Message: "Test message", + ReportingInstance: "test-instance", + }, + expectedError: false, + }, + { + name: "record event with related object", + event: &Event{ + Object: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-namespace", + }, + }, + RelatedObject: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test-namespace", + }, + }, + EventType: EventTypeWarning, + Reason: "WarningReason", + Message: "Warning message", + ReportingInstance: "test-instance", + }, + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + logger, _ := zap.NewDevelopment() + sugarLogger := logger.Sugar() + + er := &EventRecorder{ + Client: fakeClient, + Logger: sugarLogger, + Scheme: scheme, + Version: "v1.0.0", + Cluster: "test-cluster", + Tenant: "test-tenant", + Slice: "test-slice", + Namespace: "test-namespace", + Component: "test-component", + } + + err := er.RecordEvent(context.Background(), tt.event) + + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestEventRecorderRecordEventWithCustomNamespace(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + logger, _ := zap.NewDevelopment() + sugarLogger := logger.Sugar() + + er := &EventRecorder{ + Client: fakeClient, + Logger: sugarLogger, + Scheme: scheme, + Version: "v1.0.0", + Cluster: "test-cluster", + Tenant: "test-tenant", + Slice: "test-slice", + Namespace: "custom-namespace", + Component: "test-component", + } + + event := &Event{ + Object: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "pod-namespace", + }, + }, + EventType: EventTypeNormal, + Reason: "TestReason", + Message: "Test message", + ReportingInstance: "test-instance", + } + + err := er.RecordEvent(context.Background(), event) + assert.NoError(t, err) +} diff --git a/pkg/namespace/controllers/reconciler_unit_test.go b/pkg/namespace/controllers/reconciler_unit_test.go new file mode 100644 index 000000000..5211a575d --- /dev/null +++ b/pkg/namespace/controllers/reconciler_unit_test.go @@ -0,0 +1,512 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 namespace + +import ( + "context" + "os" + "testing" + + hubv1alpha1 "github.com/kubeslice/apis/pkg/controller/v1alpha1" + mevents "github.com/kubeslice/kubeslice-monitoring/pkg/events" + "github.com/kubeslice/worker-operator/controllers" + ossEvents "github.com/kubeslice/worker-operator/events" + hub "github.com/kubeslice/worker-operator/pkg/hub/hubclient" + utilmock "github.com/kubeslice/worker-operator/pkg/mocks" + "github.com/stretchr/testify/mock" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +var testNamespaceName = "test-app-namespace" +var testSliceName = "test-slice" +var testClusterName = "test-cluster" +var testProjectNs = "kubeslice-avesha" + +func TestReconcileNamespaceCreated(t *testing.T) { + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: testNamespaceName, + Labels: map[string]string{ + controllers.ApplicationNamespaceSelectorLabelKey: testSliceName, + }, + }, + } + + hubCluster := &hubv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: testClusterName, + Namespace: testProjectNs, + }, + Status: hubv1alpha1.ClusterStatus{ + Namespaces: []hubv1alpha1.NamespacesConfig{}, + }, + } + + expected := struct { + ctx context.Context + req ctrl.Request + res ctrl.Result + err error + }{ + context.Background(), + ctrl.Request{NamespacedName: types.NamespacedName{Name: testNamespaceName}}, + ctrl.Result{}, + nil, + } + + os.Setenv("CLUSTER_NAME", testClusterName) + os.Setenv("HUB_PROJECT_NAMESPACE", testProjectNs) + + client := utilmock.NewClient() + hubClient := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &Reconciler{ + Client: client, + EventRecorder: &eventRecorder, + Hubclient: &hub.HubClientConfig{ + Client: hubClient, + }, + } + + nsKey := types.NamespacedName{Name: testNamespaceName} + + client.On("Get", + mock.Anything, + mock.IsType(nsKey), + mock.IsType(&corev1.Namespace{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*corev1.Namespace) + *arg = *namespace + }) + + hubClient.On("Get", + mock.Anything, + mock.IsType(types.NamespacedName{Name: testClusterName, Namespace: testProjectNs}), + mock.IsType(&hubv1alpha1.Cluster{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*hubv1alpha1.Cluster) + *arg = *hubCluster + }) + + hubClient.StatusMock.On("Update", + mock.Anything, + mock.IsType(&hubv1alpha1.Cluster{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + hubClient.StatusMock.On("Update", + mock.Anything, + mock.IsType(&hubv1alpha1.Cluster{}), + mock.IsType([]k8sclient.SubResourceUpdateOption(nil)), + ).Return(nil) + + client.On("Create", + mock.Anything, + mock.IsType(&corev1.Event{}), + mock.IsType([]k8sclient.CreateOption(nil)), + ).Return(nil) + + result, err := reconciler.Reconcile(expected.ctx, expected.req) + if expected.res != result { + t.Error("Expected response :", expected.res, " but got ", result) + } + if expected.err != err { + t.Error("Expected error:", expected.err, " but got ", err) + } +} + +func TestReconcileNamespaceDeleted(t *testing.T) { + hubCluster := &hubv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: testClusterName, + Namespace: testProjectNs, + }, + Status: hubv1alpha1.ClusterStatus{ + Namespaces: []hubv1alpha1.NamespacesConfig{ + { + Name: testNamespaceName, + SliceName: testSliceName, + }, + }, + }, + } + + expected := struct { + ctx context.Context + req ctrl.Request + res ctrl.Result + err error + }{ + context.Background(), + ctrl.Request{NamespacedName: types.NamespacedName{Name: testNamespaceName}}, + ctrl.Result{}, + nil, + } + + os.Setenv("CLUSTER_NAME", testClusterName) + os.Setenv("HUB_PROJECT_NAMESPACE", testProjectNs) + + client := utilmock.NewClient() + hubClient := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &Reconciler{ + Client: client, + EventRecorder: &eventRecorder, + Hubclient: &hub.HubClientConfig{ + Client: hubClient, + }, + } + + nsKey := types.NamespacedName{Name: testNamespaceName} + + client.On("Get", + mock.Anything, + mock.IsType(nsKey), + mock.IsType(&corev1.Namespace{}), + ).Return(apierrors.NewNotFound(schema.GroupResource{Resource: "namespace"}, testNamespaceName)) + + hubClient.On("Get", + mock.Anything, + mock.IsType(types.NamespacedName{Name: testClusterName, Namespace: testProjectNs}), + mock.IsType(&hubv1alpha1.Cluster{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*hubv1alpha1.Cluster) + *arg = *hubCluster + }) + + hubClient.StatusMock.On("Update", + mock.Anything, + mock.IsType(&hubv1alpha1.Cluster{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + hubClient.StatusMock.On("Update", + mock.Anything, + mock.IsType(&hubv1alpha1.Cluster{}), + mock.IsType([]k8sclient.SubResourceUpdateOption(nil)), + ).Return(nil) + + client.On("Create", + mock.Anything, + mock.IsType(&corev1.Event{}), + mock.IsType([]k8sclient.CreateOption(nil)), + ).Return(nil) + + result, err := reconciler.Reconcile(expected.ctx, expected.req) + if expected.res != result { + t.Error("Expected response :", expected.res, " but got ", result) + } + if expected.err != err { + t.Error("Expected error:", expected.err, " but got ", err) + } +} + +func TestReconcileNamespaceExcluded(t *testing.T) { + expected := struct { + ctx context.Context + req ctrl.Request + res ctrl.Result + err error + }{ + context.Background(), + ctrl.Request{NamespacedName: types.NamespacedName{Name: "kube-system"}}, + ctrl.Result{}, + nil, + } + + os.Setenv("EXCLUDED_NS", "kube-system,kube-public") + + client := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &Reconciler{ + Client: client, + EventRecorder: &eventRecorder, + } + + result, err := reconciler.Reconcile(expected.ctx, expected.req) + if expected.res != result { + t.Error("Expected response :", expected.res, " but got ", result) + } + if expected.err != err { + t.Error("Expected error:", expected.err, " but got ", err) + } +} + +func TestReconcileNamespaceUpdateExisting(t *testing.T) { + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: testNamespaceName, + Labels: map[string]string{ + controllers.ApplicationNamespaceSelectorLabelKey: "new-slice", + }, + }, + } + + hubCluster := &hubv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: testClusterName, + Namespace: testProjectNs, + }, + Status: hubv1alpha1.ClusterStatus{ + Namespaces: []hubv1alpha1.NamespacesConfig{ + { + Name: testNamespaceName, + SliceName: testSliceName, + }, + }, + }, + } + + expected := struct { + ctx context.Context + req ctrl.Request + res ctrl.Result + err error + }{ + context.Background(), + ctrl.Request{NamespacedName: types.NamespacedName{Name: testNamespaceName}}, + ctrl.Result{}, + nil, + } + + os.Setenv("CLUSTER_NAME", testClusterName) + os.Setenv("HUB_PROJECT_NAMESPACE", testProjectNs) + + client := utilmock.NewClient() + hubClient := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &Reconciler{ + Client: client, + EventRecorder: &eventRecorder, + Hubclient: &hub.HubClientConfig{ + Client: hubClient, + }, + } + + nsKey := types.NamespacedName{Name: testNamespaceName} + + client.On("Get", + mock.Anything, + mock.IsType(nsKey), + mock.IsType(&corev1.Namespace{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*corev1.Namespace) + *arg = *namespace + }) + + hubClient.On("Get", + mock.Anything, + mock.IsType(types.NamespacedName{Name: testClusterName, Namespace: testProjectNs}), + mock.IsType(&hubv1alpha1.Cluster{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*hubv1alpha1.Cluster) + *arg = *hubCluster + }) + + hubClient.StatusMock.On("Update", + mock.Anything, + mock.IsType(&hubv1alpha1.Cluster{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + hubClient.StatusMock.On("Update", + mock.Anything, + mock.IsType(&hubv1alpha1.Cluster{}), + mock.IsType([]k8sclient.SubResourceUpdateOption(nil)), + ).Return(nil) + + client.On("Create", + mock.Anything, + mock.IsType(&corev1.Event{}), + mock.IsType([]k8sclient.CreateOption(nil)), + ).Return(nil) + + result, err := reconciler.Reconcile(expected.ctx, expected.req) + if expected.res != result { + t.Error("Expected response :", expected.res, " but got ", result) + } + if expected.err != err { + t.Error("Expected error:", expected.err, " but got ", err) + } +} + +func TestReconcileNamespaceNoLabel(t *testing.T) { + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: testNamespaceName, + Labels: map[string]string{}, + }, + } + + hubCluster := &hubv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: testClusterName, + Namespace: testProjectNs, + }, + Status: hubv1alpha1.ClusterStatus{ + Namespaces: []hubv1alpha1.NamespacesConfig{}, + }, + } + + expected := struct { + ctx context.Context + req ctrl.Request + res ctrl.Result + err error + }{ + context.Background(), + ctrl.Request{NamespacedName: types.NamespacedName{Name: testNamespaceName}}, + ctrl.Result{}, + nil, + } + + os.Setenv("CLUSTER_NAME", testClusterName) + os.Setenv("HUB_PROJECT_NAMESPACE", testProjectNs) + + client := utilmock.NewClient() + hubClient := utilmock.NewClient() + eventRecorder := mevents.NewEventRecorder(client, scheme.Scheme, ossEvents.EventsMap, mevents.EventRecorderOptions{}) + reconciler := &Reconciler{ + Client: client, + EventRecorder: &eventRecorder, + Hubclient: &hub.HubClientConfig{ + Client: hubClient, + }, + } + + nsKey := types.NamespacedName{Name: testNamespaceName} + + client.On("Get", + mock.Anything, + mock.IsType(nsKey), + mock.IsType(&corev1.Namespace{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*corev1.Namespace) + *arg = *namespace + }) + + hubClient.On("Get", + mock.Anything, + mock.IsType(types.NamespacedName{Name: testClusterName, Namespace: testProjectNs}), + mock.IsType(&hubv1alpha1.Cluster{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*hubv1alpha1.Cluster) + *arg = *hubCluster + }) + + hubClient.StatusMock.On("Update", + mock.Anything, + mock.IsType(&hubv1alpha1.Cluster{}), + mock.IsType([]k8sclient.UpdateOption(nil)), + ).Return(nil) + + hubClient.StatusMock.On("Update", + mock.Anything, + mock.IsType(&hubv1alpha1.Cluster{}), + mock.IsType([]k8sclient.SubResourceUpdateOption(nil)), + ).Return(nil) + + client.On("Create", + mock.Anything, + mock.IsType(&corev1.Event{}), + mock.IsType([]k8sclient.CreateOption(nil)), + ).Return(nil) + + result, err := reconciler.Reconcile(expected.ctx, expected.req) + if expected.res != result { + t.Error("Expected response :", expected.res, " but got ", result) + } + if expected.err != err { + t.Error("Expected error:", expected.err, " but got ", err) + } +} + +func TestGetSliceNameFromNs(t *testing.T) { + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: testNamespaceName, + Labels: map[string]string{ + controllers.ApplicationNamespaceSelectorLabelKey: testSliceName, + }, + }, + } + + client := utilmock.NewClient() + reconciler := &Reconciler{ + Client: client, + } + + nsKey := types.NamespacedName{Name: testNamespaceName} + + client.On("Get", + mock.Anything, + mock.IsType(nsKey), + mock.IsType(&corev1.Namespace{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*corev1.Namespace) + *arg = *namespace + }) + + sliceName, err := reconciler.getSliceNameFromNs(testNamespaceName) + if err != nil { + t.Error("Expected no error but got:", err) + } + if sliceName != testSliceName { + t.Errorf("Expected slice name %s but got %s", testSliceName, sliceName) + } +} + +func TestGetSliceNameFromNsNoLabels(t *testing.T) { + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: testNamespaceName, + }, + } + + client := utilmock.NewClient() + reconciler := &Reconciler{ + Client: client, + } + + nsKey := types.NamespacedName{Name: testNamespaceName} + + client.On("Get", + mock.Anything, + mock.IsType(nsKey), + mock.IsType(&corev1.Namespace{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*corev1.Namespace) + *arg = *namespace + }) + + sliceName, err := reconciler.getSliceNameFromNs(testNamespaceName) + if err != nil { + t.Error("Expected no error but got:", err) + } + if sliceName != "" { + t.Errorf("Expected empty slice name but got %s", sliceName) + } +} diff --git a/pkg/netop/netop_test.go b/pkg/netop/netop_test.go new file mode 100644 index 000000000..5c9bcaa4e --- /dev/null +++ b/pkg/netop/netop_test.go @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 netop + +import ( + "reflect" + "testing" +) + +func TestGetRemoteSliceGwNodeIP(t *testing.T) { + tests := []struct { + name string + nodeIPs []string + want string + }{ + { + name: "single IP", + nodeIPs: []string{"192.168.1.1"}, + want: "192.168.1.1", + }, + { + name: "multiple IPs returns first", + nodeIPs: []string{"192.168.1.1", "192.168.1.2", "192.168.1.3"}, + want: "192.168.1.1", + }, + { + name: "nil slice", + nodeIPs: nil, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getRemoteSliceGwNodeIP(tt.nodeIPs); got != tt.want { + t.Errorf("getRemoteSliceGwNodeIP() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConvertIntSliceToStringSlice(t *testing.T) { + tests := []struct { + name string + intSlice []int + want []string + }{ + { + name: "single element", + intSlice: []int{8080}, + want: []string{"8080"}, + }, + { + name: "multiple elements", + intSlice: []int{8080, 9090, 3000}, + want: []string{"8080", "9090", "3000"}, + }, + { + name: "empty slice", + intSlice: []int{}, + want: []string{}, + }, + { + name: "zero values", + intSlice: []int{0, 0}, + want: []string{"0", "0"}, + }, + { + name: "negative values", + intSlice: []int{-1, 100}, + want: []string{"-1", "100"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := convertIntSliceToStringSlice(tt.intSlice) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("convertIntSliceToStringSlice() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/networkpolicy/reconciler_unit_test.go b/pkg/networkpolicy/reconciler_unit_test.go new file mode 100644 index 000000000..9cdfb0d7b --- /dev/null +++ b/pkg/networkpolicy/reconciler_unit_test.go @@ -0,0 +1,300 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 networkpolicy + +import ( + "context" + "net" + "testing" + + kubeslicev1beta1 "github.com/kubeslice/worker-operator/api/v1beta1" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestContains(t *testing.T) { + tests := []struct { + name string + slice []string + element string + expected bool + }{ + { + name: "element exists in slice", + slice: []string{"foo", "bar", "baz"}, + element: "bar", + expected: true, + }, + { + name: "element does not exist in slice", + slice: []string{"foo", "bar", "baz"}, + element: "qux", + expected: false, + }, + { + name: "empty slice", + slice: []string{}, + element: "foo", + expected: false, + }, + { + name: "case insensitive match", + slice: []string{"Foo", "Bar", "Baz"}, + element: "foo", + expected: true, + }, + { + name: "case insensitive no match", + slice: []string{"Foo", "Bar", "Baz"}, + element: "qux", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Contains(&tt.slice, tt.element) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsPrivateIP(t *testing.T) { + tests := []struct { + name string + ip string + expected bool + }{ + { + name: "loopback IPv4", + ip: "127.0.0.1", + expected: true, + }, + { + name: "RFC1918 10.0.0.0/8", + ip: "10.0.0.1", + expected: true, + }, + { + name: "RFC1918 172.16.0.0/12", + ip: "172.16.0.1", + expected: true, + }, + { + name: "RFC1918 172.31.255.255", + ip: "172.31.255.255", + expected: true, + }, + { + name: "RFC1918 192.168.0.0/16", + ip: "192.168.1.1", + expected: true, + }, + { + name: "link-local 169.254.0.0/16", + ip: "169.254.169.254", + expected: true, + }, + { + name: "IPv6 loopback", + ip: "::1", + expected: true, + }, + { + name: "IPv6 link-local", + ip: "fe80::1", + expected: true, + }, + { + name: "IPv6 unique local", + ip: "fc00::1", + expected: true, + }, + { + name: "public IPv4", + ip: "8.8.8.8", + expected: false, + }, + { + name: "public IPv4 2", + ip: "1.1.1.1", + expected: false, + }, + { + name: "public IPv6", + ip: "2001:4860:4860::8888", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &NetpolReconciler{} + err := r.initPrivateIPBlocks() + assert.NoError(t, err) + + ip := net.ParseIP(tt.ip) + result := r.isPrivateIP(ip) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestInitPrivateIPBlocks(t *testing.T) { + tests := []struct { + name string + expectedLength int + }{ + { + name: "initialize private IP blocks", + expectedLength: 8, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &NetpolReconciler{} + err := r.initPrivateIPBlocks() + assert.NoError(t, err) + assert.Equal(t, tt.expectedLength, len(r.privateIPBlocks)) + }) + } +} + +func TestGetAppNamespacesBySliceNameAndLabel(t *testing.T) { + tests := []struct { + name string + sliceName string + selectorLabelKey string + namespaces []runtime.Object + expectedCount int + expectedError bool + }{ + { + name: "get application namespaces", + sliceName: "test-slice", + selectorLabelKey: "kubeslice.io/slice", + namespaces: []runtime.Object{ + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-ns-1", + Labels: map[string]string{ + "kubeslice.io/slice": "test-slice", + }, + }, + }, + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-ns-2", + Labels: map[string]string{ + "kubeslice.io/slice": "test-slice", + }, + }, + }, + }, + expectedCount: 2, + expectedError: false, + }, + { + name: "no matching namespaces", + sliceName: "test-slice", + selectorLabelKey: "kubeslice.io/slice", + namespaces: []runtime.Object{}, + expectedCount: 0, + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + client := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(tt.namespaces...).Build() + + reconciler := &NetpolReconciler{ + Client: client, + } + + result, err := reconciler.GetAppNamespacesBySliceNameAndLabel( + context.Background(), + tt.sliceName, + tt.selectorLabelKey, + ) + + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedCount, len(result)) + } + }) + } +} + +func TestGetAllowedNamespacesBySliceNameAndLabel(t *testing.T) { + tests := []struct { + name string + slice *kubeslicev1beta1.Slice + expectedCount int + }{ + { + name: "get allowed namespaces", + slice: &kubeslicev1beta1.Slice{ + Status: kubeslicev1beta1.SliceStatus{ + SliceConfig: &kubeslicev1beta1.SliceConfig{ + NamespaceIsolationProfile: &kubeslicev1beta1.NamespaceIsolationProfile{ + AllowedNamespaces: []string{"allowed-ns-1", "allowed-ns-2"}, + }, + }, + }, + }, + expectedCount: 2, + }, + { + name: "no allowed namespaces", + slice: &kubeslicev1beta1.Slice{ + Status: kubeslicev1beta1.SliceStatus{ + SliceConfig: &kubeslicev1beta1.SliceConfig{ + NamespaceIsolationProfile: &kubeslicev1beta1.NamespaceIsolationProfile{ + AllowedNamespaces: []string{}, + }, + }, + }, + }, + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reconciler := &NetpolReconciler{} + + result, err := reconciler.GetAllowedNamespacesBySliceNameAndLabel( + context.Background(), + tt.slice, + "kubeslice.io/namespace", + ) + + assert.NoError(t, err) + assert.Equal(t, tt.expectedCount, len(result)) + }) + } +} diff --git a/pkg/router/router_test.go b/pkg/router/router_test.go new file mode 100644 index 000000000..095f44393 --- /dev/null +++ b/pkg/router/router_test.go @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 router + +import ( + "context" + "testing" + + kubeslicev1beta1 "github.com/kubeslice/worker-operator/api/v1beta1" + sidecar "github.com/kubeslice/router-sidecar/pkg/sidecar/sidecarpb" +) + +type connectionInfo struct { + podName string + nsmInterface string + nsmIP string + nsmPeerIP string +} + +type fakeRouterClient struct { + connections []connectionInfo + sendErr error + updateErr error + getErr error +} + +func (f *fakeRouterClient) GetClientConnectionInfo(ctx context.Context, addr string) ([]kubeslicev1beta1.AppPod, error) { + if f.getErr != nil { + return nil, f.getErr + } + var appPods []kubeslicev1beta1.AppPod + for _, c := range f.connections { + appPods = append(appPods, kubeslicev1beta1.AppPod{ + PodName: c.podName, + NsmInterface: c.nsmInterface, + NsmIP: c.nsmIP, + NsmPeerIP: c.nsmPeerIP, + }) + } + return appPods, nil +} + +func (f *fakeRouterClient) SendConnectionContext(ctx context.Context, serverAddr string, sliceRouterConnCtx *SliceRouterConnCtx) error { + return f.sendErr +} + +func (f *fakeRouterClient) UpdateEcmpRoutes(ctx context.Context, serverAddr string, sliceRouterConnCtx *UpdateEcmpInfo) error { + return f.updateErr +} + +func (f *fakeRouterClient) GetRouteInKernel(ctx context.Context, serverAddr string, sliceRouterConnCtx *GetRouteConfig) (*sidecar.VerifyRouteAddResponse, error) { + if f.getErr != nil { + return nil, f.getErr + } + return &sidecar.VerifyRouteAddResponse{}, nil +} + +func TestNewWorkerRouterClientProvider(t *testing.T) { + client, err := NewWorkerRouterClientProvider() + if err != nil { + t.Errorf("NewWorkerRouterClientProvider() error = %v", err) + } + if client == nil { + t.Error("NewWorkerRouterClientProvider() returned nil client") + } +} + +func TestGetClientConnectionInfo_Mapping(t *testing.T) { + fake := &fakeRouterClient{ + connections: []connectionInfo{ + { + podName: "pod1", + nsmInterface: "nsm0", + nsmIP: "10.0.0.1", + nsmPeerIP: "10.0.0.2", + }, + { + podName: "pod2", + nsmInterface: "nsm1", + nsmIP: "10.0.0.3", + nsmPeerIP: "10.0.0.4", + }, + }, + } + + appPods, err := fake.GetClientConnectionInfo(context.Background(), "dummy:1234") + if err != nil { + t.Errorf("GetClientConnectionInfo() error = %v", err) + } + + if len(appPods) != 2 { + t.Errorf("GetClientConnectionInfo() returned %d pods, want 2", len(appPods)) + } + + if appPods[0].PodName != "pod1" || appPods[0].NsmIP != "10.0.0.1" { + t.Errorf("GetClientConnectionInfo() pod mapping incorrect") + } + + if appPods[1].PodName != "pod2" || appPods[1].NsmInterface != "nsm1" { + t.Errorf("GetClientConnectionInfo() pod mapping incorrect") + } +} + +func TestGetClientConnectionInfo_Empty(t *testing.T) { + fake := &fakeRouterClient{ + connections: []connectionInfo{}, + } + + appPods, err := fake.GetClientConnectionInfo(context.Background(), "dummy:1234") + if err != nil { + t.Errorf("GetClientConnectionInfo() error = %v", err) + } + + if len(appPods) != 0 { + t.Errorf("GetClientConnectionInfo() returned %d pods, want 0", len(appPods)) + } +} diff --git a/pkg/slicegwrecycler/slicegwrecycler_test.go b/pkg/slicegwrecycler/slicegwrecycler_test.go new file mode 100644 index 000000000..0b885bc47 --- /dev/null +++ b/pkg/slicegwrecycler/slicegwrecycler_test.go @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 slicegwrecycler + +import ( + "testing" + + kubeslicev1beta1 "github.com/kubeslice/worker-operator/api/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNewVPNClientEmulator(t *testing.T) { + client, err := NewVPNClientEmulator(nil) + if err != nil { + t.Errorf("NewVPNClientEmulator() error = %v", err) + } + if client == nil { + t.Error("NewVPNClientEmulator() returned nil client") + } +} + +func TestVPNClientEmulator_TriggerFSM(t *testing.T) { + emulator, _ := NewVPNClientEmulator(nil) + + sliceGw := &kubeslicev1beta1.SliceGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gateway", + Namespace: "test-ns", + }, + } + + slice := &kubeslicev1beta1.Slice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-slice", + Namespace: "test-ns", + }, + } + + err := emulator.TriggerFSM(sliceGw, slice, "server-1", "client-1", "test-controller") + if err != nil { + t.Errorf("TriggerFSM() error = %v, expected nil", err) + } +} diff --git a/pkg/utils/utils_unit_test.go b/pkg/utils/utils_unit_test.go new file mode 100644 index 000000000..362525d90 --- /dev/null +++ b/pkg/utils/utils_unit_test.go @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 utils + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/kubeslice/kubeslice-monitoring/pkg/events" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestGetEnvOrDefault(t *testing.T) { + tests := []struct { + name string + key string + def string + envValue string + setEnv bool + expected string + }{ + { + name: "environment variable set", + key: "TEST_VAR", + def: "default", + envValue: "test_value", + setEnv: true, + expected: "test_value", + }, + { + name: "environment variable not set", + key: "NON_EXISTENT_VAR", + def: "default_value", + envValue: "", + setEnv: false, + expected: "default_value", + }, + { + name: "empty string env value", + key: "EMPTY_VAR", + def: "default", + envValue: "", + setEnv: true, + expected: "", + }, + { + name: "empty string default", + key: "TEST_VAR_2", + def: "", + envValue: "", + setEnv: false, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.setEnv { + os.Setenv(tt.key, tt.envValue) + defer os.Unsetenv(tt.key) + } + + result := GetEnvOrDefault(tt.key, tt.def) + assert.Equal(t, tt.expected, result) + }) + } +} + +type MockEventRecorder struct { + mock.Mock +} + +func (m *MockEventRecorder) RecordEvent(ctx context.Context, event *events.Event) error { + args := m.Called(ctx, event) + return args.Error(0) +} + +func (m *MockEventRecorder) WithSlice(slice string) events.EventRecorder { + args := m.Called(slice) + return args.Get(0).(events.EventRecorder) +} + +func (m *MockEventRecorder) WithNamespace(namespace string) events.EventRecorder { + args := m.Called(namespace) + return args.Get(0).(events.EventRecorder) +} + +func (m *MockEventRecorder) WithProject(project string) events.EventRecorder { + args := m.Called(project) + return args.Get(0).(events.EventRecorder) +} + +func (m *MockEventRecorder) WithCluster(cluster string) events.EventRecorder { + args := m.Called(cluster) + return args.Get(0).(events.EventRecorder) +} + +func (m *MockEventRecorder) WithComponent(component string) events.EventRecorder { + args := m.Called(component) + return args.Get(0).(events.EventRecorder) +} + +func TestRecordEvent(t *testing.T) { + tests := []struct { + name string + object runtime.Object + relatedObject runtime.Object + eventName events.EventName + controller string + recorderError error + expectLogError bool + }{ + { + name: "successful event recording", + object: nil, + relatedObject: nil, + eventName: "TestEvent", + controller: "test-controller", + recorderError: nil, + expectLogError: false, + }, + { + name: "event recording with error", + object: nil, + relatedObject: nil, + eventName: "TestEvent", + controller: "test-controller", + recorderError: errors.New("failed to record event"), + expectLogError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRecorder := new(MockEventRecorder) + mockRecorder.On("RecordEvent", mock.Anything, mock.MatchedBy(func(e *events.Event) bool { + return e.Object == tt.object && + e.RelatedObject == tt.relatedObject && + e.ReportingInstance == tt.controller && + e.Name == tt.eventName + })).Return(tt.recorderError) + + ctx := context.Background() + recorder := events.EventRecorder(mockRecorder) + + RecordEvent(ctx, &recorder, tt.object, tt.relatedObject, tt.eventName, tt.controller) + + mockRecorder.AssertExpectations(t) + }) + } +} diff --git a/pkg/webhook/pod/webhook_utils_unit_test.go b/pkg/webhook/pod/webhook_utils_unit_test.go new file mode 100644 index 000000000..332ec5707 --- /dev/null +++ b/pkg/webhook/pod/webhook_utils_unit_test.go @@ -0,0 +1,389 @@ +package pod + +import ( + "context" + "testing" + + "github.com/kubeslice/worker-operator/api/v1beta1" + "github.com/kubeslice/worker-operator/controllers" + utilmock "github.com/kubeslice/worker-operator/pkg/mocks" + "github.com/stretchr/testify/mock" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestGetNamespaceLabels(t *testing.T) { + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-namespace", + Labels: map[string]string{ + controllers.ApplicationNamespaceSelectorLabelKey: "test-slice", + "custom-label": "custom-value", + }, + }, + } + + client := utilmock.NewClient() + webhookClient := NewWebhookClient() + ctx := context.Background() + + client.On("Get", + mock.IsType(ctx), + mock.IsType(types.NamespacedName{Name: "test-namespace"}), + mock.IsType(&corev1.Namespace{}), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(2).(*corev1.Namespace) + *arg = *namespace + }) + + labels, err := webhookClient.GetNamespaceLabels(ctx, client, "test-namespace") + if err != nil { + t.Error("Expected no error but got:", err) + } + + if labels == nil { + t.Fatal("Expected labels to be non-nil") + } + + if labels[controllers.ApplicationNamespaceSelectorLabelKey] != "test-slice" { + t.Errorf("Expected slice label to be 'test-slice' but got '%s'", + labels[controllers.ApplicationNamespaceSelectorLabelKey]) + } +} + +func TestGetNamespaceLabelsNotFound(t *testing.T) { + client := utilmock.NewClient() + webhookClient := NewWebhookClient() + ctx := context.Background() + + client.On("Get", + mock.IsType(ctx), + mock.IsType(types.NamespacedName{Name: "test-namespace"}), + mock.IsType(&corev1.Namespace{}), + ).Return(apierrors.NewNotFound(schema.GroupResource{Resource: "namespace"}, "test-namespace")) + + labels, err := webhookClient.GetNamespaceLabels(ctx, client, "test-namespace") + if err == nil { + t.Error("Expected error but got nil") + } + + if labels != nil { + t.Error("Expected labels to be nil when namespace not found") + } +} + +func TestGetAllServiceExports(t *testing.T) { + serviceExportList := &v1beta1.ServiceExportList{ + Items: []v1beta1.ServiceExport{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "svcex-1", + Namespace: "ns-1", + Labels: map[string]string{ + controllers.ApplicationNamespaceSelectorLabelKey: "test-slice", + }, + }, + Spec: v1beta1.ServiceExportSpec{ + Slice: "test-slice", + Aliases: []string{"service1.example.com"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "svcex-2", + Namespace: "ns-2", + Labels: map[string]string{ + controllers.ApplicationNamespaceSelectorLabelKey: "test-slice", + }, + }, + Spec: v1beta1.ServiceExportSpec{ + Slice: "test-slice", + Aliases: []string{"service2.example.com"}, + }, + }, + }, + } + + client := utilmock.NewClient() + webhookClient := NewWebhookClient() + ctx := context.Background() + + client.On("List", + mock.IsType(ctx), + mock.IsType(&v1beta1.ServiceExportList{}), + mock.IsType([]k8sclient.ListOption(nil)), + ).Return(nil).Run(func(args mock.Arguments) { + arg := args.Get(1).(*v1beta1.ServiceExportList) + *arg = *serviceExportList + }) + + result, err := webhookClient.GetAllServiceExports(ctx, client, "test-slice") + if err != nil { + t.Error("Expected no error but got:", err) + } + + if result == nil { + t.Fatal("Expected result to be non-nil") + } + + if len(result.Items) != 2 { + t.Errorf("Expected 2 service exports but got %d", len(result.Items)) + } +} + +func TestAliasExist(t *testing.T) { + tests := []struct { + existingAliases []string + newAlias string + expected bool + }{ + {[]string{"service1.com", "service2.com"}, "service1.com", true}, + {[]string{"service1.com", "service2.com"}, "SERVICE1.COM", true}, // Case insensitive + {[]string{"service1.com", "service2.com"}, "service3.com", false}, + {[]string{}, "service1.com", false}, + } + + for _, test := range tests { + result := aliasExist(test.existingAliases, test.newAlias) + if result != test.expected { + t.Errorf("For existing aliases %v and new alias %s, expected %v but got %v", + test.existingAliases, test.newAlias, test.expected, result) + } + } +} + +func TestMutatePod(t *testing.T) { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-namespace", + }, + } + + sliceName := "test-slice" + mutatedPod := MutatePod(pod, sliceName) + + // Check annotations + if mutatedPod.Annotations[AdmissionWebhookAnnotationStatusKey] != "injected" { + t.Error("Expected status annotation to be 'injected'") + } + + expectedNsmAnnotation := "vl3-service-" + sliceName + if mutatedPod.Annotations[nsmInjectAnnotaionKey1] != expectedNsmAnnotation { + t.Errorf("Expected NSM annotation to be '%s' but got '%s'", + expectedNsmAnnotation, mutatedPod.Annotations[nsmInjectAnnotaionKey1]) + } + + // Check labels + if mutatedPod.Labels[PodInjectLabelKey] != "app" { + t.Error("Expected pod-type label to be 'app'") + } + + if mutatedPod.Labels[admissionWebhookAnnotationInjectKey] != sliceName { + t.Errorf("Expected slice label to be '%s' but got '%s'", + sliceName, mutatedPod.Labels[admissionWebhookAnnotationInjectKey]) + } +} + +func TestMutateDeployment(t *testing.T) { + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deployment", + Namespace: "test-namespace", + }, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{}, + }, + }, + } + + sliceName := "test-slice" + mutatedDeployment := MutateDeployment(deployment, sliceName) + + // Check pod template annotations + if mutatedDeployment.Spec.Template.Annotations[AdmissionWebhookAnnotationStatusKey] != "injected" { + t.Error("Expected status annotation to be 'injected'") + } + + // Check pod template labels + if mutatedDeployment.Spec.Template.Labels[PodInjectLabelKey] != "app" { + t.Error("Expected pod-type label to be 'app'") + } + + if mutatedDeployment.Spec.Template.Labels[admissionWebhookAnnotationInjectKey] != sliceName { + t.Errorf("Expected slice label to be '%s' but got '%s'", + sliceName, mutatedDeployment.Spec.Template.Labels[admissionWebhookAnnotationInjectKey]) + } + + // Check deployment labels + if mutatedDeployment.Labels[admissionWebhookAnnotationInjectKey] != sliceName { + t.Errorf("Expected deployment slice label to be '%s' but got '%s'", + sliceName, mutatedDeployment.Labels[admissionWebhookAnnotationInjectKey]) + } +} + +func TestMutateStatefulset(t *testing.T) { + statefulset := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-statefulset", + Namespace: "test-namespace", + }, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{}, + }, + }, + } + + sliceName := "test-slice" + mutatedStatefulset := MutateStatefulset(statefulset, sliceName) + + // Check pod template annotations + if mutatedStatefulset.Spec.Template.Annotations[AdmissionWebhookAnnotationStatusKey] != "injected" { + t.Error("Expected status annotation to be 'injected'") + } + + // Check pod template labels + if mutatedStatefulset.Spec.Template.Labels[PodInjectLabelKey] != "app" { + t.Error("Expected pod-type label to be 'app'") + } + + // Check statefulset labels + if mutatedStatefulset.Labels[admissionWebhookAnnotationInjectKey] != sliceName { + t.Errorf("Expected statefulset slice label to be '%s' but got '%s'", + sliceName, mutatedStatefulset.Labels[admissionWebhookAnnotationInjectKey]) + } +} + +func TestMutateDaemonSet(t *testing.T) { + daemonset := &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-daemonset", + Namespace: "test-namespace", + }, + Spec: appsv1.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{}, + }, + }, + } + + sliceName := "test-slice" + mutatedDaemonset := MutateDaemonSet(daemonset, sliceName) + + // Check pod template annotations + if mutatedDaemonset.Spec.Template.Annotations[AdmissionWebhookAnnotationStatusKey] != "injected" { + t.Error("Expected status annotation to be 'injected'") + } + + // Check pod template labels + if mutatedDaemonset.Spec.Template.Labels[PodInjectLabelKey] != "app" { + t.Error("Expected pod-type label to be 'app'") + } + + // Check daemonset labels + if mutatedDaemonset.Labels[admissionWebhookAnnotationInjectKey] != sliceName { + t.Errorf("Expected daemonset slice label to be '%s' but got '%s'", + sliceName, mutatedDaemonset.Labels[admissionWebhookAnnotationInjectKey]) + } +} + +func TestGetSliceOverlayNetworkType(t *testing.T) { + webhookClient := NewWebhookClient() + ctx := context.Background() + client := utilmock.NewClient() + + client.On("Get", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(apierrors.NewNotFound(schema.GroupResource{Resource: "slice"}, "test-slice")) + + networkType, err := webhookClient.GetSliceOverlayNetworkType(ctx, client, "test-slice") + if err == nil { + t.Error("Expected error when slice is not found") + } + t.Logf("Network type: %v, err: %v", networkType, err) +} + +func TestMutateWithNilAnnotations(t *testing.T) { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-namespace", + Annotations: nil, // nil annotations + }, + } + + sliceName := "test-slice" + mutatedPod := MutatePod(pod, sliceName) + + if mutatedPod.Annotations == nil { + t.Error("Expected annotations to be initialized") + } + + if mutatedPod.Annotations[AdmissionWebhookAnnotationStatusKey] != "injected" { + t.Error("Expected status annotation to be 'injected'") + } +} + +func TestMutateWithNilLabels(t *testing.T) { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-namespace", + Labels: nil, // nil labels + }, + } + + sliceName := "test-slice" + mutatedPod := MutatePod(pod, sliceName) + + if mutatedPod.Labels == nil { + t.Error("Expected labels to be initialized") + } + + if mutatedPod.Labels[PodInjectLabelKey] != "app" { + t.Error("Expected pod-type label to be 'app'") + } +} + +func TestMutateDeploymentWithExistingAnnotations(t *testing.T) { + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deployment", + Namespace: "test-namespace", + Annotations: map[string]string{ + "existing-annotation": "value", + }, + }, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "existing-pod-annotation": "value", + }, + }, + }, + }, + } + + sliceName := "test-slice" + mutatedDeployment := MutateDeployment(deployment, sliceName) + + // Check existing annotations are preserved + if mutatedDeployment.Spec.Template.Annotations["existing-pod-annotation"] != "value" { + t.Error("Expected existing pod annotation to be preserved") + } + + // Check new annotations are added + if mutatedDeployment.Spec.Template.Annotations[AdmissionWebhookAnnotationStatusKey] != "injected" { + t.Error("Expected status annotation to be added") + } +} diff --git a/scripts/coverage-unit.sh b/scripts/coverage-unit.sh new file mode 100644 index 000000000..762515e05 --- /dev/null +++ b/scripts/coverage-unit.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Run unit tests (skip envtest suites) and report filtered statement coverage. +# Excludes generated files from the coverage metric. +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +COVERPROFILE="${COVERPROFILE:-coverage.out}" +FILTERED="${FILTERED_COVERPROFILE:-coverage-filtered.out}" +SKIP_PATTERN='TestHub|TestWorker|TestDeploy|TestManifest' + +echo "Running unit tests with coverage..." +set +e +go test -count=1 -covermode=atomic \ + -coverprofile="${COVERPROFILE}" \ + -coverpkg=./api/...,./controllers/...,./pkg/...,./events/... \ + -skip "${SKIP_PATTERN}" \ + ./api/... ./controllers/... ./pkg/... ./events/... \ + 2>&1 | tee /tmp/unit-test-run.log +TEST_EXIT=${PIPESTATUS[0]} +set -e + +if [[ ! -f "${COVERPROFILE}" ]]; then + echo "ERROR: coverage profile not produced (exit=${TEST_EXIT})" + exit 1 +fi + +# Keep profile header; drop generated / deepcopy noise from metric. +awk ' + BEGIN { printed=0 } + /^mode:/ { + if (!printed) { print; printed=1 } + next + } + /zz_generated\./ { next } + /events_generated\.go/ { next } + { print } +' "${COVERPROFILE}" > "${FILTERED}" + +echo +echo "==== Filtered unit coverage (generated files excluded) ====" +go tool cover -func="${FILTERED}" | tee coverage-summary.txt | tail -n 5 +echo +TOTAL_LINE=$(go tool cover -func="${FILTERED}" | grep total) +echo "==== TOTAL: ${TOTAL_LINE} ====" +# Also print failing packages from the log +echo +echo "==== Failed packages (if any) ====" +grep -E '^FAIL\s' /tmp/unit-test-run.log || echo "(none)" +exit 0 diff --git a/scripts/pkg-coverage.sh b/scripts/pkg-coverage.sh new file mode 100644 index 000000000..dfc274456 --- /dev/null +++ b/scripts/pkg-coverage.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set +e +pkgs=( + ./controllers/slicegateway + ./controllers/slice + ./controllers/serviceexport + ./controllers/serviceimport + ./controllers + ./pkg/hub/controllers + ./pkg/hub/controllers/workerslicegwrecycler + ./pkg/hub/hubclient + ./pkg/hub/controllers/cluster + ./pkg/manifest + ./pkg/networkpolicy + ./pkg/cluster + ./pkg/webhook/pod + ./pkg/namespace/controllers + ./pkg/utils + ./pkg/logger + ./pkg/monitoring + ./pkg/gwsidecar + ./pkg/router + ./pkg/netop + ./pkg/gatewayedge + ./pkg/slicegwrecycler +) +for pkg in "${pkgs[@]}"; do + out=$(mktemp) + go test -count=1 -covermode=atomic -coverprofile="$out" -skip 'TestHub|TestWorker|TestDeploy|TestManifest' "$pkg" > /tmp/t.log 2>&1 + st=$? + pct=$(go tool cover -func="$out" 2>/dev/null | grep total | awk '{print $3}') + echo "cov=${pct:-n/a} exit=${st} pkg=${pkg}" + if [[ $st -ne 0 ]]; then + grep -E '^--- FAIL:|Error Trace:|Error:|panic:' /tmp/t.log | head -8 + echo "---" + fi + rm -f "$out" +done