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 TestProxyReconcileRejectsInvalidSpec(t *testing.T) { rp := testProxy("invalid", v1alpha1.ProxyVelocity) rp.Spec.Type = "" rp.Spec.ExternalPort = 0 rp.Spec.ListenPort = 0 c := newFakeClient(t, rp) r := &ReverseProxyServerReconciler{Client: c, Scheme: testScheme(t)} if _, err := r.Reconcile(context.Background(), requestFor(rp)); err == nil || !strings.Contains(err.Error(), "unsupported proxy type") { t.Fatalf("reconcile error = %v, want invalid spec error", err) } var got v1alpha1.ReverseProxyServer mustGet(t, c, client.ObjectKeyFromObject(rp), &got) if got.Status.Phase != v1alpha1.PhaseFailed || got.Status.ObservedGeneration != rp.Generation { t.Fatalf("status = %#v, want failed observed generation", got.Status) } } 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 TestProxyReconcileMutatesServiceAndDeployment(t *testing.T) { rp := testProxy("mutable", 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.ExternalPort = 25570 current.Spec.BackendURL = "http://backend:3000/api" current.Spec.APIKeySecretRef = "proxy-key" current.Generation = 2 if err := c.Update(context.Background(), ¤t); err != nil { t.Fatalf("update proxy: %v", err) } if _, err := r.Reconcile(context.Background(), requestFor(¤t)); err != nil { t.Fatalf("second reconcile: %v", err) } key := client.ObjectKey{Name: resources.ProxyName(rp.Spec.Type, rp.Name), Namespace: rp.Namespace} var svc corev1.Service mustGet(t, c, key, &svc) if svc.Spec.Ports[0].Port != 25570 { t.Errorf("service port = %d", svc.Spec.Ports[0].Port) } var dep appsv1.Deployment mustGet(t, c, key, &dep) found := false for _, env := range dep.Spec.Template.Spec.Containers[0].Env { if env.Name == "MINIKURA_API_URL" && env.Value == "http://backend:3000/api" { found = true } } if !found { t.Error("deployment was not updated with backend URL") } } 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") } }