mirror of
https://github.com/YuzuZensai/Minikura.git
synced 2026-09-13 18:59:25 +00:00
🧪 test: add operator reconcile coverage
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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: "<node-ip>: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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,5 +181,3 @@ func MinecraftStatefulSet(mc *v1alpha1.MinecraftServer) (*appsv1.StatefulSet, er
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user