🐛 fix: harden operator reconciliation and install

This commit is contained in:
2026-08-13 03:29:29 +07:00
parent ab662a4fa4
commit e90dc8382b
28 changed files with 800 additions and 71 deletions
+31
View File
@@ -3,6 +3,7 @@ package controller
import (
"context"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
@@ -16,6 +17,11 @@ var applyOpts = []client.PatchOption{
}
func apply(ctx context.Context, c client.Client, owner client.Object, obj client.Object, scheme *runtime.Scheme) error {
if svc, ok := obj.(*corev1.Service); ok {
if err := preserveServiceAllocations(ctx, c, svc); err != nil {
return err
}
}
if err := setOwner(owner, obj, scheme); err != nil {
return err
}
@@ -30,3 +36,28 @@ func apply(ctx context.Context, c client.Client, owner client.Object, obj client
obj.SetResourceVersion("")
return c.Patch(ctx, obj, client.Apply, applyOpts...)
}
func preserveServiceAllocations(ctx context.Context, c client.Client, desired *corev1.Service) error {
var current corev1.Service
if err := c.Get(ctx, client.ObjectKeyFromObject(desired), &current); err != nil {
return client.IgnoreNotFound(err)
}
desired.Spec.ClusterIP = current.Spec.ClusterIP
desired.Spec.ClusterIPs = append([]string(nil), current.Spec.ClusterIPs...)
desired.Spec.IPFamilies = append([]corev1.IPFamily(nil), current.Spec.IPFamilies...)
desired.Spec.IPFamilyPolicy = current.Spec.IPFamilyPolicy
if desired.Spec.Type != corev1.ServiceTypeClusterIP {
for i := range desired.Spec.Ports {
if desired.Spec.Ports[i].NodePort != 0 {
continue
}
for _, port := range current.Spec.Ports {
if port.Name == desired.Spec.Ports[i].Name && port.Protocol == desired.Spec.Ports[i].Protocol {
desired.Spec.Ports[i].NodePort = port.NodePort
break
}
}
}
}
return nil
}
@@ -11,6 +11,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
"github.com/YuzuZensai/Minikura/operator/internal/resources"
)
func TestTypedObjectMarshalsWithoutTypeMeta(t *testing.T) {
@@ -33,6 +34,27 @@ func TestTypedObjectMarshalsWithoutTypeMeta(t *testing.T) {
}
}
func TestApplyPreservesServiceAllocations(t *testing.T) {
owner := testMinecraft("smp", v1alpha1.ServerStateless)
current := resources.MinecraftService(owner)
current.Spec.ClusterIP = "10.0.0.10"
current.Spec.ClusterIPs = []string{"10.0.0.10"}
current.Spec.IPFamilies = []corev1.IPFamily{corev1.IPv4Protocol}
policy := corev1.IPFamilyPolicySingleStack
current.Spec.IPFamilyPolicy = &policy
c := newFakeClient(t, owner, current)
desired := resources.MinecraftService(owner)
if err := apply(context.Background(), c, owner, desired, testScheme(t)); err != nil {
t.Fatalf("apply: %v", err)
}
var got corev1.Service
mustGet(t, c, client.ObjectKeyFromObject(current), &got)
if got.Spec.ClusterIP != "10.0.0.10" || len(got.Spec.ClusterIPs) != 1 {
t.Errorf("service allocations were not preserved: %+v", got.Spec)
}
}
func TestApplySetsTypeMeta(t *testing.T) {
scheme := testScheme(t)
owner := &v1alpha1.MinecraftServer{
@@ -2,6 +2,7 @@ package controller
import (
"context"
"fmt"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
@@ -10,7 +11,6 @@ import (
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
"github.com/YuzuZensai/Minikura/operator/internal/resources"
@@ -31,8 +31,6 @@ type MinecraftServerReconciler struct {
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
func (r *MinecraftServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var mc v1alpha1.MinecraftServer
if err := r.Get(ctx, req.NamespacedName, &mc); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
@@ -41,6 +39,12 @@ func (r *MinecraftServerReconciler) Reconcile(ctx context.Context, req ctrl.Requ
if !mc.DeletionTimestamp.IsZero() {
return ctrl.Result{}, nil
}
if mc.Spec.Type != v1alpha1.ServerStateful && mc.Spec.Type != v1alpha1.ServerStateless {
return r.fail(ctx, &mc, "InvalidSpec", fmt.Errorf("unsupported server type %q", mc.Spec.Type))
}
if mc.Spec.ListenPort <= 0 {
return r.fail(ctx, &mc, "InvalidSpec", fmt.Errorf("listenPort must be set"))
}
if err := apply(ctx, r.Client, &mc, resources.MinecraftConfigMap(&mc), r.Scheme); err != nil {
return r.fail(ctx, &mc, "ConfigMapFailed", err)
@@ -56,10 +60,13 @@ func (r *MinecraftServerReconciler) Reconcile(ctx context.Context, req ctrl.Requ
}
if err := r.pruneOppositeWorkload(ctx, &mc, stateful); err != nil {
logger.Error(err, "failed to prune previous workload")
return r.fail(ctx, &mc, "PruneFailed", err)
}
return ctrl.Result{}, r.updateStatus(ctx, &mc, stateful)
if err := r.updateStatus(ctx, &mc, stateful); err != nil {
return r.fail(ctx, &mc, "StatusFailed", err)
}
return ctrl.Result{}, nil
}
func (r *MinecraftServerReconciler) reconcileWorkload(ctx context.Context, mc *v1alpha1.MinecraftServer, stateful bool) error {
@@ -155,6 +162,7 @@ func (r *MinecraftServerReconciler) fail(ctx context.Context, mc *v1alpha1.Minec
return failWithStatus(ctx, r.Client, mc, cause, func() {
mc.Status.Phase = v1alpha1.PhaseFailed
mc.Status.Message = cause.Error()
mc.Status.ObservedGeneration = mc.Generation
setCondition(&mc.Status.Conditions, metav1.Condition{
Type: v1alpha1.ConditionReady,
Status: metav1.ConditionFalse,
@@ -54,6 +54,24 @@ func TestMinecraftReconcileStatelessCreatesResources(t *testing.T) {
}
}
func TestMinecraftReconcileRejectsInvalidSpec(t *testing.T) {
mc := testMinecraft("invalid", v1alpha1.ServerStateless)
mc.Spec.Type = ""
mc.Spec.ListenPort = 0
c := newFakeClient(t, mc)
r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)}
if _, err := r.Reconcile(context.Background(), requestFor(mc)); err == nil || !strings.Contains(err.Error(), "unsupported server type") {
t.Fatalf("reconcile error = %v, want invalid spec error", err)
}
var got v1alpha1.MinecraftServer
mustGet(t, c, client.ObjectKeyFromObject(mc), &got)
if got.Status.Phase != v1alpha1.PhaseFailed || got.Status.ObservedGeneration != mc.Generation {
t.Fatalf("status = %#v, want failed observed generation", got.Status)
}
}
func TestMinecraftReconcileStatefulCreatesStatefulSet(t *testing.T) {
mc := testMinecraft("smp", v1alpha1.ServerStateful)
c := newFakeClient(t, mc)
@@ -156,6 +174,9 @@ func TestMinecraftReconcileInvalidStorageFails(t *testing.T) {
if got.Status.Message == "" {
t.Error("expected a failure message")
}
if got.Status.ObservedGeneration != mc.Generation {
t.Errorf("observedGeneration = %d, want %d", got.Status.ObservedGeneration, mc.Generation)
}
}
func TestMinecraftPruneSkipsUnownedWorkload(t *testing.T) {
@@ -212,6 +233,39 @@ func TestMinecraftStatusUsesReadyReplicas(t *testing.T) {
}
}
func TestMinecraftReconcileMutatesServiceAndWorkload(t *testing.T) {
mc := testMinecraft("mutable", 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), &current)
current.Spec.ListenPort = 25566
current.Spec.Resources.MemoryLimitMB = 4096
current.Generation = 2
if err := c.Update(context.Background(), &current); err != nil {
t.Fatalf("update server: %v", err)
}
if _, err := r.Reconcile(context.Background(), requestFor(&current)); err != nil {
t.Fatalf("second reconcile: %v", err)
}
key := client.ObjectKey{Name: resources.ServerName(mc.Name), Namespace: mc.Namespace}
var svc corev1.Service
mustGet(t, c, key, &svc)
if svc.Spec.Ports[0].Port != 25566 {
t.Errorf("service port = %d", svc.Spec.Ports[0].Port)
}
var dep appsv1.Deployment
mustGet(t, c, key, &dep)
if got := dep.Spec.Template.Spec.Containers[0].Resources.Limits.Memory().String(); got != "4Gi" {
t.Errorf("memory limit = %q", got)
}
}
func TestMinecraftReconcileApplyFailure(t *testing.T) {
mc := testMinecraft("lobby", v1alpha1.ServerStateless)
c := newInterceptedClient(t, interceptor.Funcs{
@@ -254,7 +308,7 @@ func TestMinecraftReconcileServiceFailure(t *testing.T) {
}
}
func TestMinecraftReconcileContinuesAfterPruneError(t *testing.T) {
func TestMinecraftReconcileReportsPruneError(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 {
@@ -265,8 +319,8 @@ func TestMinecraftReconcileContinuesAfterPruneError(t *testing.T) {
},
}, 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)
if _, err := r.Reconcile(context.Background(), requestFor(mc)); err == nil {
t.Fatal("expected prune failure")
}
}
@@ -41,6 +41,12 @@ func (r *ReverseProxyServerReconciler) Reconcile(ctx context.Context, req ctrl.R
if !rp.DeletionTimestamp.IsZero() {
return ctrl.Result{}, nil
}
if rp.Spec.Type != v1alpha1.ProxyVelocity && rp.Spec.Type != v1alpha1.ProxyBungeeCord {
return r.fail(ctx, &rp, "InvalidSpec", fmt.Errorf("unsupported proxy type %q", rp.Spec.Type))
}
if rp.Spec.ExternalPort <= 0 || rp.Spec.ListenPort <= 0 {
return r.fail(ctx, &rp, "InvalidSpec", fmt.Errorf("externalPort and listenPort must be set"))
}
if err := apply(ctx, r.Client, &rp, resources.ProxyConfigMap(&rp), r.Scheme); err != nil {
return r.fail(ctx, &rp, "ConfigMapFailed", err)
@@ -57,7 +63,10 @@ func (r *ReverseProxyServerReconciler) Reconcile(ctx context.Context, req ctrl.R
return r.fail(ctx, &rp, "PruneFailed", err)
}
return ctrl.Result{}, r.updateStatus(ctx, &rp)
if err := r.updateStatus(ctx, &rp); err != nil {
return r.fail(ctx, &rp, "StatusFailed", err)
}
return ctrl.Result{}, nil
}
func (r *ReverseProxyServerReconciler) pruneStaleResources(ctx context.Context, rp *v1alpha1.ReverseProxyServer) error {
@@ -160,6 +169,7 @@ func (r *ReverseProxyServerReconciler) fail(ctx context.Context, rp *v1alpha1.Re
return failWithStatus(ctx, r.Client, rp, cause, func() {
rp.Status.Phase = v1alpha1.PhaseFailed
rp.Status.Message = cause.Error()
rp.Status.ObservedGeneration = rp.Generation
setCondition(&rp.Status.Conditions, metav1.Condition{
Type: v1alpha1.ConditionReady,
Status: metav1.ConditionFalse,
@@ -45,6 +45,25 @@ func TestProxyReconcileCreatesResources(t *testing.T) {
}
}
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)
@@ -447,6 +466,46 @@ func TestProxyStatusReadyAndEndpoint(t *testing.T) {
}
}
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), &current)
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(), &current); err != nil {
t.Fatalf("update proxy: %v", err)
}
if _, err := r.Reconcile(context.Background(), requestFor(&current)); 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 {
+17 -4
View File
@@ -96,19 +96,32 @@ func JVMEnv(jvm v1alpha1.JVMOptions, limitMB int32) []corev1.EnvVar {
return env
}
func UserEnv(base []corev1.EnvVar, extra []v1alpha1.EnvVar) []corev1.EnvVar {
func UserEnv(base []corev1.EnvVar, extra []v1alpha1.EnvVar, protected ...string) []corev1.EnvVar {
index := make(map[string]int, len(base))
reserved := make(map[string]struct{}, len(protected))
for _, name := range protected {
reserved[name] = struct{}{}
}
for i, env := range base {
index[env.Name] = i
}
for _, e := range extra {
if _, ok := reserved[e.Name]; ok {
continue
}
var valueFrom *corev1.EnvVarSource
value := e.Value
if e.ValueFrom != nil {
valueFrom = e.ValueFrom.DeepCopy()
value = ""
}
if i, ok := index[e.Name]; ok {
base[i].Value = e.Value
base[i].ValueFrom = nil
base[i].Value = value
base[i].ValueFrom = valueFrom
continue
}
index[e.Name] = len(base)
base = append(base, corev1.EnvVar{Name: e.Name, Value: e.Value})
base = append(base, corev1.EnvVar{Name: e.Name, Value: value, ValueFrom: valueFrom})
}
return base
}
@@ -125,6 +125,35 @@ func TestUserEnvClearsValueFromOnOverride(t *testing.T) {
}
}
func TestUserEnvSupportsValueFrom(t *testing.T) {
extra := []v1alpha1.EnvVar{{
Name: "FROM_SECRET",
Value: "ignored",
ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: "settings"},
Key: "value",
}},
}}
got := UserEnv(nil, extra)
if len(got) != 1 || got[0].ValueFrom == nil || got[0].ValueFrom.SecretKeyRef == nil {
t.Fatalf("valueFrom was not preserved: %+v", got)
}
if got[0].ValueFrom.SecretKeyRef.Name != "settings" {
t.Errorf("secret name = %q", got[0].ValueFrom.SecretKeyRef.Name)
}
if got[0].Value != "" {
t.Errorf("literal value = %q, want empty with valueFrom", got[0].Value)
}
}
func TestUserEnvDoesNotOverrideProtectedValues(t *testing.T) {
base := []corev1.EnvVar{{Name: "MINIKURA_API_KEY", Value: "managed"}}
got := UserEnv(base, []v1alpha1.EnvVar{{Name: "MINIKURA_API_KEY", Value: "user"}}, "MINIKURA_API_KEY")
if got[0].Value != "managed" {
t.Errorf("protected value = %q, want managed", got[0].Value)
}
}
func TestUserEnvAppendsUnknownKeys(t *testing.T) {
base := []corev1.EnvVar{{Name: "TYPE", Value: "PAPER"}}
got := UserEnv(base, []v1alpha1.EnvVar{{Name: "EXTRA", Value: "1"}})
@@ -84,13 +84,12 @@ func minecraftEnv(mc *v1alpha1.MinecraftServer) []corev1.EnvVar {
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: mc.Spec.APIKeySecretRef},
Key: "api-key",
Optional: ptr(true),
},
},
})
}
return UserEnv(env, mc.Spec.Env)
return UserEnv(env, mc.Spec.Env, "EULA", "MINIKURA_API_KEY")
}
func minecraftPodSpec(mc *v1alpha1.MinecraftServer, stateful bool) corev1.PodSpec {
@@ -195,6 +195,9 @@ func TestMinecraftAPIKeyAndOptionalEnv(t *testing.T) {
if e.ValueFrom.SecretKeyRef.Name != "minikura-key" || e.ValueFrom.SecretKeyRef.Key != "api-key" {
t.Errorf("secret ref = %+v", e.ValueFrom.SecretKeyRef)
}
if e.ValueFrom.SecretKeyRef.Optional != nil {
t.Error("API key Secret must be required")
}
}
}
if !found {
@@ -202,6 +205,21 @@ func TestMinecraftAPIKeyAndOptionalEnv(t *testing.T) {
}
}
func TestMinecraftProtectedEnvCannotBeOverridden(t *testing.T) {
mc := testServer()
mc.Spec.APIKeySecretRef = "minikura-key"
mc.Spec.Env = []v1alpha1.EnvVar{{Name: "EULA", Value: "FALSE"}, {Name: "MINIKURA_API_KEY", Value: "inline"}}
env := minecraftEnv(mc)
if got, _ := envValue(env, "EULA"); got != "TRUE" {
t.Errorf("EULA = %q", got)
}
for _, item := range env {
if item.Name == "MINIKURA_API_KEY" && (item.ValueFrom == nil || item.ValueFrom.SecretKeyRef == nil) {
t.Error("API key secret reference was overridden")
}
}
}
func TestStatefulSetDefaultStorage(t *testing.T) {
mc := testServer()
mc.Spec.StorageSize = ""
+21 -5
View File
@@ -1,23 +1,39 @@
package resources
import (
"crypto/sha256"
"fmt"
"strings"
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
)
func ServerName(name string) string { return fmt.Sprintf("minecraft-%s", name) }
const dnsLabelMaxLength = 63
func ServerName(name string) string { return limitedName("minecraft-"+name, dnsLabelMaxLength) }
func ProxyName(kind v1alpha1.ProxyKind, name string) string {
return fmt.Sprintf("%s-%s", strings.ToLower(string(kind)), name)
return limitedName(fmt.Sprintf("%s-%s", strings.ToLower(string(kind)), name), dnsLabelMaxLength)
}
func ConfigMapName(base string) string { return limitedName(base+"-config", dnsLabelMaxLength) }
func limitedName(name string, limit int) string {
if len(name) <= limit {
return name
}
sum := sha256.Sum256([]byte(name))
suffix := fmt.Sprintf("-%x", sum[:4])
return strings.TrimRight(name[:limit-len(suffix)], "-") + suffix
}
func labelValue(value string) string {
return limitedName(value, dnsLabelMaxLength)
}
func ConfigMapName(base string) string { return base + "-config" }
func ServerLabels(mc *v1alpha1.MinecraftServer) map[string]string {
return map[string]string{
"app": ServerName(mc.Name),
v1alpha1.LabelServerType: strings.ToLower(string(mc.Spec.Type)),
v1alpha1.LabelServerID: mc.Name,
v1alpha1.LabelServerID: labelValue(mc.Name),
v1alpha1.LabelManagedBy: v1alpha1.ManagerName,
}
}
@@ -26,7 +42,7 @@ func ProxyLabels(rp *v1alpha1.ReverseProxyServer) map[string]string {
return map[string]string{
"app": ProxyName(rp.Spec.Type, rp.Name),
v1alpha1.LabelServerType: strings.ToLower(string(rp.Spec.Type)),
v1alpha1.LabelProxyID: rp.Name,
v1alpha1.LabelProxyID: labelValue(rp.Name),
v1alpha1.LabelManagedBy: v1alpha1.ManagerName,
}
}
@@ -1,6 +1,7 @@
package resources
import (
"strings"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -23,6 +24,36 @@ func TestServerAndProxyNames(t *testing.T) {
}
}
func TestGeneratedNamesFitDNSLabelsAndRemainDistinct(t *testing.T) {
name := strings.Repeat("a", 63)
server := ServerName(name)
config := ConfigMapName(server)
proxy := ProxyName(v1alpha1.ProxyBungeeCord, name)
for kind, got := range map[string]string{"server": server, "config": config, "proxy": proxy} {
if len(got) > 63 {
t.Errorf("%s name length = %d: %q", kind, len(got), got)
}
if strings.HasSuffix(got, "-") {
t.Errorf("%s name has invalid suffix: %q", kind, got)
}
}
if ServerName(name) == ServerName(strings.Repeat("a", 62)+"b") {
t.Error("different long names collided")
}
}
func TestLongResourceNamesProduceValidLabelValues(t *testing.T) {
name := strings.Repeat("a", 63)
mc := &v1alpha1.MinecraftServer{ObjectMeta: metav1.ObjectMeta{Name: name}}
if got := ServerLabels(mc)[v1alpha1.LabelServerID]; len(got) > 63 {
t.Errorf("server-id label length = %d", len(got))
}
rp := &v1alpha1.ReverseProxyServer{ObjectMeta: metav1.ObjectMeta{Name: name}}
if got := ProxyLabels(rp)[v1alpha1.LabelProxyID]; len(got) > 63 {
t.Errorf("proxy-id label length = %d", len(got))
}
}
func TestServerLabels(t *testing.T) {
mc := &v1alpha1.MinecraftServer{
ObjectMeta: metav1.ObjectMeta{Name: "smp"},
@@ -1,6 +1,8 @@
package resources
import (
"strings"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -60,13 +62,36 @@ func proxyEnv(rp *v1alpha1.ReverseProxyServer) []corev1.EnvVar {
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: rp.Spec.APIKeySecretRef},
Key: "api-key",
Optional: ptr(true),
},
},
})
}
protected := []string{"MINIKURA_API_KEY"}
if rp.Spec.BackendURL != "" {
apiURL := strings.TrimRight(rp.Spec.BackendURL, "/")
env = append(env,
corev1.EnvVar{Name: "MINIKURA_API_URL", Value: apiURL},
corev1.EnvVar{Name: "MINIKURA_WEBSOCKET_URL", Value: websocketURL(apiURL)},
)
protected = append(protected, "MINIKURA_API_URL", "MINIKURA_WEBSOCKET_URL")
}
if rp.Spec.Type == v1alpha1.ProxyVelocity && rp.Spec.PluginURL != "" {
env = append(env, corev1.EnvVar{Name: "PLUGINS", Value: rp.Spec.PluginURL})
protected = append(protected, "PLUGINS")
}
return UserEnv(env, rp.Spec.Env)
return UserEnv(env, rp.Spec.Env, protected...)
}
func websocketURL(apiURL string) string {
url := strings.TrimRight(apiURL, "/") + "/servers/ws"
if strings.HasPrefix(url, "https://") {
return "wss://" + strings.TrimPrefix(url, "https://")
}
if strings.HasPrefix(url, "http://") {
return "ws://" + strings.TrimPrefix(url, "http://")
}
return url
}
func ProxyDeployment(rp *v1alpha1.ReverseProxyServer) *appsv1.Deployment {
@@ -105,9 +105,58 @@ func TestProxyAPIKeyEnv(t *testing.T) {
if e.ValueFrom == nil || e.ValueFrom.SecretKeyRef.Name != "proxy-key" {
t.Errorf("secret ref = %+v", e.ValueFrom)
}
if e.ValueFrom.SecretKeyRef.Optional != nil {
t.Error("API key Secret must be required")
}
}
}
if !found {
t.Error("MINIKURA_API_KEY missing")
}
}
func TestProxyPluginBackendWiring(t *testing.T) {
rp := testProxy()
rp.Spec.APIKeySecretRef = "proxy-key"
rp.Spec.BackendURL = "https://backend.example.com/api/"
rp.Spec.PluginURL = "https://downloads.example.com/minikura.jar"
rp.Spec.Env = []v1alpha1.EnvVar{
{Name: "MINIKURA_API_URL", Value: "http://attacker"},
{Name: "PLUGINS", Value: "http://attacker/plugin.jar"},
}
env := proxyEnv(rp)
want := map[string]string{
"MINIKURA_API_URL": "https://backend.example.com/api",
"MINIKURA_WEBSOCKET_URL": "wss://backend.example.com/api/servers/ws",
"PLUGINS": "https://downloads.example.com/minikura.jar",
}
for name, value := range want {
if got, ok := envValue(env, name); !ok || got != value {
t.Errorf("%s = %q, %v; want %q", name, got, ok, value)
}
}
}
func TestBungeeCordDoesNotInstallVelocityPlugin(t *testing.T) {
rp := testProxy()
rp.Spec.Type = v1alpha1.ProxyBungeeCord
rp.Spec.PluginURL = "https://downloads.example.com/minikura.jar"
if _, ok := envValue(proxyEnv(rp), "PLUGINS"); ok {
t.Error("Velocity plugin must not be installed on BungeeCord")
}
}
func TestProxyLegacyEnvWiringRemainsAvailableWhenFieldsAreUnset(t *testing.T) {
rp := testProxy()
rp.Spec.Env = []v1alpha1.EnvVar{
{Name: "MINIKURA_API_URL", Value: "http://legacy-backend/api"},
{Name: "PLUGINS", Value: "http://legacy/plugin.jar"},
}
env := proxyEnv(rp)
if got, _ := envValue(env, "MINIKURA_API_URL"); got != "http://legacy-backend/api" {
t.Errorf("legacy API URL = %q", got)
}
if got, _ := envValue(env, "PLUGINS"); got != "http://legacy/plugin.jar" {
t.Errorf("legacy plugin URL = %q", got)
}
}