diff --git a/config/samples/sample.yaml b/config/samples/sample.yaml new file mode 100644 index 00000000..64b29c8a --- /dev/null +++ b/config/samples/sample.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: multigres-sample +--- +apiVersion: multigres.com/v1alpha1 +kind: Etcd +metadata: + namespace: multigres-sample + name: sample +spec: {} \ No newline at end of file diff --git a/pkg/resource-handler/controller/etcd/etcd_env.go b/pkg/resource-handler/controller/etcd/container_env.go similarity index 94% rename from pkg/resource-handler/controller/etcd/etcd_env.go rename to pkg/resource-handler/controller/etcd/container_env.go index 36a8e4df..2b439dbc 100644 --- a/pkg/resource-handler/controller/etcd/etcd_env.go +++ b/pkg/resource-handler/controller/etcd/container_env.go @@ -7,10 +7,14 @@ import ( corev1 "k8s.io/api/core/v1" ) -// buildEtcdEnv constructs all environment variables for etcd clustering in +// buildContainerEnv constructs all environment variables for etcd clustering in // StatefulSets. This combines pod identity, etcd config, and cluster peer // discovery details. -func buildEtcdEnv(etcdName, namespace string, replicas int32, serviceName string) []corev1.EnvVar { +func buildContainerEnv( + etcdName, namespace string, + replicas int32, + serviceName string, +) []corev1.EnvVar { envVars := make([]corev1.EnvVar, 0) // Add pod identity variables from downward API diff --git a/pkg/resource-handler/controller/etcd/etcd_env_test.go b/pkg/resource-handler/controller/etcd/container_env_test.go similarity index 98% rename from pkg/resource-handler/controller/etcd/etcd_env_test.go rename to pkg/resource-handler/controller/etcd/container_env_test.go index 64dbafd5..0a597888 100644 --- a/pkg/resource-handler/controller/etcd/etcd_env_test.go +++ b/pkg/resource-handler/controller/etcd/container_env_test.go @@ -178,7 +178,7 @@ func TestBuildEtcdClusterPeerList(t *testing.T) { } } -func TestBuildEtcdEnv(t *testing.T) { +func TestBuildContainerEnv(t *testing.T) { tests := map[string]struct { etcdName string namespace string @@ -319,9 +319,9 @@ func TestBuildEtcdEnv(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { - got := buildEtcdEnv(tc.etcdName, tc.namespace, tc.replicas, tc.serviceName) + got := buildContainerEnv(tc.etcdName, tc.namespace, tc.replicas, tc.serviceName) if diff := cmp.Diff(tc.want, got); diff != "" { - t.Errorf("BuildEtcdEnv() mismatch (-want +got):\n%s", diff) + t.Errorf("BuildContainerEnv() mismatch (-want +got):\n%s", diff) } }) } diff --git a/pkg/resource-handler/controller/etcd/etcd_controller_test.go b/pkg/resource-handler/controller/etcd/etcd_controller_test.go index 1d9bb5da..a8d6696b 100644 --- a/pkg/resource-handler/controller/etcd/etcd_controller_test.go +++ b/pkg/resource-handler/controller/etcd/etcd_controller_test.go @@ -299,7 +299,7 @@ func TestEtcdReconciler_Reconcile(t *testing.T) { ////---------------------------------------- /// Error //------------------------------------------ - "error on StatefulSet create": { + "error on status update": { etcd: &multigresv1alpha1.Etcd{ ObjectMeta: metav1.ObjectMeta{ Name: "test-etcd", @@ -309,32 +309,33 @@ func TestEtcdReconciler_Reconcile(t *testing.T) { }, existingObjects: []client.Object{}, failureConfig: &testutil.FailureConfig{ - OnCreate: func(obj client.Object) error { - if _, ok := obj.(*appsv1.StatefulSet); ok { - return testutil.ErrPermissionError - } - return nil - }, + OnStatusUpdate: testutil.FailOnObjectName("test-etcd", testutil.ErrInjected), }, wantErr: true, }, - "error on headless Service create": { + "error on Get StatefulSet in updateStatus (network error)": { etcd: &multigresv1alpha1.Etcd{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-etcd", - Namespace: "default", + Name: "test-etcd-status", + Namespace: "default", + Finalizers: []string{finalizerName}, }, Spec: multigresv1alpha1.EtcdSpec{}, }, - existingObjects: []client.Object{}, - failureConfig: &testutil.FailureConfig{ - OnCreate: func(obj client.Object) error { - if svc, ok := obj.(*corev1.Service); ok && svc.Name == "test-etcd-headless" { - return testutil.ErrPermissionError - } - return nil + existingObjects: []client.Object{ + &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-etcd-status", + Namespace: "default", + }, }, }, + failureConfig: &testutil.FailureConfig{ + // Fail StatefulSet Get after first successful call + // First Get succeeds (in reconcileStatefulSet) + // Second Get fails (in updateStatus) + OnGet: testutil.FailKeyAfterNCalls(1, testutil.ErrNetworkTimeout), + }, wantErr: true, }, "error on client Service create": { @@ -356,58 +357,14 @@ func TestEtcdReconciler_Reconcile(t *testing.T) { }, wantErr: true, }, - "error on status update": { - etcd: &multigresv1alpha1.Etcd{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-etcd", - Namespace: "default", - }, - Spec: multigresv1alpha1.EtcdSpec{}, - }, - existingObjects: []client.Object{}, - failureConfig: &testutil.FailureConfig{ - OnStatusUpdate: testutil.FailOnObjectName("test-etcd", testutil.ErrInjected), - }, - wantErr: true, - }, - "error on Get Etcd": { - etcd: &multigresv1alpha1.Etcd{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-etcd", - Namespace: "default", - }, - Spec: multigresv1alpha1.EtcdSpec{}, - }, - existingObjects: []client.Object{}, - failureConfig: &testutil.FailureConfig{ - OnGet: testutil.FailOnKeyName("test-etcd", testutil.ErrNetworkTimeout), - }, - wantErr: true, - }, - "error on finalizer Update": { - etcd: &multigresv1alpha1.Etcd{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-etcd", - Namespace: "default", - }, - Spec: multigresv1alpha1.EtcdSpec{}, - }, - existingObjects: []client.Object{}, - failureConfig: &testutil.FailureConfig{ - OnUpdate: testutil.FailOnObjectName("test-etcd", testutil.ErrInjected), - }, - wantErr: true, - }, - "error on StatefulSet Update": { + "error on client Service Update": { etcd: &multigresv1alpha1.Etcd{ ObjectMeta: metav1.ObjectMeta{ Name: "test-etcd", Namespace: "default", Finalizers: []string{finalizerName}, }, - Spec: multigresv1alpha1.EtcdSpec{ - Replicas: int32Ptr(5), - }, + Spec: multigresv1alpha1.EtcdSpec{}, }, existingObjects: []client.Object{ &appsv1.StatefulSet{ @@ -415,14 +372,23 @@ func TestEtcdReconciler_Reconcile(t *testing.T) { Name: "test-etcd", Namespace: "default", }, - Spec: appsv1.StatefulSetSpec{ - Replicas: int32Ptr(3), + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-etcd-headless", + Namespace: "default", + }, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-etcd", + Namespace: "default", }, }, }, failureConfig: &testutil.FailureConfig{ OnUpdate: func(obj client.Object) error { - if _, ok := obj.(*appsv1.StatefulSet); ok { + if svc, ok := obj.(*corev1.Service); ok && svc.Name == "test-etcd" { return testutil.ErrInjected } return nil @@ -430,10 +396,10 @@ func TestEtcdReconciler_Reconcile(t *testing.T) { }, wantErr: true, }, - "error on headless Service Update": { + "error on Get client Service (network error)": { etcd: &multigresv1alpha1.Etcd{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-etcd", + Name: "test-etcd-svc", Namespace: "default", Finalizers: []string{finalizerName}, }, @@ -442,28 +408,46 @@ func TestEtcdReconciler_Reconcile(t *testing.T) { existingObjects: []client.Object{ &appsv1.StatefulSet{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-etcd", + Name: "test-etcd-svc", Namespace: "default", }, }, &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-etcd-headless", + Name: "test-etcd-svc-headless", Namespace: "default", }, }, }, failureConfig: &testutil.FailureConfig{ - OnUpdate: func(obj client.Object) error { + OnGet: testutil.FailOnNamespacedKeyName( + "test-etcd-svc", + "default", + testutil.ErrNetworkTimeout, + ), + }, + wantErr: true, + }, + "error on headless Service create": { + etcd: &multigresv1alpha1.Etcd{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-etcd", + Namespace: "default", + }, + Spec: multigresv1alpha1.EtcdSpec{}, + }, + existingObjects: []client.Object{}, + failureConfig: &testutil.FailureConfig{ + OnCreate: func(obj client.Object) error { if svc, ok := obj.(*corev1.Service); ok && svc.Name == "test-etcd-headless" { - return testutil.ErrInjected + return testutil.ErrPermissionError } return nil }, }, wantErr: true, }, - "error on client Service Update": { + "error on headless Service Update": { etcd: &multigresv1alpha1.Etcd{ ObjectMeta: metav1.ObjectMeta{ Name: "test-etcd", @@ -485,16 +469,10 @@ func TestEtcdReconciler_Reconcile(t *testing.T) { Namespace: "default", }, }, - &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-etcd", - Namespace: "default", - }, - }, }, failureConfig: &testutil.FailureConfig{ OnUpdate: func(obj client.Object) error { - if svc, ok := obj.(*corev1.Service); ok && svc.Name == "test-etcd" { + if svc, ok := obj.(*corev1.Service); ok && svc.Name == "test-etcd-headless" { return testutil.ErrInjected } return nil @@ -502,10 +480,10 @@ func TestEtcdReconciler_Reconcile(t *testing.T) { }, wantErr: true, }, - "error on Get StatefulSet in updateStatus": { + "error on Get headless Service (network error)": { etcd: &multigresv1alpha1.Etcd{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-etcd-status", + Name: "test-etcd", Namespace: "default", Finalizers: []string{finalizerName}, }, @@ -514,48 +492,50 @@ func TestEtcdReconciler_Reconcile(t *testing.T) { existingObjects: []client.Object{ &appsv1.StatefulSet{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-etcd-status", + Name: "test-etcd", Namespace: "default", }, }, }, failureConfig: &testutil.FailureConfig{ - // Fail StatefulSet Get after first successful call - // First Get succeeds (in reconcileStatefulSet) - // Second Get fails (in updateStatus) - OnGet: testutil.FailKeyAfterNCalls(1, testutil.ErrNetworkTimeout), + OnGet: func(key client.ObjectKey) error { + if key.Name == "test-etcd-headless" { + return testutil.ErrNetworkTimeout + } + return nil + }, }, wantErr: true, }, - "error on Get StatefulSet (not NotFound)": { + "error on StatefulSet create": { etcd: &multigresv1alpha1.Etcd{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-etcd", - Namespace: "default", - Finalizers: []string{finalizerName}, + Name: "test-etcd", + Namespace: "default", }, Spec: multigresv1alpha1.EtcdSpec{}, }, existingObjects: []client.Object{}, failureConfig: &testutil.FailureConfig{ - OnGet: func(key client.ObjectKey) error { - // Fail StatefulSet Get with non-NotFound error - if key.Name == "test-etcd" { - return testutil.ErrNetworkTimeout + OnCreate: func(obj client.Object) error { + if _, ok := obj.(*appsv1.StatefulSet); ok { + return testutil.ErrPermissionError } return nil }, }, wantErr: true, }, - "error on Get headless Service (not NotFound)": { + "error on StatefulSet Update": { etcd: &multigresv1alpha1.Etcd{ ObjectMeta: metav1.ObjectMeta{ Name: "test-etcd", Namespace: "default", Finalizers: []string{finalizerName}, }, - Spec: multigresv1alpha1.EtcdSpec{}, + Spec: multigresv1alpha1.EtcdSpec{ + Replicas: int32Ptr(5), + }, }, existingObjects: []client.Object{ &appsv1.StatefulSet{ @@ -563,48 +543,52 @@ func TestEtcdReconciler_Reconcile(t *testing.T) { Name: "test-etcd", Namespace: "default", }, + Spec: appsv1.StatefulSetSpec{ + Replicas: int32Ptr(3), + }, }, }, failureConfig: &testutil.FailureConfig{ - OnGet: func(key client.ObjectKey) error { - // Fail headless Service Get with non-NotFound error - if key.Name == "test-etcd-headless" { - return testutil.ErrNetworkTimeout + OnUpdate: func(obj client.Object) error { + if _, ok := obj.(*appsv1.StatefulSet); ok { + return testutil.ErrInjected } return nil }, }, wantErr: true, }, - "error on Get client Service (not NotFound)": { + "error on Get StatefulSet (network error)": { etcd: &multigresv1alpha1.Etcd{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-etcd-svc", + Name: "test-etcd", Namespace: "default", Finalizers: []string{finalizerName}, }, Spec: multigresv1alpha1.EtcdSpec{}, }, - existingObjects: []client.Object{ - &appsv1.StatefulSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-etcd-svc", - Namespace: "default", - }, + existingObjects: []client.Object{}, + failureConfig: &testutil.FailureConfig{ + OnGet: func(key client.ObjectKey) error { + if key.Name == "test-etcd" { + return testutil.ErrNetworkTimeout + } + return nil }, - &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-etcd-svc-headless", - Namespace: "default", - }, + }, + wantErr: true, + }, + "error on finalizer Update": { + etcd: &multigresv1alpha1.Etcd{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-etcd", + Namespace: "default", }, + Spec: multigresv1alpha1.EtcdSpec{}, }, + existingObjects: []client.Object{}, failureConfig: &testutil.FailureConfig{ - OnGet: testutil.FailOnNamespacedKeyName( - "test-etcd-svc", - "default", - testutil.ErrNetworkTimeout, - ), + OnUpdate: testutil.FailOnObjectName("test-etcd", testutil.ErrInjected), }, wantErr: true, }, @@ -634,6 +618,20 @@ func TestEtcdReconciler_Reconcile(t *testing.T) { }, wantErr: true, }, + "error on Get Etcd (network error)": { + etcd: &multigresv1alpha1.Etcd{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-etcd", + Namespace: "default", + }, + Spec: multigresv1alpha1.EtcdSpec{}, + }, + existingObjects: []client.Object{}, + failureConfig: &testutil.FailureConfig{ + OnGet: testutil.FailOnKeyName("test-etcd", testutil.ErrNetworkTimeout), + }, + wantErr: true, + }, } for name, tc := range tests { diff --git a/pkg/resource-handler/controller/etcd/statefulset.go b/pkg/resource-handler/controller/etcd/statefulset.go index c927fda5..2e8d94f4 100644 --- a/pkg/resource-handler/controller/etcd/statefulset.go +++ b/pkg/resource-handler/controller/etcd/statefulset.go @@ -84,7 +84,7 @@ func BuildStatefulSet( Name: "etcd", Image: image, Resources: etcd.Spec.Resources, - Env: buildEtcdEnv( + Env: buildContainerEnv( etcd.Name, etcd.Namespace, replicas, diff --git a/pkg/resource-handler/controller/etcd/statefulset_test.go b/pkg/resource-handler/controller/etcd/statefulset_test.go index 2e76968e..3eb3f0d1 100644 --- a/pkg/resource-handler/controller/etcd/statefulset_test.go +++ b/pkg/resource-handler/controller/etcd/statefulset_test.go @@ -102,7 +102,7 @@ func TestBuildStatefulSet(t *testing.T) { Name: "etcd", Image: DefaultImage, Resources: corev1.ResourceRequirements{}, - Env: buildEtcdEnv( + Env: buildContainerEnv( "test-etcd", "default", 3, @@ -211,7 +211,7 @@ func TestBuildStatefulSet(t *testing.T) { Name: "etcd", Image: "quay.io/coreos/etcd:v3.5.15", Resources: corev1.ResourceRequirements{}, - Env: buildEtcdEnv( + Env: buildContainerEnv( "etcd-custom", "test", 5, @@ -319,7 +319,7 @@ func TestBuildStatefulSet(t *testing.T) { Name: "etcd", Image: DefaultImage, Resources: corev1.ResourceRequirements{}, - Env: buildEtcdEnv( + Env: buildContainerEnv( "test-etcd", "default", 3, @@ -435,7 +435,7 @@ func TestBuildStatefulSet(t *testing.T) { Name: "etcd", Image: DefaultImage, Resources: corev1.ResourceRequirements{}, - Env: buildEtcdEnv( + Env: buildContainerEnv( "test-etcd", "default", 3, @@ -473,7 +473,7 @@ func TestBuildStatefulSet(t *testing.T) { }, }, }, - "scheme without Etcd type - should error": { + "scheme with incorrect type - should error": { etcd: &multigresv1alpha1.Etcd{ ObjectMeta: metav1.ObjectMeta{ Name: "test-etcd", @@ -481,7 +481,7 @@ func TestBuildStatefulSet(t *testing.T) { }, Spec: multigresv1alpha1.EtcdSpec{}, }, - scheme: runtime.NewScheme(), // empty scheme without Etcd type + scheme: runtime.NewScheme(), // empty scheme with incorrect type wantErr: true, }, } diff --git a/pkg/resource-handler/controller/multigateway/container_env.go b/pkg/resource-handler/controller/multigateway/container_env.go new file mode 100644 index 00000000..12881adf --- /dev/null +++ b/pkg/resource-handler/controller/multigateway/container_env.go @@ -0,0 +1,34 @@ +package multigateway + +import ( + corev1 "k8s.io/api/core/v1" +) + +// buildContainerEnv constructs all environment variables for the MultiGateway +// container. +func buildContainerEnv() []corev1.EnvVar { + envVars := []corev1.EnvVar{ + { + // TODO: get etcd endpoints and forward them to MultiGateway + Name: "ETCD_ENDPOINTS", + Value: "", + }, + { + // TODO: is there an env var for HTTP port? + Name: "HTTP_PORT", + Value: "", + }, + { + // TODO: is there an env var for GRPC port? + Name: "GRPC_PORT", + Value: "", + }, + { + // TODO: is there an env var for Postgres port? + Name: "POSTGRES_PORT", + Value: "", + }, + } + + return envVars +} diff --git a/pkg/resource-handler/controller/multigateway/deployment.go b/pkg/resource-handler/controller/multigateway/deployment.go new file mode 100644 index 00000000..194c1dc6 --- /dev/null +++ b/pkg/resource-handler/controller/multigateway/deployment.go @@ -0,0 +1,89 @@ +package multigateway + +import ( + "fmt" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + + multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" + "github.com/numtide/multigres-operator/pkg/resource-handler/controller/metadata" +) + +const ( + // ComponentName is the component label value for MultiGateway resources + ComponentName = "multigateway" + + // DefaultReplicas is the default number of MultiGateway replicas + DefaultReplicas int32 = 2 + + // DefaultImage is the default etcd container image + DefaultImage = "numtide/multigres-operator:latest" +) + +// BuildDeployment creates a Deployment for the Etcd cluster. +// Returns a deterministic Deployment based on the Etcd spec. +func BuildDeployment( + mg *multigresv1alpha1.MultiGateway, + scheme *runtime.Scheme, +) (*appsv1.Deployment, error) { + replicas := DefaultReplicas + // TODO: Debatable whether this defaulting makes sense. + if mg.Spec.Replicas != nil { + replicas = *mg.Spec.Replicas + } + + image := DefaultImage + if mg.Spec.Image != "" { + image = mg.Spec.Image + } + + labels := metadata.BuildStandardLabels(mg.Name, ComponentName, mg.Spec.CellName) + podLabels := metadata.MergeLabels(labels, mg.Spec.PodLabels) + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: mg.Name, + Namespace: mg.Namespace, + Labels: labels, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: labels, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: podLabels, + Annotations: mg.Spec.PodAnnotations, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: mg.Spec.ServiceAccountName, + ImagePullSecrets: mg.Spec.ImagePullSecrets, + Containers: []corev1.Container{ + { + Name: "multigateway", + Image: image, + Resources: mg.Spec.Resources, + Env: buildContainerEnv(), + Ports: buildContainerPorts(mg), + }, + }, + Affinity: mg.Spec.Affinity, + Tolerations: mg.Spec.Tolerations, + NodeSelector: mg.Spec.NodeSelector, + TopologySpreadConstraints: mg.Spec.TopologySpreadConstraints, + }, + }, + }, + } + + if err := ctrl.SetControllerReference(mg, deployment, scheme); err != nil { + return nil, fmt.Errorf("failed to set controller reference: %w", err) + } + + return deployment, nil +} diff --git a/pkg/resource-handler/controller/multigateway/deployment_test.go b/pkg/resource-handler/controller/multigateway/deployment_test.go new file mode 100644 index 00000000..87ddd262 --- /dev/null +++ b/pkg/resource-handler/controller/multigateway/deployment_test.go @@ -0,0 +1,213 @@ +package multigateway + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" +) + +func int32Ptr(i int32) *int32 { + return &i +} + +func boolPtr(b bool) *bool { + return &b +} + +func TestBuildDeployment(t *testing.T) { + scheme := runtime.NewScheme() + _ = multigresv1alpha1.AddToScheme(scheme) + + tests := map[string]struct { + mg *multigresv1alpha1.MultiGateway + scheme *runtime.Scheme + want *appsv1.Deployment + wantErr bool + }{ + "minimal spec - all defaults": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + UID: "test-uid", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + scheme: scheme, + want: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + Labels: map[string]string{ + "app.kubernetes.io/name": "multigres", + "app.kubernetes.io/instance": "test-multigateway", + "app.kubernetes.io/component": "multigateway", + "app.kubernetes.io/part-of": "multigres", + "app.kubernetes.io/managed-by": "multigres-operator", + "multigres.com/cell": "multigres-global-topo", + }, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "multigres.com/v1alpha1", + Kind: "MultiGateway", + Name: "test-multigateway", + UID: "test-uid", + Controller: boolPtr(true), + BlockOwnerDeletion: boolPtr(true), + }, + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: int32Ptr(2), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app.kubernetes.io/name": "multigres", + "app.kubernetes.io/instance": "test-multigateway", + "app.kubernetes.io/component": "multigateway", + "app.kubernetes.io/part-of": "multigres", + "app.kubernetes.io/managed-by": "multigres-operator", + "multigres.com/cell": "multigres-global-topo", + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app.kubernetes.io/name": "multigres", + "app.kubernetes.io/instance": "test-multigateway", + "app.kubernetes.io/component": "multigateway", + "app.kubernetes.io/part-of": "multigres", + "app.kubernetes.io/managed-by": "multigres-operator", + "multigres.com/cell": "multigres-global-topo", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "multigateway", + Image: DefaultImage, + Resources: corev1.ResourceRequirements{}, + Env: buildContainerEnv(), + Ports: buildContainerPorts( + &multigresv1alpha1.MultiGateway{}, + ), + }, + }, + }, + }, + }, + }, + }, + "custom replicas and image": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + UID: "test-uid", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{ + Replicas: int32Ptr(3), + Image: "foo/bar:1.2.3", + }, + }, + scheme: scheme, + want: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + Labels: map[string]string{ + "app.kubernetes.io/name": "multigres", + "app.kubernetes.io/instance": "test-multigateway", + "app.kubernetes.io/component": "multigateway", + "app.kubernetes.io/part-of": "multigres", + "app.kubernetes.io/managed-by": "multigres-operator", + "multigres.com/cell": "multigres-global-topo", + }, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "multigres.com/v1alpha1", + Kind: "MultiGateway", + Name: "test-multigateway", + UID: "test-uid", + Controller: boolPtr(true), + BlockOwnerDeletion: boolPtr(true), + }, + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: int32Ptr(3), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app.kubernetes.io/name": "multigres", + "app.kubernetes.io/instance": "test-multigateway", + "app.kubernetes.io/component": "multigateway", + "app.kubernetes.io/part-of": "multigres", + "app.kubernetes.io/managed-by": "multigres-operator", + "multigres.com/cell": "multigres-global-topo", + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app.kubernetes.io/name": "multigres", + "app.kubernetes.io/instance": "test-multigateway", + "app.kubernetes.io/component": "multigateway", + "app.kubernetes.io/part-of": "multigres", + "app.kubernetes.io/managed-by": "multigres-operator", + "multigres.com/cell": "multigres-global-topo", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "multigateway", + Image: "foo/bar:1.2.3", + Resources: corev1.ResourceRequirements{}, + Env: buildContainerEnv(), + Ports: buildContainerPorts( + &multigresv1alpha1.MultiGateway{}, + ), + }, + }, + }, + }, + }, + }, + }, + "scheme with incorrect type - should error": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + scheme: runtime.NewScheme(), // empty scheme with incorrect type + wantErr: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got, err := BuildDeployment(tc.mg, tc.scheme) + + if (err != nil) != tc.wantErr { + t.Errorf("BuildDeployment() error = %v, wantErr %v", err, tc.wantErr) + return + } + + if tc.wantErr { + return + } + + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("BuildDeployment() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/pkg/resource-handler/controller/multigateway/dummy.go b/pkg/resource-handler/controller/multigateway/dummy.go deleted file mode 100644 index 0560d57d..00000000 --- a/pkg/resource-handler/controller/multigateway/dummy.go +++ /dev/null @@ -1,5 +0,0 @@ -package multigateway - -func Dummy() string { - return "dummy string from resource-handler's multigateway controller" -} diff --git a/pkg/resource-handler/controller/multigateway/multigateway_controller.go b/pkg/resource-handler/controller/multigateway/multigateway_controller.go new file mode 100644 index 00000000..49f9d792 --- /dev/null +++ b/pkg/resource-handler/controller/multigateway/multigateway_controller.go @@ -0,0 +1,247 @@ +package multigateway + +import ( + "context" + "fmt" + "slices" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" +) + +const ( + finalizerName = "multigateway.multigres.com/finalizer" +) + +// MultiGatewayReconciler reconciles an MultiGateway object. +type MultiGatewayReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=multigres.com,resources=multigateways,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=multigres.com,resources=multigateways/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=multigres.com,resources=multigateways/finalizers,verbs=update +// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete + +// Reconcile handles MultiGateway resource reconciliation. +func (r *MultiGatewayReconciler) Reconcile( + ctx context.Context, + req ctrl.Request, +) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + // Fetch the MultiGateway instance + mg := &multigresv1alpha1.MultiGateway{} + if err := r.Get(ctx, req.NamespacedName, mg); err != nil { + if errors.IsNotFound(err) { + logger.Info("MultiGateway resource not found, ignoring") + return ctrl.Result{}, nil + } + logger.Error(err, "Failed to get MultiGateway") + return ctrl.Result{}, err + } + + // Handle deletion + if !mg.DeletionTimestamp.IsZero() { + return r.handleDeletion(ctx, mg) + } + + // Add finalizer if not present + if !slices.Contains(mg.Finalizers, finalizerName) { + mg.Finalizers = append(mg.Finalizers, finalizerName) + if err := r.Update(ctx, mg); err != nil { + logger.Error(err, "Failed to add finalizer") + return ctrl.Result{}, err + } + } + + // Reconcile StatefulSet + if err := r.reconcileDeployment(ctx, mg); err != nil { + logger.Error(err, "Failed to reconcile Deployment") + return ctrl.Result{}, err + } + + // Reconcile Service + if err := r.reconcileService(ctx, mg); err != nil { + logger.Error(err, "Failed to reconcile client Service") + return ctrl.Result{}, err + } + + // Update status + if err := r.updateStatus(ctx, mg); err != nil { + logger.Error(err, "Failed to update status") + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +// handleDeletion handles cleanup when MultiGateway is being deleted. +func (r *MultiGatewayReconciler) handleDeletion( + ctx context.Context, + mg *multigresv1alpha1.MultiGateway, +) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + if slices.Contains(mg.Finalizers, finalizerName) { + // Perform cleanup if needed + // Currently no special cleanup required - owner references handle resource deletion + + // Remove finalizer + mg.Finalizers = slices.DeleteFunc(mg.Finalizers, func(s string) bool { + return s == finalizerName + }) + if err := r.Update(ctx, mg); err != nil { + logger.Error(err, "Failed to remove finalizer") + return ctrl.Result{}, err + } + } + + return ctrl.Result{}, nil +} + +// reconcileDeployment creates or updates the Deployment for MultiGateway. +func (r *MultiGatewayReconciler) reconcileDeployment( + ctx context.Context, + mg *multigresv1alpha1.MultiGateway, +) error { + desired, err := BuildDeployment(mg, r.Scheme) + if err != nil { + return fmt.Errorf("failed to build Deployment: %w", err) + } + + existing := &appsv1.Deployment{} + err = r.Get(ctx, client.ObjectKey{Namespace: mg.Namespace, Name: mg.Name}, existing) + if err != nil { + if errors.IsNotFound(err) { + // Create new Deployment + if err := r.Create(ctx, desired); err != nil { + return fmt.Errorf("failed to create Deployment: %w", err) + } + return nil + } + return fmt.Errorf("failed to get Deployment: %w", err) + } + + // Update existing Deployment + existing.Spec = desired.Spec + existing.Labels = desired.Labels + if err := r.Update(ctx, existing); err != nil { + return fmt.Errorf("failed to update Deployment: %w", err) + } + + return nil +} + +// reconcileService creates or updates the client Service for MultiGateway. +func (r *MultiGatewayReconciler) reconcileService( + ctx context.Context, + mg *multigresv1alpha1.MultiGateway, +) error { + desired, err := BuildService(mg, r.Scheme) + if err != nil { + return fmt.Errorf("failed to build Service: %w", err) + } + + existing := &corev1.Service{} + err = r.Get(ctx, client.ObjectKey{Namespace: mg.Namespace, Name: mg.Name}, existing) + if err != nil { + if errors.IsNotFound(err) { + // Create new Service + if err := r.Create(ctx, desired); err != nil { + return fmt.Errorf("failed to create Service: %w", err) + } + return nil + } + return fmt.Errorf("failed to get Service: %w", err) + } + + // Update existing Service + existing.Spec.Ports = desired.Spec.Ports + existing.Spec.Selector = desired.Spec.Selector + existing.Labels = desired.Labels + if err := r.Update(ctx, existing); err != nil { + return fmt.Errorf("failed to update Service: %w", err) + } + + return nil +} + +// updateStatus updates the Etcd status based on observed state. +func (r *MultiGatewayReconciler) updateStatus( + ctx context.Context, + mg *multigresv1alpha1.MultiGateway, +) error { + // Get the Deployment to check status + dp := &appsv1.Deployment{} + err := r.Get(ctx, client.ObjectKey{Namespace: mg.Namespace, Name: mg.Name}, dp) + if err != nil { + if errors.IsNotFound(err) { + // Deployment not created yet + return nil + } + return fmt.Errorf("failed to get Deployment for status: %w", err) + } + + // Update status fields + mg.Status.Replicas = dp.Status.Replicas + mg.Status.ReadyReplicas = dp.Status.ReadyReplicas + mg.Status.Ready = dp.Status.ReadyReplicas == dp.Status.Replicas && dp.Status.Replicas > 0 + mg.Status.ObservedGeneration = mg.Generation + + // Update conditions + mg.Status.Conditions = r.buildConditions(mg, dp) + + if err := r.Status().Update(ctx, mg); err != nil { + return fmt.Errorf("failed to update status: %w", err) + } + + return nil +} + +// buildConditions creates status conditions based on observed state. +func (r *MultiGatewayReconciler) buildConditions( + mg *multigresv1alpha1.MultiGateway, + sts *appsv1.Deployment, +) []metav1.Condition { + conditions := []metav1.Condition{} + + // Ready condition + readyCondition := metav1.Condition{ + Type: "Ready", + ObservedGeneration: mg.Generation, + LastTransitionTime: metav1.Now(), + } + + if sts.Status.ReadyReplicas == sts.Status.Replicas && sts.Status.Replicas > 0 { + readyCondition.Status = metav1.ConditionTrue + readyCondition.Reason = "AllReplicasReady" + readyCondition.Message = fmt.Sprintf("All %d replicas are ready", sts.Status.ReadyReplicas) + } else { + readyCondition.Status = metav1.ConditionFalse + readyCondition.Reason = "NotAllReplicasReady" + readyCondition.Message = fmt.Sprintf("%d/%d replicas ready", sts.Status.ReadyReplicas, sts.Status.Replicas) + } + + conditions = append(conditions, readyCondition) + return conditions +} + +// SetupWithManager sets up the controller with the Manager. +func (r *MultiGatewayReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&multigresv1alpha1.MultiGateway{}). + Owns(&appsv1.Deployment{}). + Owns(&corev1.Service{}). + Complete(r) +} diff --git a/pkg/resource-handler/controller/multigateway/multigateway_controller_internal_test.go b/pkg/resource-handler/controller/multigateway/multigateway_controller_internal_test.go new file mode 100644 index 00000000..e05f7ff4 --- /dev/null +++ b/pkg/resource-handler/controller/multigateway/multigateway_controller_internal_test.go @@ -0,0 +1,208 @@ +package multigateway + +import ( + "context" + "testing" + + appsv1 "k8s.io/api/apps/v1" + 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" + + multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" + "github.com/numtide/multigres-operator/pkg/resource-handler/controller/testutil" +) + +// TestReconcileDeployment_InvalidScheme tests the error path when BuildDeployment fails. +// This should never happen in production - scheme is properly set up in main.go. +// Test exists for coverage of defensive error handling. +func TestReconcileDeployment_InvalidScheme(t *testing.T) { + // Empty scheme without Etcd type registered + invalidScheme := runtime.NewScheme() + + mg := &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(invalidScheme). + Build() + + reconciler := &MultiGatewayReconciler{ + Client: fakeClient, + Scheme: invalidScheme, + } + + err := reconciler.reconcileDeployment(context.Background(), mg) + if err == nil { + t.Error("reconcileDeployment() should error with invalid scheme") + } +} + +// TestReconcileService_InvalidScheme tests the error path when BuildClientService fails. +func TestReconcileService_InvalidScheme(t *testing.T) { + invalidScheme := runtime.NewScheme() + + mg := &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(invalidScheme). + Build() + + reconciler := &MultiGatewayReconciler{ + Client: fakeClient, + Scheme: invalidScheme, + } + + err := reconciler.reconcileService(context.Background(), mg) + if err == nil { + t.Error("reconcileService() should error with invalid scheme") + } +} + +// TestUpdateStatus_DeploymentNotFound tests the NotFound path in updateStatus. +func TestUpdateStatus_DeploymentNotFound(t *testing.T) { + scheme := runtime.NewScheme() + _ = multigresv1alpha1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) // Need Deployment type registered for Get to work + + mg := &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(mg). + WithStatusSubresource(&multigresv1alpha1.MultiGateway{}). + Build() + + reconciler := &MultiGatewayReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + // Call updateStatus when Deployment doesn't exist yet + err := reconciler.updateStatus(context.Background(), mg) + if err != nil { + t.Errorf("updateStatus() should not error when Deployment not found, got: %v", err) + } +} + +// TestHandleDeletion_NoFinalizer tests early return when no finalizer is present. +func TestHandleDeletion_NoFinalizer(t *testing.T) { + scheme := runtime.NewScheme() + _ = multigresv1alpha1.AddToScheme(scheme) + + mg := &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + Finalizers: []string{}, // No finalizer + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(mg). + Build() + + reconciler := &MultiGatewayReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + result, err := reconciler.handleDeletion(context.Background(), mg) + if err != nil { + t.Errorf("handleDeletion() should not error when no finalizer, got: %v", err) + } + if result.RequeueAfter > 0 { + t.Error("handleDeletion() should not requeue when no finalizer") + } +} + +// TestReconcileService_GetError tests error path on Get client Service (not NotFound). +func TestReconcileService_GetError(t *testing.T) { + scheme := runtime.NewScheme() + _ = multigresv1alpha1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + mg := &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + } + + // Create client with failure injection + baseClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(mg). + Build() + + fakeClient := testutil.NewFakeClientWithFailures(baseClient, &testutil.FailureConfig{ + OnGet: testutil.FailOnKeyName("test-multigateway", testutil.ErrNetworkTimeout), + }) + + reconciler := &MultiGatewayReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + err := reconciler.reconcileService(context.Background(), mg) + if err == nil { + t.Error("reconcileService() should error on Get failure") + } +} + +// TestUpdateStatus_GetError tests error path on Get StatefulSet (not NotFound). +func TestUpdateStatus_GetError(t *testing.T) { + scheme := runtime.NewScheme() + _ = multigresv1alpha1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + + mg := &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + } + + baseClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(mg). + WithStatusSubresource(&multigresv1alpha1.MultiGateway{}). + Build() + + fakeClient := testutil.NewFakeClientWithFailures(baseClient, &testutil.FailureConfig{ + OnGet: testutil.FailOnKeyName("test-multigateway", testutil.ErrNetworkTimeout), + }) + + reconciler := &MultiGatewayReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + err := reconciler.updateStatus(context.Background(), mg) + if err == nil { + t.Error("updateStatus() should error on Get failure") + } +} diff --git a/pkg/resource-handler/controller/multigateway/multigateway_controller_test.go b/pkg/resource-handler/controller/multigateway/multigateway_controller_test.go new file mode 100644 index 00000000..fe257bbe --- /dev/null +++ b/pkg/resource-handler/controller/multigateway/multigateway_controller_test.go @@ -0,0 +1,643 @@ +package multigateway + +import ( + "slices" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" + "github.com/numtide/multigres-operator/pkg/resource-handler/controller/testutil" +) + +func TestMultiGatewayReconciler_Reconcile(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + _ = multigresv1alpha1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + tests := map[string]struct { + mg *multigresv1alpha1.MultiGateway + existingObjects []client.Object + failureConfig *testutil.FailureConfig + // TODO: If wantErr is false but failureConfig is set, assertions may fail + // due to failure injection. This should be addressed when we need to test + // partial failures that don't prevent reconciliation success. + wantErr bool + wantRequeue bool + assertFunc func(t *testing.T, c client.Client, mg *multigresv1alpha1.MultiGateway) + }{ + ////---------------------------------------- + /// Success + //------------------------------------------ + "create all resources for new MultiGateway": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + existingObjects: []client.Object{}, + assertFunc: func(t *testing.T, c client.Client, mg *multigresv1alpha1.MultiGateway) { + // Verify all three resources were created + sts := &appsv1.Deployment{} + if err := c.Get(t.Context(), + types.NamespacedName{Name: "test-multigateway", Namespace: "default"}, + sts); err != nil { + t.Errorf("Deployment should exist: %v", err) + } + + svc := &corev1.Service{} + if err := c.Get(t.Context(), + types.NamespacedName{Name: "test-multigateway", Namespace: "default"}, + svc); err != nil { + t.Errorf("Service should exist: %v", err) + } + + // Verify defaults and finalizer + if *sts.Spec.Replicas != DefaultReplicas { + t.Errorf( + "Deployment replicas = %d, want %d", + *sts.Spec.Replicas, + DefaultReplicas, + ) + } + + updatedMultiGateway := &multigresv1alpha1.MultiGateway{} + if err := c.Get(t.Context(), types.NamespacedName{Name: "test-multigateway", Namespace: "default"}, updatedMultiGateway); err != nil { + t.Fatalf("Failed to get MultiGateway: %v", err) + } + if !slices.Contains(updatedMultiGateway.Finalizers, finalizerName) { + t.Errorf("Finalizer should be added") + } + }, + }, + "update existing resources": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-multigateway", + Namespace: "default", + Finalizers: []string{finalizerName}, + }, + Spec: multigresv1alpha1.MultiGatewaySpec{ + Replicas: int32Ptr(5), + Image: "foo/bar:1.2.3", + }, + }, + existingObjects: []client.Object{ + &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-multigateway", + Namespace: "default", + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: int32Ptr(3), // will be updated to 5 + }, + Status: appsv1.StatefulSetStatus{ + Replicas: 3, + ReadyReplicas: 3, + }, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-multigateway-headless", + Namespace: "default", + }, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-multigateway", + Namespace: "default", + }, + }, + }, + assertFunc: func(t *testing.T, c client.Client, mg *multigresv1alpha1.MultiGateway) { + dp := &appsv1.Deployment{} + err := c.Get(t.Context(), types.NamespacedName{ + Name: "existing-multigateway", + Namespace: "default", + }, dp) + if err != nil { + t.Fatalf("Failed to get Deployment: %v", err) + } + + if *dp.Spec.Replicas != 5 { + t.Errorf("Deployment replicas = %d, want 5", *dp.Spec.Replicas) + } + + if dp.Spec.Template.Spec.Containers[0].Image != "foo/bar:1.2.3" { + t.Errorf( + "Deployment image = %s, want foo/bar:1.2.3", + dp.Spec.Template.Spec.Containers[0].Image, + ) + } + }, + }, + "MultiGateway with cellName": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "multigateway-zone1", + Namespace: "default", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{ + CellName: "zone1", + }, + }, + existingObjects: []client.Object{}, + assertFunc: func(t *testing.T, c client.Client, mg *multigresv1alpha1.MultiGateway) { + dp := &appsv1.Deployment{} + if err := c.Get(t.Context(), + types.NamespacedName{Name: "multigateway-zone1", Namespace: "default"}, + dp); err != nil { + t.Fatalf("Failed to get Deployment: %v", err) + } + if dp.Labels["multigres.com/cell"] != "zone1" { + t.Errorf( + "Deployment cell label = %s, want zone1", + dp.Labels["multigres.com/cell"], + ) + } + + svc := &corev1.Service{} + if err := c.Get(t.Context(), + types.NamespacedName{Name: "multigateway-zone1", Namespace: "default"}, + svc); err != nil { + t.Fatalf("Failed to get Service: %v", err) + } + if svc.Labels["multigres.com/cell"] != "zone1" { + t.Errorf( + "Service cell label = %s, want zone1", + svc.Labels["multigres.com/cell"], + ) + } + }, + }, + "deletion with finalizer": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway-deletion", + Namespace: "default", + DeletionTimestamp: &metav1.Time{Time: metav1.Now().Time}, + Finalizers: []string{finalizerName}, + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + existingObjects: []client.Object{ + &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway-deletion", + Namespace: "default", + DeletionTimestamp: &metav1.Time{Time: metav1.Now().Time}, + Finalizers: []string{finalizerName}, + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + }, + assertFunc: func(t *testing.T, c client.Client, multigateway *multigresv1alpha1.MultiGateway) { + updatedMultiGateway := &multigresv1alpha1.MultiGateway{} + err := c.Get(t.Context(), + types.NamespacedName{Name: "test-multigateway-deletion", Namespace: "default"}, + updatedMultiGateway) + if err == nil { + t.Errorf( + "MultiGateway object should be deleted but still exists (finalizers: %v)", + updatedMultiGateway.Finalizers, + ) + } + }, + }, + "all replicas ready status": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway-ready", + Namespace: "default", + Finalizers: []string{finalizerName}, + }, + Spec: multigresv1alpha1.MultiGatewaySpec{ + Replicas: int32Ptr(3), + }, + }, + existingObjects: []client.Object{ + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway-ready", + Namespace: "default", + }, + Spec: appsv1.DeploymentSpec{ + Replicas: int32Ptr(3), + }, + Status: appsv1.DeploymentStatus{ + Replicas: 3, + ReadyReplicas: 3, + }, + }, + }, + assertFunc: func(t *testing.T, c client.Client, multigateway *multigresv1alpha1.MultiGateway) { + updatedMultiGateway := &multigresv1alpha1.MultiGateway{} + if err := c.Get(t.Context(), + types.NamespacedName{Name: "test-multigateway-ready", Namespace: "default"}, + updatedMultiGateway); err != nil { + t.Fatalf("Failed to get MultiGateway: %v", err) + } + + if !updatedMultiGateway.Status.Ready { + t.Error("Status.Ready should be true") + } + if updatedMultiGateway.Status.Replicas != 3 { + t.Errorf("Status.Replicas = %d, want 3", updatedMultiGateway.Status.Replicas) + } + if updatedMultiGateway.Status.ReadyReplicas != 3 { + t.Errorf( + "Status.ReadyReplicas = %d, want 3", + updatedMultiGateway.Status.ReadyReplicas, + ) + } + if len(updatedMultiGateway.Status.Conditions) == 0 { + t.Error("Status.Conditions should not be empty") + } else { + readyCondition := updatedMultiGateway.Status.Conditions[0] + if readyCondition.Type != "Ready" { + t.Errorf("Condition type = %s, want Ready", readyCondition.Type) + } + if readyCondition.Status != metav1.ConditionTrue { + t.Errorf("Condition status = %s, want True", readyCondition.Status) + } + } + + if !slices.Contains(updatedMultiGateway.Finalizers, finalizerName) { + t.Errorf("Finalizer should be present") + } + }, + }, + ////---------------------------------------- + /// Error + //------------------------------------------ + "error on status update": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + existingObjects: []client.Object{}, + failureConfig: &testutil.FailureConfig{ + OnStatusUpdate: testutil.FailOnObjectName( + "test-multigateway", + testutil.ErrInjected, + ), + }, + wantErr: true, + }, + "error on Get Deployment in updateStatus (network error)": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway-status", + Namespace: "default", + Finalizers: []string{finalizerName}, + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + existingObjects: []client.Object{ + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway-status", + Namespace: "default", + }, + }, + }, + failureConfig: &testutil.FailureConfig{ + // Fail Deployment Get after first successful call + // First Get succeeds (in reconcileDeployment) + // Second Get fails (in updateStatus) + OnGet: testutil.FailKeyAfterNCalls(1, testutil.ErrNetworkTimeout), + }, + wantErr: true, + }, + "error on Service create": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + existingObjects: []client.Object{}, + failureConfig: &testutil.FailureConfig{ + OnCreate: func(obj client.Object) error { + if svc, ok := obj.(*corev1.Service); ok && svc.Name == "test-multigateway" { + return testutil.ErrPermissionError + } + return nil + }, + }, + wantErr: true, + }, + "error on Service Update": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + Finalizers: []string{finalizerName}, + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + existingObjects: []client.Object{ + &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + }, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway-headless", + Namespace: "default", + }, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + }, + }, + }, + failureConfig: &testutil.FailureConfig{ + OnUpdate: func(obj client.Object) error { + if svc, ok := obj.(*corev1.Service); ok && svc.Name == "test-multigateway" { + return testutil.ErrInjected + } + return nil + }, + }, + wantErr: true, + }, + "error on Get Service (network error)": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway-svc", + Namespace: "default", + Finalizers: []string{finalizerName}, + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + existingObjects: []client.Object{ + &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway-svc", + Namespace: "default", + }, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway-svc-headless", + Namespace: "default", + }, + }, + }, + failureConfig: &testutil.FailureConfig{ + OnGet: testutil.FailOnNamespacedKeyName( + "test-multigateway-svc", + "default", + testutil.ErrNetworkTimeout, + ), + }, + wantErr: true, + }, + "error on Deployment create": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + existingObjects: []client.Object{}, + failureConfig: &testutil.FailureConfig{ + OnCreate: func(obj client.Object) error { + if _, ok := obj.(*appsv1.Deployment); ok { + return testutil.ErrPermissionError + } + return nil + }, + }, + wantErr: true, + }, + "error on Deployment Update": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + Finalizers: []string{finalizerName}, + }, + Spec: multigresv1alpha1.MultiGatewaySpec{ + Replicas: int32Ptr(5), + }, + }, + existingObjects: []client.Object{ + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + }, + Spec: appsv1.DeploymentSpec{ + Replicas: int32Ptr(3), + }, + }, + }, + failureConfig: &testutil.FailureConfig{ + OnUpdate: func(obj client.Object) error { + if _, ok := obj.(*appsv1.Deployment); ok { + return testutil.ErrInjected + } + return nil + }, + }, + wantErr: true, + }, + "error on Get Deployment (network error)": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + Finalizers: []string{finalizerName}, + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + existingObjects: []client.Object{}, + failureConfig: &testutil.FailureConfig{ + OnGet: func(key client.ObjectKey) error { + if key.Name == "test-multigateway" { + return testutil.ErrNetworkTimeout + } + return nil + }, + }, + wantErr: true, + }, + "error on finalizer Update": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + existingObjects: []client.Object{}, + failureConfig: &testutil.FailureConfig{ + OnUpdate: testutil.FailOnObjectName("test-multigateway", testutil.ErrInjected), + }, + wantErr: true, + }, + "deletion error on finalizer removal": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway-del", + Namespace: "default", + DeletionTimestamp: &metav1.Time{Time: metav1.Now().Time}, + Finalizers: []string{finalizerName}, + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + existingObjects: []client.Object{ + &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway-del", + Namespace: "default", + DeletionTimestamp: &metav1.Time{Time: metav1.Now().Time}, + Finalizers: []string{finalizerName}, + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + }, + failureConfig: &testutil.FailureConfig{ + OnUpdate: testutil.FailOnObjectName("test-multigateway-del", testutil.ErrInjected), + }, + wantErr: true, + }, + "error on Get MultiGateway (network error)": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + existingObjects: []client.Object{}, + failureConfig: &testutil.FailureConfig{ + OnGet: testutil.FailOnKeyName("test-multigateway", testutil.ErrNetworkTimeout), + }, + wantErr: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + // Create base fake client + baseClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(tc.existingObjects...). + WithStatusSubresource(&multigresv1alpha1.MultiGateway{}). + Build() + + fakeClient := client.Client(baseClient) + // Wrap with failure injection if configured + if tc.failureConfig != nil { + fakeClient = testutil.NewFakeClientWithFailures(baseClient, tc.failureConfig) + } + + reconciler := &MultiGatewayReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + // Create the MultiGateway resource if not in existing objects + mgInExisting := false + for _, obj := range tc.existingObjects { + if mg, ok := obj.(*multigresv1alpha1.MultiGateway); ok && mg.Name == tc.mg.Name { + mgInExisting = true + break + } + } + if !mgInExisting { + err := fakeClient.Create(t.Context(), tc.mg) + if err != nil { + t.Fatalf("Failed to create MultiGateway: %v", err) + } + } + + // Reconcile + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: tc.mg.Name, + Namespace: tc.mg.Namespace, + }, + } + + result, err := reconciler.Reconcile(t.Context(), req) + if (err != nil) != tc.wantErr { + t.Errorf("Reconcile() error = %v, wantErr %v", err, tc.wantErr) + return + } + if tc.wantErr { + return + } + + // NOTE: Check for requeue delay when we need to support such setup. + _ = result + // // Check requeue + // if (result.RequeueAfter != 0) != tc.wantRequeue { + // t.Errorf("Reconcile() result.Requeue = %v, want %v", result.RequeueAfter, tc.wantRequeue) + // } + + // Run custom assertions if provided + if tc.assertFunc != nil { + tc.assertFunc(t, fakeClient, tc.mg) + } + }) + } +} + +func TestMultiGatewayReconciler_ReconcileNotFound(t *testing.T) { + scheme := runtime.NewScheme() + _ = multigresv1alpha1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + reconciler := &MultiGatewayReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + // Reconcile non-existent resource + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: "nonexistent-multigateway", + Namespace: "default", + }, + } + + result, err := reconciler.Reconcile(t.Context(), req) + if err != nil { + t.Errorf("Reconcile() should not error on NotFound, got: %v", err) + } + if result.RequeueAfter > 0 { + t.Errorf("Reconcile() should not requeue on NotFound") + } +} diff --git a/pkg/resource-handler/controller/multigateway/ports.go b/pkg/resource-handler/controller/multigateway/ports.go new file mode 100644 index 00000000..25ecf271 --- /dev/null +++ b/pkg/resource-handler/controller/multigateway/ports.go @@ -0,0 +1,94 @@ +package multigateway + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" +) + +const ( + // HTTPPort is the default port for HTTP connections. + HTTPPort int32 = 15100 + + // GRPCPort is the default port for GRPC connections. + GRPCPort int32 = 15170 + + // PostgresPort is the default port for database connections. + PostgresPort int32 = 15432 +) + +// buildContainerPorts creates the port definitions for the etcd container. +// Uses default ports since MultiGatewaySpec doesn't have port configuration yet. +func buildContainerPorts(mg *multigresv1alpha1.MultiGateway) []corev1.ContainerPort { + httpPort := HTTPPort + grpcPort := GRPCPort + postgresPort := PostgresPort + + if mg.Spec.HTTPPort != 0 { + httpPort = mg.Spec.HTTPPort + } + if mg.Spec.GRPCPort != 0 { + grpcPort = mg.Spec.GRPCPort + } + if mg.Spec.PostgresPort != 0 { + postgresPort = mg.Spec.PostgresPort + } + + return []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: httpPort, + Protocol: corev1.ProtocolTCP, + }, + { + Name: "grpc", + ContainerPort: grpcPort, + Protocol: corev1.ProtocolTCP, + }, + { + Name: "postgres", + ContainerPort: postgresPort, + Protocol: corev1.ProtocolTCP, + }, + } +} + +// buildServicePorts creates service ports for the client service. +// Only includes the client port for external access. +func buildServicePorts(mg *multigresv1alpha1.MultiGateway) []corev1.ServicePort { + httpPort := HTTPPort + grpcPort := GRPCPort + postgresPort := PostgresPort + + if mg.Spec.HTTPPort != 0 { + httpPort = mg.Spec.HTTPPort + } + if mg.Spec.GRPCPort != 0 { + grpcPort = mg.Spec.GRPCPort + } + if mg.Spec.PostgresPort != 0 { + postgresPort = mg.Spec.PostgresPort + } + + return []corev1.ServicePort{ + { + Name: "http", + Port: httpPort, + Protocol: corev1.ProtocolTCP, + TargetPort: intstr.FromString("http"), + }, + { + Name: "grpc", + Port: grpcPort, + Protocol: corev1.ProtocolTCP, + TargetPort: intstr.FromString("grpc"), + }, + { + Name: "postgres", + Port: postgresPort, + Protocol: corev1.ProtocolTCP, + TargetPort: intstr.FromString("postgres"), + }, + } +} diff --git a/pkg/resource-handler/controller/multigateway/ports_test.go b/pkg/resource-handler/controller/multigateway/ports_test.go new file mode 100644 index 00000000..3ce18c1d --- /dev/null +++ b/pkg/resource-handler/controller/multigateway/ports_test.go @@ -0,0 +1,166 @@ +package multigateway + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" +) + +func TestBuildContainerPorts(t *testing.T) { + tests := map[string]struct { + mg *multigresv1alpha1.MultiGateway + want []corev1.ContainerPort + }{ + "default ports": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-etcd", + Namespace: "default", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + want: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: 15100, + Protocol: corev1.ProtocolTCP, + }, + { + Name: "grpc", + ContainerPort: 15170, + Protocol: corev1.ProtocolTCP, + }, + { + Name: "postgres", + ContainerPort: 15432, + Protocol: corev1.ProtocolTCP, + }, + }, + }, + "custom ports": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-etcd", + Namespace: "default", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{ + HTTPPort: 1, + GRPCPort: 2, + PostgresPort: 3, + }, + }, + want: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: 1, + Protocol: corev1.ProtocolTCP, + }, + + { + Name: "grpc", + ContainerPort: 2, + Protocol: corev1.ProtocolTCP, + }, + { + Name: "postgres", + ContainerPort: 3, + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := buildContainerPorts(tc.mg) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("buildContainerPorts() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestBuildServicePorts(t *testing.T) { + tests := map[string]struct { + mg *multigresv1alpha1.MultiGateway + want []corev1.ServicePort + }{ + "default ports": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-etcd", + Namespace: "default", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + want: []corev1.ServicePort{ + { + Name: "http", + Port: 15100, + TargetPort: intstr.FromString("http"), + Protocol: corev1.ProtocolTCP, + }, + { + Name: "grpc", + Port: 15170, + TargetPort: intstr.FromString("grpc"), + Protocol: corev1.ProtocolTCP, + }, + { + Name: "postgres", + Port: 15432, + TargetPort: intstr.FromString("postgres"), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + "custom ports": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-etcd", + Namespace: "default", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{ + HTTPPort: 1, + GRPCPort: 2, + PostgresPort: 3, + }, + }, + want: []corev1.ServicePort{ + { + Name: "http", + Port: 1, + TargetPort: intstr.FromString("http"), + Protocol: corev1.ProtocolTCP, + }, + + { + Name: "grpc", + Port: 2, + TargetPort: intstr.FromString("grpc"), + Protocol: corev1.ProtocolTCP, + }, + { + Name: "postgres", + Port: 3, + TargetPort: intstr.FromString("postgres"), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := buildServicePorts(tc.mg) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("buildServicePorts() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/pkg/resource-handler/controller/multigateway/service.go b/pkg/resource-handler/controller/multigateway/service.go new file mode 100644 index 00000000..aa7d1bc3 --- /dev/null +++ b/pkg/resource-handler/controller/multigateway/service.go @@ -0,0 +1,41 @@ +package multigateway + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + + multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" + "github.com/numtide/multigres-operator/pkg/resource-handler/controller/metadata" +) + +// BuildService creates a client Service for external access to Etcd. +// This service load balances across all etcd members. +func BuildService( + mg *multigresv1alpha1.MultiGateway, + scheme *runtime.Scheme, +) (*corev1.Service, error) { + labels := metadata.BuildStandardLabels(mg.Name, ComponentName, mg.Spec.CellName) + + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: mg.Name, + Namespace: mg.Namespace, + Labels: labels, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Selector: labels, + Ports: buildServicePorts(mg), + }, + } + + if err := ctrl.SetControllerReference(mg, svc, scheme); err != nil { + return nil, fmt.Errorf("failed to set controller reference: %w", err) + } + + return svc, nil +} diff --git a/pkg/resource-handler/controller/multigateway/service_test.go b/pkg/resource-handler/controller/multigateway/service_test.go new file mode 100644 index 00000000..4ed98885 --- /dev/null +++ b/pkg/resource-handler/controller/multigateway/service_test.go @@ -0,0 +1,102 @@ +package multigateway + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" +) + +func TestBuildService(t *testing.T) { + scheme := runtime.NewScheme() + _ = multigresv1alpha1.AddToScheme(scheme) + + tests := map[string]struct { + mg *multigresv1alpha1.MultiGateway + scheme *runtime.Scheme + want *corev1.Service + wantErr bool + }{ + "minimal spec": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + UID: "test-uid", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + scheme: scheme, + want: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + Labels: map[string]string{ + "app.kubernetes.io/name": "multigres", + "app.kubernetes.io/instance": "test-multigateway", + "app.kubernetes.io/component": "multigateway", + "app.kubernetes.io/part-of": "multigres", + "app.kubernetes.io/managed-by": "multigres-operator", + "multigres.com/cell": "multigres-global-topo", + }, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "multigres.com/v1alpha1", + Kind: "MultiGateway", + Name: "test-multigateway", + UID: "test-uid", + Controller: boolPtr(true), + BlockOwnerDeletion: boolPtr(true), + }, + }, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Selector: map[string]string{ + "app.kubernetes.io/name": "multigres", + "app.kubernetes.io/instance": "test-multigateway", + "app.kubernetes.io/component": "multigateway", + "app.kubernetes.io/part-of": "multigres", + "app.kubernetes.io/managed-by": "multigres-operator", + "multigres.com/cell": "multigres-global-topo", + }, + Ports: buildServicePorts(&multigresv1alpha1.MultiGateway{}), + }, + }, + }, + "scheme with incorrect type - should error": { + mg: &multigresv1alpha1.MultiGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multigateway", + Namespace: "default", + }, + Spec: multigresv1alpha1.MultiGatewaySpec{}, + }, + scheme: runtime.NewScheme(), // empty scheme with incorrect type + wantErr: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got, err := BuildService(tc.mg, tc.scheme) + + if (err != nil) != tc.wantErr { + t.Errorf("BuildClientService() error = %v, wantErr %v", err, tc.wantErr) + return + } + + if tc.wantErr { + return + } + + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("BuildClientService() mismatch (-want +got):\n%s", diff) + } + }) + } +}