From 6fedbeeddeef90be8351bfabc063199c1a6aab30 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Thu, 13 Aug 2026 02:45:19 +0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AA=20test:=20add=20operator=20reconci?= =?UTF-8?q?le=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- operator/internal/controller/apply_test.go | 49 +- operator/internal/controller/helpers_test.go | 103 ++++ .../controller/minecraftserver_controller.go | 30 +- .../minecraftserver_controller_test.go | 400 +++++++++++++++ .../reverseproxyserver_controller.go | 29 +- .../reverseproxyserver_controller_test.go | 484 ++++++++++++++++++ operator/internal/controller/status.go | 21 + operator/internal/controller/status_test.go | 152 ++++++ operator/internal/resources/common.go | 22 +- operator/internal/resources/common_test.go | 109 ++++ .../internal/resources/minecraftserver.go | 2 - .../resources/minecraftserver_test.go | 71 +++ operator/internal/resources/naming_test.go | 60 +++ .../internal/resources/reverseproxyserver.go | 2 +- .../resources/reverseproxyserver_test.go | 70 +++ 15 files changed, 1543 insertions(+), 61 deletions(-) create mode 100644 operator/internal/controller/helpers_test.go create mode 100644 operator/internal/controller/minecraftserver_controller_test.go create mode 100644 operator/internal/controller/reverseproxyserver_controller_test.go create mode 100644 operator/internal/controller/status_test.go create mode 100644 operator/internal/resources/naming_test.go diff --git a/operator/internal/controller/apply_test.go b/operator/internal/controller/apply_test.go index 24ec645..d064a5f 100644 --- a/operator/internal/controller/apply_test.go +++ b/operator/internal/controller/apply_test.go @@ -1,30 +1,18 @@ package controller import ( + "context" "encoding/json" "testing" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/apiutil" v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1" ) -func testScheme(t *testing.T) *runtime.Scheme { - t.Helper() - s := runtime.NewScheme() - if err := clientgoscheme.AddToScheme(s); err != nil { - t.Fatalf("add client-go scheme: %v", err) - } - if err := v1alpha1.AddToScheme(s); err != nil { - t.Fatalf("add v1alpha1 scheme: %v", err) - } - return s -} - func TestTypedObjectMarshalsWithoutTypeMeta(t *testing.T) { cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "a", Namespace: "b"}} @@ -89,3 +77,36 @@ func TestApplySetsTypeMeta(t *testing.T) { t.Error("owner reference should be a controller reference") } } + +func TestApplyCreatesAndUpdates(t *testing.T) { + owner := testMinecraft("smp", v1alpha1.ServerStateful) + c := newFakeClient(t, owner) + scheme := testScheme(t) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "minecraft-smp-config", Namespace: owner.Namespace}, + Data: map[string]string{"server-type": "STATEFUL"}, + } + if err := apply(context.Background(), c, owner, cm, scheme); err != nil { + t.Fatalf("create apply: %v", err) + } + + var got corev1.ConfigMap + mustGet(t, c, client.ObjectKeyFromObject(cm), &got) + if got.Data["server-type"] != "STATEFUL" { + t.Errorf("data = %v", got.Data) + } + if len(got.OwnerReferences) != 1 { + t.Fatalf("ownerReferences = %d, want 1", len(got.OwnerReferences)) + } + + updated := got.DeepCopy() + updated.Data["server-type"] = "STATELESS" + if err := apply(context.Background(), c, owner, updated, scheme); err != nil { + t.Fatalf("update apply: %v", err) + } + mustGet(t, c, client.ObjectKeyFromObject(cm), &got) + if got.Data["server-type"] != "STATELESS" { + t.Errorf("updated data = %v", got.Data) + } +} diff --git a/operator/internal/controller/helpers_test.go b/operator/internal/controller/helpers_test.go new file mode 100644 index 0000000..f8ce621 --- /dev/null +++ b/operator/internal/controller/helpers_test.go @@ -0,0 +1,103 @@ +package controller + +import ( + "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" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1" +) + +func testScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(s); err != nil { + t.Fatalf("add client-go scheme: %v", err) + } + if err := v1alpha1.AddToScheme(s); err != nil { + t.Fatalf("add v1alpha1 scheme: %v", err) + } + return s +} + +func newFakeClient(t *testing.T, objs ...client.Object) client.Client { + t.Helper() + return newInterceptedClient(t, interceptor.Funcs{}, objs...) +} + +func newInterceptedClient(t *testing.T, funcs interceptor.Funcs, objs ...client.Object) client.Client { + t.Helper() + return fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithStatusSubresource( + &v1alpha1.MinecraftServer{}, + &v1alpha1.ReverseProxyServer{}, + &appsv1.Deployment{}, + &appsv1.StatefulSet{}, + &corev1.Service{}, + ). + WithObjects(objs...). + WithInterceptorFuncs(funcs). + Build() +} + +func requestFor(obj client.Object) reconcile.Request { + return reconcile.Request{NamespacedName: types.NamespacedName{ + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + }} +} + +func testMinecraft(name string, kind v1alpha1.ServerKind) *v1alpha1.MinecraftServer { + return &v1alpha1.MinecraftServer{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1alpha1.GroupVersion.String(), + Kind: "MinecraftServer", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "minikura", + UID: types.UID("uid-" + name), + Generation: 1, + }, + Spec: v1alpha1.MinecraftServerSpec{ + Type: kind, + ListenPort: 25565, + ServiceType: v1alpha1.ExposureClusterIP, + JarType: "PAPER", + MinecraftVersion: "1.20.4", + StorageSize: "10Gi", + }, + } +} + +func testProxy(name string, kind v1alpha1.ProxyKind) *v1alpha1.ReverseProxyServer { + return &v1alpha1.ReverseProxyServer{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1alpha1.GroupVersion.String(), + Kind: "ReverseProxyServer", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "minikura", + UID: types.UID("uid-" + name), + Generation: 1, + }, + Spec: v1alpha1.ReverseProxyServerSpec{ + Type: kind, + ExternalAddress: "play.example.com", + ExternalPort: 25565, + ListenPort: 25577, + ServiceType: v1alpha1.ExposureLoadBalancer, + }, + } +} diff --git a/operator/internal/controller/minecraftserver_controller.go b/operator/internal/controller/minecraftserver_controller.go index 7efb580..883c4c9 100644 --- a/operator/internal/controller/minecraftserver_controller.go +++ b/operator/internal/controller/minecraftserver_controller.go @@ -2,7 +2,6 @@ package controller import ( "context" - "fmt" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -116,10 +115,7 @@ func (r *MinecraftServerReconciler) updateStatus(ctx context.Context, mc *v1alph } } - phase := v1alpha1.PhasePending - if ready > 0 { - phase = v1alpha1.PhaseRunning - } + phase := phaseFromReady(ready) endpoint, err := r.endpoint(ctx, mc) if err != nil { @@ -156,21 +152,17 @@ func (r *MinecraftServerReconciler) endpoint(ctx context.Context, mc *v1alpha1.M } func (r *MinecraftServerReconciler) fail(ctx context.Context, mc *v1alpha1.MinecraftServer, reason string, cause error) (ctrl.Result, error) { - patch := client.MergeFrom(mc.DeepCopy()) - mc.Status.Phase = v1alpha1.PhaseFailed - mc.Status.Message = cause.Error() - setCondition(&mc.Status.Conditions, metav1.Condition{ - Type: v1alpha1.ConditionReady, - Status: metav1.ConditionFalse, - Reason: reason, - Message: cause.Error(), - ObservedGeneration: mc.Generation, + return failWithStatus(ctx, r.Client, mc, cause, func() { + mc.Status.Phase = v1alpha1.PhaseFailed + mc.Status.Message = cause.Error() + setCondition(&mc.Status.Conditions, metav1.Condition{ + Type: v1alpha1.ConditionReady, + Status: metav1.ConditionFalse, + Reason: reason, + Message: cause.Error(), + ObservedGeneration: mc.Generation, + }) }) - - if err := r.Status().Patch(ctx, mc, patch); err != nil { - return ctrl.Result{}, fmt.Errorf("%w (status patch failed: %v)", cause, err) - } - return ctrl.Result{}, cause } func (r *MinecraftServerReconciler) SetupWithManager(mgr ctrl.Manager) error { diff --git a/operator/internal/controller/minecraftserver_controller_test.go b/operator/internal/controller/minecraftserver_controller_test.go new file mode 100644 index 0000000..9eb489e --- /dev/null +++ b/operator/internal/controller/minecraftserver_controller_test.go @@ -0,0 +1,400 @@ +package controller + +import ( + "context" + "errors" + "strings" + "testing" + + 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" + "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/interceptor" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1" + "github.com/YuzuZensai/Minikura/operator/internal/resources" +) + +func TestMinecraftReconcileStatelessCreatesResources(t *testing.T) { + mc := testMinecraft("lobby", v1alpha1.ServerStateless) + c := newFakeClient(t, mc) + r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} + + if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { + t.Fatalf("reconcile: %v", err) + } + + name := resources.ServerName(mc.Name) + ns := mc.Namespace + mustGet(t, c, client.ObjectKey{Name: resources.ConfigMapName(name), Namespace: ns}, &corev1.ConfigMap{}) + mustGet(t, c, client.ObjectKey{Name: name, Namespace: ns}, &corev1.Service{}) + mustGet(t, c, client.ObjectKey{Name: name, Namespace: ns}, &appsv1.Deployment{}) + + var sts appsv1.StatefulSet + if err := c.Get(context.Background(), client.ObjectKey{Name: name, Namespace: ns}, &sts); !apierrors.IsNotFound(err) { + t.Fatalf("expected no statefulset, got %v", err) + } + + var got v1alpha1.MinecraftServer + mustGet(t, c, client.ObjectKeyFromObject(mc), &got) + if got.Status.Phase != v1alpha1.PhasePending { + t.Errorf("phase = %q, want Pending", got.Status.Phase) + } + if got.Status.ObservedGeneration != 1 { + t.Errorf("observedGeneration = %d, want 1", got.Status.ObservedGeneration) + } + if got.Status.Endpoint != "minecraft-lobby.minikura.svc.cluster.local:25565" { + t.Errorf("endpoint = %q", got.Status.Endpoint) + } +} + +func TestMinecraftReconcileStatefulCreatesStatefulSet(t *testing.T) { + mc := testMinecraft("smp", v1alpha1.ServerStateful) + c := newFakeClient(t, mc) + r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} + + if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { + t.Fatalf("reconcile: %v", err) + } + + name := resources.ServerName(mc.Name) + mustGet(t, c, client.ObjectKey{Name: name, Namespace: mc.Namespace}, &appsv1.StatefulSet{}) + + var dep appsv1.Deployment + if err := c.Get(context.Background(), client.ObjectKey{Name: name, Namespace: mc.Namespace}, &dep); !apierrors.IsNotFound(err) { + t.Fatalf("expected no deployment, got %v", err) + } +} + +func TestMinecraftReconcilePrunesOppositeWorkload(t *testing.T) { + mc := testMinecraft("swap", v1alpha1.ServerStateless) + c := newFakeClient(t, mc) + r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} + + if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { + t.Fatalf("first reconcile: %v", err) + } + + var current v1alpha1.MinecraftServer + mustGet(t, c, client.ObjectKeyFromObject(mc), ¤t) + current.Spec.Type = v1alpha1.ServerStateful + current.Generation = 2 + if err := c.Update(context.Background(), ¤t); err != nil { + t.Fatalf("update type: %v", err) + } + + if _, err := r.Reconcile(context.Background(), requestFor(¤t)); err != nil { + t.Fatalf("second reconcile: %v", err) + } + + name := resources.ServerName(mc.Name) + key := client.ObjectKey{Name: name, Namespace: mc.Namespace} + mustGet(t, c, key, &appsv1.StatefulSet{}) + + var dep appsv1.Deployment + if err := c.Get(context.Background(), key, &dep); !apierrors.IsNotFound(err) { + t.Fatalf("expected deployment pruned, got %v", err) + } +} + +func TestMinecraftReconcileIgnoresNotFound(t *testing.T) { + r := &MinecraftServerReconciler{Client: newFakeClient(t), Scheme: testScheme(t)} + res, err := r.Reconcile(context.Background(), reconcile.Request{ + NamespacedName: types.NamespacedName{Name: "missing", Namespace: "minikura"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res != (ctrl.Result{}) { + t.Errorf("result = %#v, want empty", res) + } +} + +func TestMinecraftReconcileSkipsDeleting(t *testing.T) { + now := metav1.Now() + mc := testMinecraft("gone", v1alpha1.ServerStateless) + mc.DeletionTimestamp = &now + mc.Finalizers = []string{"test.finalizer"} + + c := newFakeClient(t, mc) + r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} + if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { + t.Fatalf("reconcile: %v", err) + } + + var cm corev1.ConfigMap + err := c.Get(context.Background(), client.ObjectKey{ + Name: resources.ConfigMapName(resources.ServerName(mc.Name)), + Namespace: mc.Namespace, + }, &cm) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected no resources for deleting object, got %v", err) + } +} + +func TestMinecraftReconcileInvalidStorageFails(t *testing.T) { + mc := testMinecraft("bad", v1alpha1.ServerStateful) + mc.Spec.StorageSize = "not-a-size" + c := newFakeClient(t, mc) + r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} + + if _, err := r.Reconcile(context.Background(), requestFor(mc)); err == nil { + t.Fatal("expected error for invalid storageSize") + } + + var got v1alpha1.MinecraftServer + mustGet(t, c, client.ObjectKeyFromObject(mc), &got) + if got.Status.Phase != v1alpha1.PhaseFailed { + t.Errorf("phase = %q, want Failed", got.Status.Phase) + } + if got.Status.Message == "" { + t.Error("expected a failure message") + } +} + +func TestMinecraftPruneSkipsUnownedWorkload(t *testing.T) { + mc := testMinecraft("swap", v1alpha1.ServerStateful) + stale := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: resources.ServerName(mc.Name), + Namespace: mc.Namespace, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "x"}}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "x"}}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "c", Image: "busybox"}}}, + }, + }, + } + c := newFakeClient(t, mc, stale) + r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} + if err := r.pruneOppositeWorkload(context.Background(), mc, true); err != nil { + t.Fatalf("prune: %v", err) + } + mustGet(t, c, client.ObjectKeyFromObject(stale), &appsv1.Deployment{}) +} + +func TestMinecraftStatusUsesReadyReplicas(t *testing.T) { + mc := testMinecraft("ready", v1alpha1.ServerStateless) + c := newFakeClient(t, mc) + r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} + + if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { + t.Fatalf("reconcile: %v", err) + } + + var dep appsv1.Deployment + mustGet(t, c, client.ObjectKey{Name: resources.ServerName(mc.Name), Namespace: mc.Namespace}, &dep) + dep.Status.Replicas = 1 + dep.Status.ReadyReplicas = 1 + if err := c.Status().Update(context.Background(), &dep); err != nil { + t.Fatalf("update deployment status: %v", err) + } + + if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { + t.Fatalf("second reconcile: %v", err) + } + + var got v1alpha1.MinecraftServer + mustGet(t, c, client.ObjectKeyFromObject(mc), &got) + if got.Status.Phase != v1alpha1.PhaseRunning { + t.Errorf("phase = %q, want Running", got.Status.Phase) + } + if got.Status.ReadyReplicas != 1 { + t.Errorf("readyReplicas = %d, want 1", got.Status.ReadyReplicas) + } +} + +func TestMinecraftReconcileApplyFailure(t *testing.T) { + mc := testMinecraft("lobby", v1alpha1.ServerStateless) + c := newInterceptedClient(t, interceptor.Funcs{ + Patch: func(ctx context.Context, c client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if _, ok := obj.(*corev1.ConfigMap); ok { + return errors.New("configmap apply failed") + } + return c.Patch(ctx, obj, patch, opts...) + }, + }, mc) + r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} + + if _, err := r.Reconcile(context.Background(), requestFor(mc)); err == nil { + t.Fatal("expected apply failure") + } + + var got v1alpha1.MinecraftServer + mustGet(t, c, client.ObjectKeyFromObject(mc), &got) + if got.Status.Phase != v1alpha1.PhaseFailed { + t.Errorf("phase = %q, want Failed", got.Status.Phase) + } + if !strings.Contains(got.Status.Message, "configmap apply failed") { + t.Errorf("message = %q", got.Status.Message) + } +} + +func TestMinecraftReconcileServiceFailure(t *testing.T) { + mc := testMinecraft("lobby", v1alpha1.ServerStateless) + c := newInterceptedClient(t, interceptor.Funcs{ + Patch: func(ctx context.Context, c client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if _, ok := obj.(*corev1.Service); ok { + return errors.New("service apply failed") + } + return c.Patch(ctx, obj, patch, opts...) + }, + }, mc) + r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} + if _, err := r.Reconcile(context.Background(), requestFor(mc)); err == nil { + t.Fatal("expected service apply failure") + } +} + +func TestMinecraftReconcileContinuesAfterPruneError(t *testing.T) { + mc := testMinecraft("lobby", v1alpha1.ServerStateless) + c := newInterceptedClient(t, interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*appsv1.StatefulSet); ok { + return errors.New("sts lookup failed") + } + return c.Get(ctx, key, obj, opts...) + }, + }, mc) + r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} + if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { + t.Fatalf("prune errors should not fail reconcile: %v", err) + } +} + +func TestMinecraftUpdateStatusWorkloadGetError(t *testing.T) { + mc := testMinecraft("lobby", v1alpha1.ServerStateless) + c := newInterceptedClient(t, interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*appsv1.Deployment); ok { + return errors.New("deployment get failed") + } + return c.Get(ctx, key, obj, opts...) + }, + }, mc) + r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} + if err := r.updateStatus(context.Background(), mc, false); err == nil { + t.Fatal("expected workload get error") + } +} + +func TestMinecraftUpdateStatusStatefulGetError(t *testing.T) { + mc := testMinecraft("smp", v1alpha1.ServerStateful) + c := newInterceptedClient(t, interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*appsv1.StatefulSet); ok { + return errors.New("sts get failed") + } + return c.Get(ctx, key, obj, opts...) + }, + }, mc) + r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} + if err := r.updateStatus(context.Background(), mc, true); err == nil { + t.Fatal("expected statefulset get error") + } +} + +func TestMinecraftUpdateStatusEndpointError(t *testing.T) { + mc := testMinecraft("lobby", v1alpha1.ServerStateless) + c := newInterceptedClient(t, interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*corev1.Service); ok { + return errors.New("service get failed") + } + return c.Get(ctx, key, obj, opts...) + }, + }, mc) + r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} + if err := r.updateStatus(context.Background(), mc, false); err == nil { + t.Fatal("expected endpoint get error") + } +} + +func TestMinecraftEndpointGetError(t *testing.T) { + mc := testMinecraft("lobby", v1alpha1.ServerStateless) + c := newInterceptedClient(t, interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*corev1.Service); ok { + return errors.New("service get failed") + } + return c.Get(ctx, key, obj, opts...) + }, + }, mc) + r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} + if _, err := r.endpoint(context.Background(), mc); err == nil { + t.Fatal("expected service get error") + } +} + +func TestMinecraftEndpointMissingService(t *testing.T) { + mc := testMinecraft("lobby", v1alpha1.ServerStateless) + r := &MinecraftServerReconciler{Client: newFakeClient(t, mc), Scheme: testScheme(t)} + got, err := r.endpoint(context.Background(), mc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Errorf("endpoint = %q, want empty", got) + } +} + +func TestMinecraftStatusUsesStatefulReadyReplicas(t *testing.T) { + mc := testMinecraft("smp", v1alpha1.ServerStateful) + c := newFakeClient(t, mc) + r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} + if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { + t.Fatalf("reconcile: %v", err) + } + + var sts appsv1.StatefulSet + mustGet(t, c, client.ObjectKey{Name: resources.ServerName(mc.Name), Namespace: mc.Namespace}, &sts) + sts.Status.Replicas = 1 + sts.Status.ReadyReplicas = 1 + if err := c.Status().Update(context.Background(), &sts); err != nil { + t.Fatalf("update sts status: %v", err) + } + if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { + t.Fatalf("second reconcile: %v", err) + } + + var got v1alpha1.MinecraftServer + mustGet(t, c, client.ObjectKeyFromObject(mc), &got) + if got.Status.Phase != v1alpha1.PhaseRunning || got.Status.ReadyReplicas != 1 { + t.Errorf("status = %+v", got.Status) + } +} + +func TestApplyRejectsCrossNamespaceOwner(t *testing.T) { + owner := testMinecraft("smp", v1alpha1.ServerStateful) + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "x", Namespace: "other"}} + if err := apply(context.Background(), newFakeClient(t), owner, cm, testScheme(t)); err == nil { + t.Fatal("expected setOwner to fail across namespaces") + } +} + +func TestApplyRejectsUnknownType(t *testing.T) { + scheme := runtime.NewScheme() + if err := v1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + owner := testMinecraft("smp", v1alpha1.ServerStateful) + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "x", Namespace: owner.Namespace}} + if err := apply(context.Background(), newFakeClient(t, owner), owner, cm, scheme); err == nil { + t.Fatal("expected GVK lookup to fail") + } +} + +func mustGet(t *testing.T, c client.Client, key client.ObjectKey, obj client.Object) { + t.Helper() + if err := c.Get(context.Background(), key, obj); err != nil { + t.Fatalf("get %T %s: %v", obj, key, err) + } +} diff --git a/operator/internal/controller/reverseproxyserver_controller.go b/operator/internal/controller/reverseproxyserver_controller.go index b718359..bbcef04 100644 --- a/operator/internal/controller/reverseproxyserver_controller.go +++ b/operator/internal/controller/reverseproxyserver_controller.go @@ -136,10 +136,7 @@ func (r *ReverseProxyServerReconciler) updateStatus(ctx context.Context, rp *v1a return err } - phase := v1alpha1.PhasePending - if ready > 0 { - phase = v1alpha1.PhaseRunning - } + phase := phaseFromReady(ready) patch := client.MergeFrom(rp.DeepCopy()) rp.Status.Phase = phase @@ -160,21 +157,17 @@ func (r *ReverseProxyServerReconciler) updateStatus(ctx context.Context, rp *v1a } func (r *ReverseProxyServerReconciler) fail(ctx context.Context, rp *v1alpha1.ReverseProxyServer, reason string, cause error) (ctrl.Result, error) { - patch := client.MergeFrom(rp.DeepCopy()) - rp.Status.Phase = v1alpha1.PhaseFailed - rp.Status.Message = cause.Error() - setCondition(&rp.Status.Conditions, metav1.Condition{ - Type: v1alpha1.ConditionReady, - Status: metav1.ConditionFalse, - Reason: reason, - Message: cause.Error(), - ObservedGeneration: rp.Generation, + return failWithStatus(ctx, r.Client, rp, cause, func() { + rp.Status.Phase = v1alpha1.PhaseFailed + rp.Status.Message = cause.Error() + setCondition(&rp.Status.Conditions, metav1.Condition{ + Type: v1alpha1.ConditionReady, + Status: metav1.ConditionFalse, + Reason: reason, + Message: cause.Error(), + ObservedGeneration: rp.Generation, + }) }) - - if err := r.Status().Patch(ctx, rp, patch); err != nil { - return ctrl.Result{}, fmt.Errorf("%w (status patch failed: %v)", cause, err) - } - return ctrl.Result{}, cause } func (r *ReverseProxyServerReconciler) proxiesForServer(ctx context.Context, obj client.Object) []reconcile.Request { diff --git a/operator/internal/controller/reverseproxyserver_controller_test.go b/operator/internal/controller/reverseproxyserver_controller_test.go new file mode 100644 index 0000000..f633f84 --- /dev/null +++ b/operator/internal/controller/reverseproxyserver_controller_test.go @@ -0,0 +1,484 @@ +package controller + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + 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/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1" + "github.com/YuzuZensai/Minikura/operator/internal/resources" +) + +func TestProxyReconcileCreatesResources(t *testing.T) { + rp := testProxy("edge", v1alpha1.ProxyVelocity) + c := newFakeClient(t, rp) + r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} + + if _, err := r.Reconcile(context.Background(), requestFor(rp)); err != nil { + t.Fatalf("reconcile: %v", err) + } + + name := resources.ProxyName(rp.Spec.Type, rp.Name) + ns := rp.Namespace + mustGet(t, c, client.ObjectKey{Name: resources.ConfigMapName(name), Namespace: ns}, &corev1.ConfigMap{}) + mustGet(t, c, client.ObjectKey{Name: name, Namespace: ns}, &corev1.Service{}) + mustGet(t, c, client.ObjectKey{Name: name, Namespace: ns}, &appsv1.Deployment{}) + + var got v1alpha1.ReverseProxyServer + mustGet(t, c, client.ObjectKeyFromObject(rp), &got) + if got.Status.Phase != v1alpha1.PhasePending { + t.Errorf("phase = %q, want Pending", got.Status.Phase) + } + if got.Status.Endpoint != "" { + t.Errorf("endpoint = %q, want empty until LB is assigned", got.Status.Endpoint) + } +} + +func TestProxyReconcilePrunesStaleType(t *testing.T) { + rp := testProxy("edge", v1alpha1.ProxyVelocity) + c := newFakeClient(t, rp) + r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} + + if _, err := r.Reconcile(context.Background(), requestFor(rp)); err != nil { + t.Fatalf("first reconcile: %v", err) + } + + var current v1alpha1.ReverseProxyServer + mustGet(t, c, client.ObjectKeyFromObject(rp), ¤t) + current.Spec.Type = v1alpha1.ProxyBungeeCord + current.Generation = 2 + if err := c.Update(context.Background(), ¤t); err != nil { + t.Fatalf("update type: %v", err) + } + + if _, err := r.Reconcile(context.Background(), requestFor(¤t)); err != nil { + t.Fatalf("second reconcile: %v", err) + } + + oldName := resources.ProxyName(v1alpha1.ProxyVelocity, rp.Name) + newName := resources.ProxyName(v1alpha1.ProxyBungeeCord, rp.Name) + mustGet(t, c, client.ObjectKey{Name: newName, Namespace: rp.Namespace}, &appsv1.Deployment{}) + + var stale appsv1.Deployment + if err := c.Get(context.Background(), client.ObjectKey{Name: oldName, Namespace: rp.Namespace}, &stale); !apierrors.IsNotFound(err) { + t.Fatalf("expected stale velocity deployment pruned, got %v", err) + } +} + +func TestProxyBackendsRespectsSelector(t *testing.T) { + lobby := testMinecraft("lobby", v1alpha1.ServerStateless) + lobby.Labels = map[string]string{"tier": "lobby"} + smp := testMinecraft("smp", v1alpha1.ServerStateful) + smp.Labels = map[string]string{"tier": "survival"} + rp := testProxy("edge", v1alpha1.ProxyVelocity) + rp.Spec.BackendSelector = &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "lobby"}, + } + + c := newFakeClient(t, lobby, smp, rp) + r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} + + if _, err := r.Reconcile(context.Background(), requestFor(rp)); err != nil { + t.Fatalf("reconcile: %v", err) + } + + var got v1alpha1.ReverseProxyServer + mustGet(t, c, client.ObjectKeyFromObject(rp), &got) + if !reflect.DeepEqual(got.Status.Backends, []string{"lobby"}) { + t.Errorf("backends = %v, want [lobby]", got.Status.Backends) + } +} + +func TestProxyBackendsListsAllWhenUnfiltered(t *testing.T) { + c := newFakeClient(t, + testMinecraft("b", v1alpha1.ServerStateless), + testMinecraft("a", v1alpha1.ServerStateful), + testProxy("edge", v1alpha1.ProxyVelocity), + ) + r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} + + names, err := r.backends(context.Background(), testProxy("edge", v1alpha1.ProxyVelocity)) + if err != nil { + t.Fatalf("backends: %v", err) + } + if !reflect.DeepEqual(names, []string{"a", "b"}) { + t.Errorf("backends = %v, want [a b]", names) + } +} + +func TestMatchesBackend(t *testing.T) { + obj := testMinecraft("lobby", v1alpha1.ServerStateless) + obj.Labels = map[string]string{"tier": "lobby"} + + t.Run("nil selector matches all", func(t *testing.T) { + if !matchesBackend(*testProxy("edge", v1alpha1.ProxyVelocity), obj) { + t.Error("expected match") + } + }) + + t.Run("matching labels", func(t *testing.T) { + rp := testProxy("edge", v1alpha1.ProxyVelocity) + rp.Spec.BackendSelector = &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "lobby"}} + if !matchesBackend(*rp, obj) { + t.Error("expected match") + } + }) + + t.Run("non matching labels", func(t *testing.T) { + rp := testProxy("edge", v1alpha1.ProxyVelocity) + rp.Spec.BackendSelector = &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "survival"}} + if matchesBackend(*rp, obj) { + t.Error("expected no match") + } + }) +} + +func TestProxiesForServer(t *testing.T) { + all := testProxy("all", v1alpha1.ProxyVelocity) + filtered := testProxy("filtered", v1alpha1.ProxyVelocity) + filtered.Spec.BackendSelector = &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "lobby"}} + other := testProxy("other", v1alpha1.ProxyVelocity) + other.Spec.BackendSelector = &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "survival"}} + + mc := testMinecraft("lobby", v1alpha1.ServerStateless) + mc.Labels = map[string]string{"tier": "lobby"} + + c := newFakeClient(t, all, filtered, other) + r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} + reqs := r.proxiesForServer(context.Background(), mc) + + got := map[string]bool{} + for _, req := range reqs { + got[req.Name] = true + } + if !got["all"] || !got["filtered"] { + t.Errorf("requests = %v, want all and filtered", got) + } + if got["other"] { + t.Error("did not expect the survival-only proxy") + } +} + +func TestProxyBackendsInvalidSelector(t *testing.T) { + rp := testProxy("edge", v1alpha1.ProxyVelocity) + rp.Spec.BackendSelector = &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{{ + Key: "tier", + Operator: "NotARealOperator", + Values: []string{"lobby"}, + }}, + } + r := &ReverseProxyServerReconciler{Client: newFakeClient(t), Scheme: testScheme(t)} + if _, err := r.backends(context.Background(), rp); err == nil { + t.Fatal("expected invalid backendSelector to fail") + } +} + +func TestProxyReconcileIgnoresNotFound(t *testing.T) { + r := &ReverseProxyServerReconciler{Client: newFakeClient(t), Scheme: testScheme(t)} + if _, err := r.Reconcile(context.Background(), reconcile.Request{ + NamespacedName: types.NamespacedName{Name: "missing", Namespace: "minikura"}, + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProxyReconcileSkipsDeleting(t *testing.T) { + now := metav1.Now() + rp := testProxy("edge", v1alpha1.ProxyVelocity) + rp.DeletionTimestamp = &now + rp.Finalizers = []string{"test.finalizer"} + c := newFakeClient(t, rp) + r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} + if _, err := r.Reconcile(context.Background(), requestFor(rp)); err != nil { + t.Fatalf("reconcile: %v", err) + } + var cm corev1.ConfigMap + err := c.Get(context.Background(), client.ObjectKey{ + Name: resources.ConfigMapName(resources.ProxyName(rp.Spec.Type, rp.Name)), + Namespace: rp.Namespace, + }, &cm) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected no resources for deleting object, got %v", err) + } +} + +func TestProxyReconcileApplyFailure(t *testing.T) { + rp := testProxy("edge", v1alpha1.ProxyVelocity) + c := newInterceptedClient(t, interceptor.Funcs{ + Patch: func(ctx context.Context, c client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if _, ok := obj.(*corev1.ConfigMap); ok { + return errors.New("configmap apply failed") + } + return c.Patch(ctx, obj, patch, opts...) + }, + }, rp) + r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} + if _, err := r.Reconcile(context.Background(), requestFor(rp)); err == nil { + t.Fatal("expected apply failure") + } + var got v1alpha1.ReverseProxyServer + mustGet(t, c, client.ObjectKeyFromObject(rp), &got) + if got.Status.Phase != v1alpha1.PhaseFailed { + t.Errorf("phase = %q, want Failed", got.Status.Phase) + } + if !strings.Contains(got.Status.Message, "configmap apply failed") { + t.Errorf("message = %q", got.Status.Message) + } +} + +func TestProxyReconcileServiceAndDeploymentFailures(t *testing.T) { + t.Run("service", func(t *testing.T) { + rp := testProxy("edge", v1alpha1.ProxyVelocity) + c := newInterceptedClient(t, interceptor.Funcs{ + Patch: func(ctx context.Context, c client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if _, ok := obj.(*corev1.Service); ok { + return errors.New("service apply failed") + } + return c.Patch(ctx, obj, patch, opts...) + }, + }, rp) + r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} + if _, err := r.Reconcile(context.Background(), requestFor(rp)); err == nil { + t.Fatal("expected service failure") + } + }) + t.Run("deployment", func(t *testing.T) { + rp := testProxy("edge", v1alpha1.ProxyVelocity) + c := newInterceptedClient(t, interceptor.Funcs{ + Patch: func(ctx context.Context, c client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if _, ok := obj.(*appsv1.Deployment); ok { + return errors.New("deployment apply failed") + } + return c.Patch(ctx, obj, patch, opts...) + }, + }, rp) + r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} + if _, err := r.Reconcile(context.Background(), requestFor(rp)); err == nil { + t.Fatal("expected deployment failure") + } + }) +} + +func TestProxyPruneSkipsUnowned(t *testing.T) { + rp := testProxy("edge", v1alpha1.ProxyVelocity) + stale := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: resources.ProxyName(v1alpha1.ProxyBungeeCord, rp.Name), + Namespace: rp.Namespace, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "x"}}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "x"}}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "c", Image: "busybox"}}}, + }, + }, + } + c := newFakeClient(t, rp, stale) + r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} + if err := r.pruneStaleResources(context.Background(), rp); err != nil { + t.Fatalf("prune: %v", err) + } + mustGet(t, c, client.ObjectKeyFromObject(stale), &appsv1.Deployment{}) +} + +func TestProxyPruneGetError(t *testing.T) { + rp := testProxy("edge", v1alpha1.ProxyVelocity) + c := newInterceptedClient(t, interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if key.Name == resources.ProxyName(v1alpha1.ProxyBungeeCord, rp.Name) { + return errors.New("get failed") + } + return c.Get(ctx, key, obj, opts...) + }, + }, rp) + r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} + if err := r.pruneStaleResources(context.Background(), rp); err == nil { + t.Fatal("expected prune get error") + } +} + +func TestProxyReconcilePruneFailure(t *testing.T) { + rp := testProxy("edge", v1alpha1.ProxyVelocity) + c := newFakeClient(t, rp) + r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} + if _, err := r.Reconcile(context.Background(), requestFor(rp)); err != nil { + t.Fatalf("seed: %v", err) + } + + var current v1alpha1.ReverseProxyServer + mustGet(t, c, client.ObjectKeyFromObject(rp), ¤t) + current.Spec.Type = v1alpha1.ProxyBungeeCord + if err := c.Update(context.Background(), ¤t); err != nil { + t.Fatalf("update type: %v", err) + } + + watch, ok := c.(client.WithWatch) + if !ok { + t.Fatal("fake client does not implement WithWatch") + } + r.Client = interceptor.NewClient(watch, interceptor.Funcs{ + Delete: func(ctx context.Context, inner client.WithWatch, obj client.Object, opts ...client.DeleteOption) error { + return errors.New("delete failed") + }, + }) + if _, err := r.Reconcile(context.Background(), requestFor(¤t)); err == nil { + t.Fatal("expected prune failure") + } +} + +func TestProxyPruneDeleteError(t *testing.T) { + rp := testProxy("edge", v1alpha1.ProxyVelocity) + c := newFakeClient(t, rp) + r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} + if _, err := r.Reconcile(context.Background(), requestFor(rp)); err != nil { + t.Fatalf("seed: %v", err) + } + + var current v1alpha1.ReverseProxyServer + mustGet(t, c, client.ObjectKeyFromObject(rp), ¤t) + current.Spec.Type = v1alpha1.ProxyBungeeCord + + watch, ok := c.(client.WithWatch) + if !ok { + t.Fatal("fake client does not implement WithWatch") + } + r.Client = interceptor.NewClient(watch, interceptor.Funcs{ + Delete: func(ctx context.Context, inner client.WithWatch, obj client.Object, opts ...client.DeleteOption) error { + return errors.New("delete failed") + }, + }) + if err := r.pruneStaleResources(context.Background(), ¤t); err == nil { + t.Fatal("expected prune delete error") + } +} + +func TestProxyUpdateStatusErrors(t *testing.T) { + t.Run("deployment get", func(t *testing.T) { + rp := testProxy("edge", v1alpha1.ProxyVelocity) + c := newInterceptedClient(t, interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*appsv1.Deployment); ok { + return errors.New("deployment get failed") + } + return c.Get(ctx, key, obj, opts...) + }, + }, rp) + r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} + if err := r.updateStatus(context.Background(), rp); err == nil { + t.Fatal("expected deployment get error") + } + }) + t.Run("service get", func(t *testing.T) { + rp := testProxy("edge", v1alpha1.ProxyVelocity) + c := newInterceptedClient(t, interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*corev1.Service); ok { + return errors.New("service get failed") + } + return c.Get(ctx, key, obj, opts...) + }, + }, rp) + r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} + if err := r.updateStatus(context.Background(), rp); err == nil { + t.Fatal("expected service get error") + } + }) + t.Run("invalid selector", func(t *testing.T) { + rp := testProxy("edge", v1alpha1.ProxyVelocity) + rp.Spec.BackendSelector = &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{{ + Key: "tier", Operator: "NotARealOperator", Values: []string{"x"}, + }}, + } + r := &ReverseProxyServerReconciler{Client: newFakeClient(t, rp), Scheme: testScheme(t)} + if err := r.updateStatus(context.Background(), rp); err == nil { + t.Fatal("expected backend selector error") + } + }) +} + +func TestProxyStatusReadyAndEndpoint(t *testing.T) { + rp := testProxy("edge", v1alpha1.ProxyVelocity) + c := newFakeClient(t, rp) + r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} + if _, err := r.Reconcile(context.Background(), requestFor(rp)); err != nil { + t.Fatalf("reconcile: %v", err) + } + + name := resources.ProxyName(rp.Spec.Type, rp.Name) + var dep appsv1.Deployment + mustGet(t, c, client.ObjectKey{Name: name, Namespace: rp.Namespace}, &dep) + dep.Status.Replicas = 1 + dep.Status.ReadyReplicas = 1 + if err := c.Status().Update(context.Background(), &dep); err != nil { + t.Fatalf("dep status: %v", err) + } + + var svc corev1.Service + mustGet(t, c, client.ObjectKey{Name: name, Namespace: rp.Namespace}, &svc) + svc.Status.LoadBalancer.Ingress = []corev1.LoadBalancerIngress{{IP: "9.9.9.9"}} + if err := c.Status().Update(context.Background(), &svc); err != nil { + t.Fatalf("svc status: %v", err) + } + + if _, err := r.Reconcile(context.Background(), requestFor(rp)); err != nil { + t.Fatalf("second reconcile: %v", err) + } + var got v1alpha1.ReverseProxyServer + mustGet(t, c, client.ObjectKeyFromObject(rp), &got) + if got.Status.Phase != v1alpha1.PhaseRunning { + t.Errorf("phase = %q", got.Status.Phase) + } + if got.Status.Endpoint != "9.9.9.9:25565" { + t.Errorf("endpoint = %q", got.Status.Endpoint) + } +} + +func TestProxiesForServerListError(t *testing.T) { + c := newInterceptedClient(t, interceptor.Funcs{ + List: func(ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { + return errors.New("list failed") + }, + }) + r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} + if reqs := r.proxiesForServer(context.Background(), testMinecraft("lobby", v1alpha1.ServerStateless)); reqs != nil { + t.Errorf("requests = %v, want nil", reqs) + } +} + +func TestMatchesBackendInvalidSelector(t *testing.T) { + rp := testProxy("edge", v1alpha1.ProxyVelocity) + rp.Spec.BackendSelector = &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{{ + Key: "tier", Operator: "NotARealOperator", Values: []string{"x"}, + }}, + } + if matchesBackend(*rp, testMinecraft("lobby", v1alpha1.ServerStateless)) { + t.Error("invalid selector should not match") + } +} + +func TestProxyBackendsListError(t *testing.T) { + c := newInterceptedClient(t, interceptor.Funcs{ + List: func(ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { + return errors.New("list failed") + }, + }) + r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} + if _, err := r.backends(context.Background(), testProxy("edge", v1alpha1.ProxyVelocity)); err == nil { + t.Fatal("expected list error") + } +} diff --git a/operator/internal/controller/status.go b/operator/internal/controller/status.go index f39dd88..b3d5cdb 100644 --- a/operator/internal/controller/status.go +++ b/operator/internal/controller/status.go @@ -1,13 +1,25 @@ package controller import ( + "context" "fmt" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1" ) +func phaseFromReady(ready int32) string { + if ready > 0 { + return v1alpha1.PhaseRunning + } + return v1alpha1.PhasePending +} + func conditionStatus(ok bool) metav1.ConditionStatus { if ok { return metav1.ConditionTrue @@ -22,6 +34,15 @@ func setCondition(conditions *[]metav1.Condition, c metav1.Condition) { meta.SetStatusCondition(conditions, c) } +func failWithStatus(ctx context.Context, c client.Client, obj client.Object, cause error, mutate func()) (ctrl.Result, error) { + patch := client.MergeFrom(obj.DeepCopyObject().(client.Object)) + mutate() + if err := c.Status().Patch(ctx, obj, patch); err != nil { + return ctrl.Result{}, fmt.Errorf("%w (status patch failed: %v)", cause, err) + } + return ctrl.Result{}, cause +} + func serviceEndpoint(svc *corev1.Service, namespace string) string { internal := fmt.Sprintf("%s.%s.svc.cluster.local", svc.Name, namespace) port := int32(0) diff --git a/operator/internal/controller/status_test.go b/operator/internal/controller/status_test.go new file mode 100644 index 0000000..ac98535 --- /dev/null +++ b/operator/internal/controller/status_test.go @@ -0,0 +1,152 @@ +package controller + +import ( + "context" + "errors" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1" +) + +func TestPhaseFromReady(t *testing.T) { + if got := phaseFromReady(0); got != v1alpha1.PhasePending { + t.Errorf("ready=0 -> %q, want Pending", got) + } + if got := phaseFromReady(1); got != v1alpha1.PhaseRunning { + t.Errorf("ready=1 -> %q, want Running", got) + } +} + +func TestConditionStatus(t *testing.T) { + if got := conditionStatus(true); got != metav1.ConditionTrue { + t.Errorf("true -> %q", got) + } + if got := conditionStatus(false); got != metav1.ConditionFalse { + t.Errorf("false -> %q", got) + } +} + +func TestSetConditionFillsReason(t *testing.T) { + var conditions []metav1.Condition + setCondition(&conditions, metav1.Condition{Type: v1alpha1.ConditionReady}) + if conditions[0].Reason != "Unknown" { + t.Errorf("reason = %q, want Unknown", conditions[0].Reason) + } +} + +func TestFailWithStatusWrapsPatchError(t *testing.T) { + mc := testMinecraft("lobby", v1alpha1.ServerStateless) + c := newInterceptedClient(t, interceptor.Funcs{ + SubResourcePatch: func(ctx context.Context, c client.Client, subResourceName string, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + return errors.New("status denied") + }, + }, mc) + _, err := failWithStatus(context.Background(), c, mc, errors.New("root cause"), func() { + mc.Status.Phase = v1alpha1.PhaseFailed + }) + if err == nil { + t.Fatal("expected wrapped status patch error") + } + if !strings.Contains(err.Error(), "root cause") || !strings.Contains(err.Error(), "status denied") { + t.Errorf("error = %v", err) + } +} + +func TestServiceEndpoint(t *testing.T) { + tests := []struct { + name string + svc *corev1.Service + want string + }{ + { + name: "cluster ip", + svc: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "minecraft-smp"}, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{{Port: 25565}}, + }, + }, + want: "minecraft-smp.minikura.svc.cluster.local:25565", + }, + { + name: "load balancer hostname", + svc: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "velocity-edge"}, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{{Port: 25565}}, + }, + Status: corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{ + Ingress: []corev1.LoadBalancerIngress{{Hostname: "lb.example.com"}}, + }, + }, + }, + want: "lb.example.com:25565", + }, + { + name: "load balancer ip", + svc: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "velocity-edge"}, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{{Port: 25565}}, + }, + Status: corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{ + Ingress: []corev1.LoadBalancerIngress{{IP: "1.2.3.4"}}, + }, + }, + }, + want: "1.2.3.4:25565", + }, + { + name: "load balancer pending", + svc: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "velocity-edge"}, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{{Port: 25565}}, + }, + }, + want: "", + }, + { + name: "node port assigned", + svc: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "minecraft-smp"}, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{{Port: 25565, NodePort: 30123}}, + }, + }, + want: ":30123", + }, + { + name: "node port unassigned", + svc: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "minecraft-smp"}, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{{Port: 25565}}, + }, + }, + want: "minecraft-smp.minikura.svc.cluster.local", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := serviceEndpoint(tt.svc, "minikura"); got != tt.want { + t.Errorf("serviceEndpoint() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/operator/internal/resources/common.go b/operator/internal/resources/common.go index 62aa186..2224861 100644 --- a/operator/internal/resources/common.go +++ b/operator/internal/resources/common.go @@ -12,12 +12,16 @@ import ( ) const ( - MinecraftImage = "itzg/minecraft-server" - ProxyImage = "itzg/mc-proxy:latest" - ContainerPort = 25565 - DefaultHeapPercent = 80 + MinecraftImage = "itzg/minecraft-server" + ProxyImage = "itzg/mc-proxy:latest" + ContainerPort = 25565 + DefaultHeapPercent = 80 + DefaultMemoryLimitMB = 2048 + MinHeapMB = 256 ) +func ptr[T any](v T) *T { return &v } + func ServiceType(exposure v1alpha1.ServiceExposure, fallback corev1.ServiceType) corev1.ServiceType { switch exposure { case v1alpha1.ExposureClusterIP: @@ -32,12 +36,15 @@ func ServiceType(exposure v1alpha1.ServiceExposure, fallback corev1.ServiceType) } func HeapMB(limitMB int32, heapPercent int32) string { + if limitMB <= 0 { + limitMB = DefaultMemoryLimitMB + } if heapPercent <= 0 || heapPercent > 100 { heapPercent = DefaultHeapPercent } heap := int64(limitMB) * int64(heapPercent) / 100 - if heap < 256 { - heap = 256 + if heap < MinHeapMB { + heap = MinHeapMB } return fmt.Sprintf("%dM", heap) } @@ -45,7 +52,7 @@ func HeapMB(limitMB int32, heapPercent int32) string { func ResourceRequirements(r v1alpha1.Resources) corev1.ResourceRequirements { limitMB := r.MemoryLimitMB if limitMB <= 0 { - limitMB = 2048 + limitMB = DefaultMemoryLimitMB } requestMB := r.MemoryRequestMB if requestMB <= 0 || requestMB > limitMB { @@ -97,6 +104,7 @@ func UserEnv(base []corev1.EnvVar, extra []v1alpha1.EnvVar) []corev1.EnvVar { for _, e := range extra { if i, ok := index[e.Name]; ok { base[i].Value = e.Value + base[i].ValueFrom = nil continue } index[e.Name] = len(base) diff --git a/operator/internal/resources/common_test.go b/operator/internal/resources/common_test.go index 876bfad..1637692 100644 --- a/operator/internal/resources/common_test.go +++ b/operator/internal/resources/common_test.go @@ -21,6 +21,7 @@ func TestHeapMB(t *testing.T) { {"out of range falls back", 1024, 150, "819M"}, {"floor applies to tiny limits", 128, 80, "256M"}, {"full allocation", 1000, 100, "1000M"}, + {"zero limit uses default memory", 0, 80, "1638M"}, } for _, tt := range tests { @@ -49,6 +50,29 @@ func TestResourceRequirements(t *testing.T) { } }) + t.Run("zero memory uses default", func(t *testing.T) { + got := ResourceRequirements(v1alpha1.Resources{}) + want := resource.MustParse("2048Mi") + if got.Limits.Memory().Cmp(want) != 0 { + t.Errorf("limit memory = %v, want %v", got.Limits.Memory(), &want) + } + }) + + t.Run("explicit request below limit is kept", func(t *testing.T) { + got := ResourceRequirements(v1alpha1.Resources{MemoryLimitMB: 4096, MemoryRequestMB: 1024}) + want := resource.MustParse("1024Mi") + if got.Requests.Memory().Cmp(want) != 0 { + t.Errorf("request memory = %v, want %v", got.Requests.Memory(), &want) + } + }) + + t.Run("valid cpu request is kept", func(t *testing.T) { + got := ResourceRequirements(v1alpha1.Resources{MemoryLimitMB: 1024, CPURequest: "250m"}) + if got.Requests.Cpu().String() != "250m" { + t.Errorf("cpu request = %v, want 250m", got.Requests.Cpu()) + } + }) + t.Run("invalid cpu strings are dropped", func(t *testing.T) { got := ResourceRequirements(v1alpha1.Resources{ MemoryLimitMB: 1024, @@ -84,3 +108,88 @@ func TestUserEnvOverridesDefaults(t *testing.T) { t.Errorf("TYPE = %q, want PAPER", got[0].Value) } } + +func TestUserEnvClearsValueFromOnOverride(t *testing.T) { + base := []corev1.EnvVar{{ + Name: "MINIKURA_API_KEY", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{Key: "api-key"}, + }, + }} + got := UserEnv(base, []v1alpha1.EnvVar{{Name: "MINIKURA_API_KEY", Value: "inline"}}) + if got[0].Value != "inline" { + t.Errorf("value = %q, want inline", got[0].Value) + } + if got[0].ValueFrom != nil { + t.Error("ValueFrom should be cleared when overridden") + } +} + +func TestUserEnvAppendsUnknownKeys(t *testing.T) { + base := []corev1.EnvVar{{Name: "TYPE", Value: "PAPER"}} + got := UserEnv(base, []v1alpha1.EnvVar{{Name: "EXTRA", Value: "1"}}) + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } +} + +func TestJVMEnv(t *testing.T) { + env := JVMEnv(v1alpha1.JVMOptions{ + Opts: "-XX:+UseG1GC", + UseAikarFlags: true, + UseMeowIceFlags: true, + HeapPercent: 70, + }, 2048) + + want := map[string]string{ + "MEMORY": "1433M", + "JVM_OPTS": "-XX:+UseG1GC", + "USE_AIKAR_FLAGS": "true", + "USE_MEOWICE_FLAGS": "true", + } + for key, value := range want { + got, ok := envValue(env, key) + if !ok || got != value { + t.Errorf("%s = %q, %v; want %q", key, got, ok, value) + } + } +} + +func TestJVMEnvOmitsOptionalFlags(t *testing.T) { + env := JVMEnv(v1alpha1.JVMOptions{}, 1024) + if _, ok := envValue(env, "JVM_OPTS"); ok { + t.Error("JVM_OPTS should be omitted") + } + if _, ok := envValue(env, "USE_AIKAR_FLAGS"); ok { + t.Error("USE_AIKAR_FLAGS should be omitted") + } +} + +func TestTCPProbeDefaultsPort(t *testing.T) { + probe := TCPProbe(15, 0) + if probe.TCPSocket.Port.IntVal != ContainerPort { + t.Errorf("port = %d, want %d", probe.TCPSocket.Port.IntVal, ContainerPort) + } + if probe.InitialDelaySeconds != 15 { + t.Errorf("initialDelay = %d", probe.InitialDelaySeconds) + } +} + +func TestBoolValue(t *testing.T) { + if !BoolValue(nil, true) { + t.Error("nil should use fallback true") + } + f := false + if BoolValue(&f, true) { + t.Error("explicit false should win") + } +} + +func TestServiceTypeLoadBalancer(t *testing.T) { + if got := ServiceType(v1alpha1.ExposureLoadBalancer, corev1.ServiceTypeClusterIP); got != corev1.ServiceTypeLoadBalancer { + t.Errorf("got %v", got) + } + if got := ServiceType(v1alpha1.ExposureClusterIP, corev1.ServiceTypeLoadBalancer); got != corev1.ServiceTypeClusterIP { + t.Errorf("got %v", got) + } +} diff --git a/operator/internal/resources/minecraftserver.go b/operator/internal/resources/minecraftserver.go index 6c74d5b..28f6901 100644 --- a/operator/internal/resources/minecraftserver.go +++ b/operator/internal/resources/minecraftserver.go @@ -181,5 +181,3 @@ func MinecraftStatefulSet(mc *v1alpha1.MinecraftServer) (*appsv1.StatefulSet, er }, }, nil } - -func ptr[T any](v T) *T { return &v } diff --git a/operator/internal/resources/minecraftserver_test.go b/operator/internal/resources/minecraftserver_test.go index 0bd81d8..7278d0d 100644 --- a/operator/internal/resources/minecraftserver_test.go +++ b/operator/internal/resources/minecraftserver_test.go @@ -152,3 +152,74 @@ func TestStatelessHasNoDataVolume(t *testing.T) { } } } + +func TestMinecraftConfigMap(t *testing.T) { + cm := MinecraftConfigMap(testServer()) + if cm.Name != "minecraft-smp-config" { + t.Errorf("name = %q", cm.Name) + } + if cm.Data["jar-type"] != "PAPER" || cm.Data["minecraft-version"] != "1.20.4" { + t.Errorf("data = %v", cm.Data) + } +} + +func TestMinecraftAPIKeyAndOptionalEnv(t *testing.T) { + mc := testServer() + mc.Spec.APIKeySecretRef = "minikura-key" + mc.Spec.Properties.LevelSeed = "abc" + mc.Spec.Properties.LevelType = "flat" + falseVal := false + mc.Spec.Properties.OnlineMode = &falseVal + + env := minecraftEnv(mc) + got, ok := envValue(env, "SEED") + if !ok || got != "abc" { + t.Errorf("SEED = %q, %v", got, ok) + } + got, ok = envValue(env, "LEVEL_TYPE") + if !ok || got != "flat" { + t.Errorf("LEVEL_TYPE = %q, %v", got, ok) + } + got, ok = envValue(env, "ONLINE_MODE") + if !ok || got != "false" { + t.Errorf("ONLINE_MODE = %q, %v", got, ok) + } + + found := false + for _, e := range env { + if e.Name == "MINIKURA_API_KEY" { + found = true + if e.ValueFrom == nil || e.ValueFrom.SecretKeyRef == nil { + t.Fatal("expected secret ref for API key") + } + if e.ValueFrom.SecretKeyRef.Name != "minikura-key" || e.ValueFrom.SecretKeyRef.Key != "api-key" { + t.Errorf("secret ref = %+v", e.ValueFrom.SecretKeyRef) + } + } + } + if !found { + t.Error("MINIKURA_API_KEY missing") + } +} + +func TestStatefulSetDefaultStorage(t *testing.T) { + mc := testServer() + mc.Spec.StorageSize = "" + sts, err := MinecraftStatefulSet(mc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := sts.Spec.VolumeClaimTemplates[0].Spec.Resources.Requests.Storage().String(); got != "1Gi" { + t.Errorf("storage = %s, want 1Gi", got) + } +} + +func TestMinecraftServiceDefaultsToClusterIP(t *testing.T) { + svc := MinecraftService(testServer()) + if svc.Spec.Type != corev1.ServiceTypeClusterIP { + t.Errorf("type = %v", svc.Spec.Type) + } + if svc.Spec.Ports[0].Port != 25565 { + t.Errorf("port = %d", svc.Spec.Ports[0].Port) + } +} diff --git a/operator/internal/resources/naming_test.go b/operator/internal/resources/naming_test.go new file mode 100644 index 0000000..0c4f1c3 --- /dev/null +++ b/operator/internal/resources/naming_test.go @@ -0,0 +1,60 @@ +package resources + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1" +) + +func TestServerAndProxyNames(t *testing.T) { + if got := ServerName("smp"); got != "minecraft-smp" { + t.Errorf("ServerName = %q", got) + } + if got := ProxyName(v1alpha1.ProxyVelocity, "edge"); got != "velocity-edge" { + t.Errorf("ProxyName velocity = %q", got) + } + if got := ProxyName(v1alpha1.ProxyBungeeCord, "edge"); got != "bungeecord-edge" { + t.Errorf("ProxyName bungee = %q", got) + } + if got := ConfigMapName("minecraft-smp"); got != "minecraft-smp-config" { + t.Errorf("ConfigMapName = %q", got) + } +} + +func TestServerLabels(t *testing.T) { + mc := &v1alpha1.MinecraftServer{ + ObjectMeta: metav1.ObjectMeta{Name: "smp"}, + Spec: v1alpha1.MinecraftServerSpec{Type: v1alpha1.ServerStateful}, + } + labels := ServerLabels(mc) + want := map[string]string{ + "app": "minecraft-smp", + v1alpha1.LabelServerType: "stateful", + v1alpha1.LabelServerID: "smp", + v1alpha1.LabelManagedBy: v1alpha1.ManagerName, + } + for k, v := range want { + if labels[k] != v { + t.Errorf("label %s = %q, want %q", k, labels[k], v) + } + } +} + +func TestProxyLabels(t *testing.T) { + rp := &v1alpha1.ReverseProxyServer{ + ObjectMeta: metav1.ObjectMeta{Name: "edge"}, + Spec: v1alpha1.ReverseProxyServerSpec{Type: v1alpha1.ProxyVelocity}, + } + labels := ProxyLabels(rp) + if labels["app"] != "velocity-edge" { + t.Errorf("app = %q", labels["app"]) + } + if labels[v1alpha1.LabelServerType] != "velocity" { + t.Errorf("server-type = %q", labels[v1alpha1.LabelServerType]) + } + if labels[v1alpha1.LabelProxyID] != "edge" { + t.Errorf("proxy-id = %q", labels[v1alpha1.LabelProxyID]) + } +} diff --git a/operator/internal/resources/reverseproxyserver.go b/operator/internal/resources/reverseproxyserver.go index c78536e..8c5d048 100644 --- a/operator/internal/resources/reverseproxyserver.go +++ b/operator/internal/resources/reverseproxyserver.go @@ -93,7 +93,7 @@ func ProxyDeployment(rp *v1alpha1.ReverseProxyServer) *appsv1.Deployment { {Name: "config", MountPath: "/config"}, }, ReadinessProbe: TCPProbe(30, rp.Spec.ListenPort), - Resources: ResourceRequirements(rp.Spec.Resources), + Resources: ResourceRequirements(rp.Spec.Resources), }}, Volumes: []corev1.Volume{{ Name: "config", diff --git a/operator/internal/resources/reverseproxyserver_test.go b/operator/internal/resources/reverseproxyserver_test.go index 379f3c8..a0b07d8 100644 --- a/operator/internal/resources/reverseproxyserver_test.go +++ b/operator/internal/resources/reverseproxyserver_test.go @@ -3,6 +3,7 @@ package resources import ( "testing" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1" @@ -41,3 +42,72 @@ func TestProxyEnvCarriesRuntimeConfig(t *testing.T) { } } } + +func TestProxyConfigMap(t *testing.T) { + cm := ProxyConfigMap(testProxy()) + if cm.Name != "velocity-edge-config" { + t.Errorf("name = %q", cm.Name) + } + if cm.Data["proxy-type"] != "VELOCITY" || cm.Data["external-address"] != "play.example.com" { + t.Errorf("data = %v", cm.Data) + } +} + +func TestProxyService(t *testing.T) { + rp := testProxy() + svc := ProxyService(rp) + if svc.Spec.Type != corev1.ServiceTypeLoadBalancer { + t.Errorf("type = %v", svc.Spec.Type) + } + if svc.Spec.Ports[0].Port != 25565 { + t.Errorf("port = %d", svc.Spec.Ports[0].Port) + } + if svc.Spec.Ports[0].TargetPort.IntVal != 25577 { + t.Errorf("targetPort = %d", svc.Spec.Ports[0].TargetPort.IntVal) + } +} + +func TestProxyServiceNodePort(t *testing.T) { + rp := testProxy() + rp.Spec.ServiceType = v1alpha1.ExposureNodePort + rp.Spec.NodePort = 30555 + svc := ProxyService(rp) + if svc.Spec.Type != corev1.ServiceTypeNodePort { + t.Errorf("type = %v", svc.Spec.Type) + } + if svc.Spec.Ports[0].NodePort != 30555 { + t.Errorf("nodePort = %d", svc.Spec.Ports[0].NodePort) + } +} + +func TestProxyDeploymentListenPortAndProbe(t *testing.T) { + dep := ProxyDeployment(testProxy()) + container := dep.Spec.Template.Spec.Containers[0] + if container.Ports[0].ContainerPort != 25577 { + t.Errorf("containerPort = %d", container.Ports[0].ContainerPort) + } + if container.ReadinessProbe == nil || container.ReadinessProbe.TCPSocket == nil { + t.Fatal("expected TCP readiness probe") + } + if container.ReadinessProbe.TCPSocket.Port.IntVal != 25577 { + t.Errorf("probe port = %d", container.ReadinessProbe.TCPSocket.Port.IntVal) + } +} + +func TestProxyAPIKeyEnv(t *testing.T) { + rp := testProxy() + rp.Spec.APIKeySecretRef = "proxy-key" + env := proxyEnv(rp) + found := false + for _, e := range env { + if e.Name == "MINIKURA_API_KEY" { + found = true + if e.ValueFrom == nil || e.ValueFrom.SecretKeyRef.Name != "proxy-key" { + t.Errorf("secret ref = %+v", e.ValueFrom) + } + } + } + if !found { + t.Error("MINIKURA_API_KEY missing") + } +}