🐛 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
+2 -1
View File
@@ -1,4 +1,5 @@
FROM golang:1.26 AS build
ARG GO_VERSION=1.26.5
FROM golang:${GO_VERSION} AS build
WORKDIR /workspace
COPY go.mod go.sum ./
+3 -10
View File
@@ -44,7 +44,7 @@ docker-build:
docker build -t $(IMG) .
.PHONY: install-crds
install-crds: manifests
install-crds:
kubectl apply -f config/crd
.PHONY: uninstall-crds
@@ -52,12 +52,5 @@ uninstall-crds:
kubectl delete --ignore-not-found -f config/crd
.PHONY: deploy
deploy: manifests
kubectl create namespace $(NAMESPACE) --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f config/crd
kubectl apply -f config/rbac/role.yaml
kubectl apply -n $(NAMESPACE) -f config/rbac/service_account.yaml
kubectl apply -n $(NAMESPACE) -f config/rbac/backend.yaml
kubectl patch clusterrolebinding minikura-operator-rolebinding --type=json -p='[{"op":"replace","path":"/subjects/0/namespace","value":"$(NAMESPACE)"}]'
kubectl patch clusterrolebinding minikura-backend-operator-resources --type=json -p='[{"op":"replace","path":"/subjects/0/namespace","value":"$(NAMESPACE)"}]'
sed 's|minikura-operator:latest|$(IMG)|' config/manager/deployment.yaml | kubectl apply -n $(NAMESPACE) -f -
deploy:
KUBERNETES_NAMESPACE="$(NAMESPACE)" OPERATOR_IMAGE="$(IMG)" bash ../scripts/install.sh
+6
View File
@@ -1,5 +1,7 @@
package v1alpha1
import corev1 "k8s.io/api/core/v1"
const (
Domain = "minikura.kirameki.cafe"
LabelPrefix = Domain
@@ -18,11 +20,15 @@ const (
ConditionReady = "Ready"
)
// +kubebuilder:validation:XValidation:rule="!(has(self.value) && has(self.valueFrom))",message="value and valueFrom are mutually exclusive"
type EnvVar struct {
Name string `json:"name"`
// +optional
Value string `json:"value,omitempty"`
// +optional
ValueFrom *corev1.EnvVarSource `json:"valueFrom,omitempty"`
}
// +kubebuilder:validation:Enum=ClusterIP;NodePort;LoadBalancer
@@ -46,8 +46,17 @@ type ReverseProxyServerSpec struct {
// +optional
Env []EnvVar `json:"env,omitempty"`
APIKeySecretRef string `json:"apiKeySecretRef"`
// BackendURL is the base URL used by the Minikura proxy plugin, including
// the API path (for example, http://minikura-backend:3000/api).
// +optional
APIKeySecretRef string `json:"apiKeySecretRef,omitempty"`
BackendURL string `json:"backendURL,omitempty"`
// PluginURL is a comma-separated list of download URLs understood by the
// proxy image's PLUGINS installer. It must include plugin dependencies.
// +optional
PluginURL string `json:"pluginURL,omitempty"`
// +optional
BackendSelector *metav1.LabelSelector `json:"backendSelector,omitempty"`
+16 -6
View File
@@ -5,13 +5,19 @@
package v1alpha1
import (
"k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *EnvVar) DeepCopyInto(out *EnvVar) {
*out = *in
if in.ValueFrom != nil {
in, out := &in.ValueFrom, &out.ValueFrom
*out = new(v1.EnvVarSource)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvVar.
@@ -132,7 +138,9 @@ func (in *MinecraftServerSpec) DeepCopyInto(out *MinecraftServerSpec) {
if in.Env != nil {
in, out := &in.Env, &out.Env
*out = make([]EnvVar, len(*in))
copy(*out, *in)
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
@@ -151,7 +159,7 @@ func (in *MinecraftServerStatus) DeepCopyInto(out *MinecraftServerStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
*out = make([]metav1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
@@ -250,11 +258,13 @@ func (in *ReverseProxyServerSpec) DeepCopyInto(out *ReverseProxyServerSpec) {
if in.Env != nil {
in, out := &in.Env, &out.Env
*out = make([]EnvVar, len(*in))
copy(*out, *in)
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.BackendSelector != nil {
in, out := &in.BackendSelector, &out.BackendSelector
*out = new(v1.LabelSelector)
*out = new(metav1.LabelSelector)
(*in).DeepCopyInto(*out)
}
}
@@ -279,7 +289,7 @@ func (in *ReverseProxyServerStatus) DeepCopyInto(out *ReverseProxyServerStatus)
}
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
*out = make([]metav1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
@@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.21.0
controller-gen.kubebuilder.io/version: v0.20.0
name: minecraftservers.minikura.kirameki.cafe
spec:
group: minikura.kirameki.cafe
@@ -69,9 +69,142 @@ spec:
type: string
value:
type: string
valueFrom:
description: EnvVarSource represents a source for the value
of an EnvVar.
properties:
configMapKeyRef:
description: Selects a key of a ConfigMap.
properties:
key:
description: The key to select.
type: string
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
optional:
description: Specify whether the ConfigMap or its key
must be defined
type: boolean
required:
- key
type: object
x-kubernetes-map-type: atomic
fieldRef:
description: |-
Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['<KEY>']`, `metadata.annotations['<KEY>']`,
spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs.
properties:
apiVersion:
description: Version of the schema the FieldPath is
written in terms of, defaults to "v1".
type: string
fieldPath:
description: Path of the field to select in the specified
API version.
type: string
required:
- fieldPath
type: object
x-kubernetes-map-type: atomic
fileKeyRef:
description: |-
FileKeyRef selects a key of the env file.
Requires the EnvFiles feature gate to be enabled.
properties:
key:
description: |-
The key within the env file. An invalid key will prevent the pod from starting.
The keys defined within a source may consist of any printable ASCII characters except '='.
During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters.
type: string
optional:
default: false
description: |-
Specify whether the file or its key must be defined. If the file or key
does not exist, then the env var is not published.
If optional is set to true and the specified key does not exist,
the environment variable will not be set in the Pod's containers.
If optional is set to false and the specified key does not exist,
an error will be returned during Pod creation.
type: boolean
path:
description: |-
The path within the volume from which to select the file.
Must be relative and may not contain the '..' path or start with '..'.
type: string
volumeName:
description: The name of the volume mount containing
the env file.
type: string
required:
- key
- path
- volumeName
type: object
x-kubernetes-map-type: atomic
resourceFieldRef:
description: |-
Selects a resource of the container: only resources limits and requests
(limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported.
properties:
containerName:
description: 'Container name: required for volumes,
optional for env vars'
type: string
divisor:
anyOf:
- type: integer
- type: string
description: Specifies the output format of the exposed
resources, defaults to "1"
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
resource:
description: 'Required: resource to select'
type: string
required:
- resource
type: object
x-kubernetes-map-type: atomic
secretKeyRef:
description: Selects a key of a secret in the pod's namespace
properties:
key:
description: The key of the secret to select from. Must
be a valid secret key.
type: string
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
optional:
description: Specify whether the Secret or its key must
be defined
type: boolean
required:
- key
type: object
x-kubernetes-map-type: atomic
type: object
required:
- name
type: object
x-kubernetes-validations:
- message: value and valueFrom are mutually exclusive
rule: '!(has(self.value) && has(self.valueFrom))'
type: array
jarType:
default: VANILLA
@@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.21.0
controller-gen.kubebuilder.io/version: v0.20.0
name: reverseproxyservers.minikura.kirameki.cafe
spec:
group: minikura.kirameki.cafe
@@ -106,6 +106,11 @@ spec:
type: object
type: object
x-kubernetes-map-type: atomic
backendURL:
description: |-
BackendURL is the base URL used by the Minikura proxy plugin, including
the API path (for example, http://minikura-backend:3000/api).
type: string
description:
type: string
env:
@@ -115,9 +120,142 @@ spec:
type: string
value:
type: string
valueFrom:
description: EnvVarSource represents a source for the value
of an EnvVar.
properties:
configMapKeyRef:
description: Selects a key of a ConfigMap.
properties:
key:
description: The key to select.
type: string
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
optional:
description: Specify whether the ConfigMap or its key
must be defined
type: boolean
required:
- key
type: object
x-kubernetes-map-type: atomic
fieldRef:
description: |-
Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['<KEY>']`, `metadata.annotations['<KEY>']`,
spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs.
properties:
apiVersion:
description: Version of the schema the FieldPath is
written in terms of, defaults to "v1".
type: string
fieldPath:
description: Path of the field to select in the specified
API version.
type: string
required:
- fieldPath
type: object
x-kubernetes-map-type: atomic
fileKeyRef:
description: |-
FileKeyRef selects a key of the env file.
Requires the EnvFiles feature gate to be enabled.
properties:
key:
description: |-
The key within the env file. An invalid key will prevent the pod from starting.
The keys defined within a source may consist of any printable ASCII characters except '='.
During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters.
type: string
optional:
default: false
description: |-
Specify whether the file or its key must be defined. If the file or key
does not exist, then the env var is not published.
If optional is set to true and the specified key does not exist,
the environment variable will not be set in the Pod's containers.
If optional is set to false and the specified key does not exist,
an error will be returned during Pod creation.
type: boolean
path:
description: |-
The path within the volume from which to select the file.
Must be relative and may not contain the '..' path or start with '..'.
type: string
volumeName:
description: The name of the volume mount containing
the env file.
type: string
required:
- key
- path
- volumeName
type: object
x-kubernetes-map-type: atomic
resourceFieldRef:
description: |-
Selects a resource of the container: only resources limits and requests
(limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported.
properties:
containerName:
description: 'Container name: required for volumes,
optional for env vars'
type: string
divisor:
anyOf:
- type: integer
- type: string
description: Specifies the output format of the exposed
resources, defaults to "1"
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
x-kubernetes-int-or-string: true
resource:
description: 'Required: resource to select'
type: string
required:
- resource
type: object
x-kubernetes-map-type: atomic
secretKeyRef:
description: Selects a key of a secret in the pod's namespace
properties:
key:
description: The key of the secret to select from. Must
be a valid secret key.
type: string
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
optional:
description: Specify whether the Secret or its key must
be defined
type: boolean
required:
- key
type: object
x-kubernetes-map-type: atomic
type: object
required:
- name
type: object
x-kubernetes-validations:
- message: value and valueFrom are mutually exclusive
rule: '!(has(self.value) && has(self.valueFrom))'
type: array
externalAddress:
type: string
@@ -152,6 +290,11 @@ spec:
maximum: 32767
minimum: 30000
type: integer
pluginURL:
description: |-
PluginURL is a comma-separated list of download URLs understood by the
proxy image's PLUGINS installer. It must include plugin dependencies.
type: string
resources:
properties:
cpuLimit:
@@ -183,6 +326,7 @@ spec:
- BUNGEECORD
type: string
required:
- apiKeySecretRef
- externalAddress
- externalPort
- type
+1
View File
@@ -22,6 +22,7 @@ spec:
containers:
- name: operator
image: minikura-operator:latest
imagePullPolicy: Always
args:
- --leader-elect
env:
+36 -8
View File
@@ -4,41 +4,69 @@ metadata:
name: minikura-backend
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
kind: Role
metadata:
name: minikura-backend-operator-resources
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "create", "update", "delete"]
verbs: ["get", "create", "update", "delete"]
- apiGroups: [""]
resources: ["pods", "services", "configmaps", "nodes"]
resources: ["pods", "services", "configmaps"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get", "list"]
verbs: ["get"]
- apiGroups: [""]
resources: ["pods/exec"]
resources: ["pods/attach", "pods/exec"]
verbs: ["get", "create"]
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets"]
verbs: ["get", "list", "watch"]
- apiGroups: ["metrics.k8s.io"]
resources: ["pods", "nodes"]
resources: ["pods"]
verbs: ["get", "list"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch"]
- apiGroups: ["minikura.kirameki.cafe"]
resources: ["minecraftservers", "reverseproxyservers"]
verbs: ["get", "list", "watch", "create", "update", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
kind: RoleBinding
metadata:
name: minikura-backend-operator-resources
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
kind: Role
name: minikura-backend-operator-resources
subjects:
- kind: ServiceAccount
name: minikura-backend
namespace: minikura
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: minikura-backend-cluster-observer
rules:
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get", "list"]
- apiGroups: ["metrics.k8s.io"]
resources: ["nodes"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: minikura-backend-cluster-observer
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: minikura-backend-cluster-observer
subjects:
- kind: ServiceAccount
name: minikura-backend
namespace: minikura
+1 -1
View File
@@ -4,7 +4,7 @@ metadata:
name: minikura-operator
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
kind: RoleBinding
metadata:
name: minikura-operator-rolebinding
roleRef:
+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)
}
}