From e90dc8382bd73e05542b92d5c51da231079d518d Mon Sep 17 00:00:00 2001 From: Yuzu Date: Thu, 13 Aug 2026 03:29:29 +0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20harden=20operator=20recon?= =?UTF-8?q?ciliation=20and=20install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 + README.md | 14 ++ operator/Dockerfile | 3 +- operator/Makefile | 13 +- operator/api/v1alpha1/common.go | 6 + .../api/v1alpha1/reverseproxyserver_types.go | 11 +- .../api/v1alpha1/zz_generated.deepcopy.go | 22 ++- ...nikura.kirameki.cafe_minecraftservers.yaml | 135 +++++++++++++++- ...ura.kirameki.cafe_reverseproxyservers.yaml | 146 +++++++++++++++++- operator/config/manager/deployment.yaml | 1 + operator/config/rbac/backend.yaml | 44 +++++- operator/config/rbac/service_account.yaml | 2 +- operator/internal/controller/apply.go | 31 ++++ operator/internal/controller/apply_test.go | 22 +++ .../controller/minecraftserver_controller.go | 18 ++- .../minecraftserver_controller_test.go | 60 ++++++- .../reverseproxyserver_controller.go | 12 +- .../reverseproxyserver_controller_test.go | 59 +++++++ operator/internal/resources/common.go | 21 ++- operator/internal/resources/common_test.go | 29 ++++ .../internal/resources/minecraftserver.go | 3 +- .../resources/minecraftserver_test.go | 18 +++ operator/internal/resources/naming.go | 26 +++- operator/internal/resources/naming_test.go | 31 ++++ .../internal/resources/reverseproxyserver.go | 29 +++- .../resources/reverseproxyserver_test.go | 49 ++++++ package.json | 1 + scripts/install.sh | 59 ++++--- 28 files changed, 800 insertions(+), 71 deletions(-) diff --git a/.env.example b/.env.example index 480f03f..9c12797 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,9 @@ API_URL="http://localhost:3000" # Kubernetes Configuration KUBERNETES_NAMESPACE="minikura" + +# Base API URL reachable from operator-managed proxy pods +MINIKURA_OPERATOR_BACKEND_URL="http://minikura-backend:3000/api" + +# Comma-separated download URLs for RedisBungee and the shaded Minikura Velocity plugin JAR +# MINIKURA_VELOCITY_PLUGIN_URL="https://example.com/redisbungee.jar,https://example.com/minikura-velocity.jar" diff --git a/README.md b/README.md index e025c8a..53f5e25 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,20 @@ Minikura is designed to simplify the management and deployment of Minecraft serv 🚧 **Note:** Minikura is in heavy development and very incomplete. Its scope, features, and roadmap are subject to change as the project evolves. +## Kubernetes Operator + +Install the checked-in CRDs, least-privilege RBAC, service accounts, and operator Deployment into the current Kubernetes context: + +```sh +OPERATOR_IMAGE=registry.example.com/minikura-operator:tag bun run setup +``` + +`OPERATOR_IMAGE` defaults to `minikura-operator:latest` for clusters where that image is already available. Set `KUBERNETES_NAMESPACE` to install outside `minikura` and `ROLLOUT_TIMEOUT` to change the default `120s` readiness timeout. An in-cluster backend Deployment must use the `minikura-backend` service account in the same namespace. + +Operator-managed proxy pods use `MINIKURA_OPERATOR_BACKEND_URL` to reach the backend, defaulting to `http://minikura-backend:3000/api`. Set `MINIKURA_VELOCITY_PLUGIN_URL` to comma-separated RedisBungee and shaded Minikura Velocity plugin JAR URLs to install both required plugins in Velocity pods. + +Run `bun run operator:validate` for shell syntax and client-side Kubernetes manifest validation without connecting to a cluster. + --- ## 🚀 Planned Feature Set diff --git a/operator/Dockerfile b/operator/Dockerfile index 2296571..6d529bd 100644 --- a/operator/Dockerfile +++ b/operator/Dockerfile @@ -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 ./ diff --git a/operator/Makefile b/operator/Makefile index cbb870f..e3cec31 100644 --- a/operator/Makefile +++ b/operator/Makefile @@ -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 diff --git a/operator/api/v1alpha1/common.go b/operator/api/v1alpha1/common.go index 84dd949..957c63e 100644 --- a/operator/api/v1alpha1/common.go +++ b/operator/api/v1alpha1/common.go @@ -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 diff --git a/operator/api/v1alpha1/reverseproxyserver_types.go b/operator/api/v1alpha1/reverseproxyserver_types.go index bf67c0d..6f70b34 100644 --- a/operator/api/v1alpha1/reverseproxyserver_types.go +++ b/operator/api/v1alpha1/reverseproxyserver_types.go @@ -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"` diff --git a/operator/api/v1alpha1/zz_generated.deepcopy.go b/operator/api/v1alpha1/zz_generated.deepcopy.go index 30e4a69..2700e8c 100644 --- a/operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/operator/api/v1alpha1/zz_generated.deepcopy.go @@ -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]) } diff --git a/operator/config/crd/minikura.kirameki.cafe_minecraftservers.yaml b/operator/config/crd/minikura.kirameki.cafe_minecraftservers.yaml index 2bc5dce..1ddd84b 100644 --- a/operator/config/crd/minikura.kirameki.cafe_minecraftservers.yaml +++ b/operator/config/crd/minikura.kirameki.cafe_minecraftservers.yaml @@ -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['']`, `metadata.annotations['']`, + 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 diff --git a/operator/config/crd/minikura.kirameki.cafe_reverseproxyservers.yaml b/operator/config/crd/minikura.kirameki.cafe_reverseproxyservers.yaml index 711d3af..d3c2385 100644 --- a/operator/config/crd/minikura.kirameki.cafe_reverseproxyservers.yaml +++ b/operator/config/crd/minikura.kirameki.cafe_reverseproxyservers.yaml @@ -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['']`, `metadata.annotations['']`, + 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 diff --git a/operator/config/manager/deployment.yaml b/operator/config/manager/deployment.yaml index 407871c..c192a3e 100644 --- a/operator/config/manager/deployment.yaml +++ b/operator/config/manager/deployment.yaml @@ -22,6 +22,7 @@ spec: containers: - name: operator image: minikura-operator:latest + imagePullPolicy: Always args: - --leader-elect env: diff --git a/operator/config/rbac/backend.yaml b/operator/config/rbac/backend.yaml index 14de280..5086548 100644 --- a/operator/config/rbac/backend.yaml +++ b/operator/config/rbac/backend.yaml @@ -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 diff --git a/operator/config/rbac/service_account.yaml b/operator/config/rbac/service_account.yaml index 3d6737a..e903c15 100644 --- a/operator/config/rbac/service_account.yaml +++ b/operator/config/rbac/service_account.yaml @@ -4,7 +4,7 @@ metadata: name: minikura-operator --- apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding +kind: RoleBinding metadata: name: minikura-operator-rolebinding roleRef: diff --git a/operator/internal/controller/apply.go b/operator/internal/controller/apply.go index fe506c8..9c5139e 100644 --- a/operator/internal/controller/apply.go +++ b/operator/internal/controller/apply.go @@ -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), ¤t); 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 +} diff --git a/operator/internal/controller/apply_test.go b/operator/internal/controller/apply_test.go index d064a5f..c8237f1 100644 --- a/operator/internal/controller/apply_test.go +++ b/operator/internal/controller/apply_test.go @@ -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{ diff --git a/operator/internal/controller/minecraftserver_controller.go b/operator/internal/controller/minecraftserver_controller.go index 883c4c9..2b24190 100644 --- a/operator/internal/controller/minecraftserver_controller.go +++ b/operator/internal/controller/minecraftserver_controller.go @@ -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, diff --git a/operator/internal/controller/minecraftserver_controller_test.go b/operator/internal/controller/minecraftserver_controller_test.go index 9eb489e..af25a68 100644 --- a/operator/internal/controller/minecraftserver_controller_test.go +++ b/operator/internal/controller/minecraftserver_controller_test.go @@ -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), ¤t) + current.Spec.ListenPort = 25566 + current.Spec.Resources.MemoryLimitMB = 4096 + current.Generation = 2 + if err := c.Update(context.Background(), ¤t); err != nil { + t.Fatalf("update server: %v", err) + } + if _, err := r.Reconcile(context.Background(), requestFor(¤t)); 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") } } diff --git a/operator/internal/controller/reverseproxyserver_controller.go b/operator/internal/controller/reverseproxyserver_controller.go index bbcef04..ad2730b 100644 --- a/operator/internal/controller/reverseproxyserver_controller.go +++ b/operator/internal/controller/reverseproxyserver_controller.go @@ -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, diff --git a/operator/internal/controller/reverseproxyserver_controller_test.go b/operator/internal/controller/reverseproxyserver_controller_test.go index f633f84..0d9278d 100644 --- a/operator/internal/controller/reverseproxyserver_controller_test.go +++ b/operator/internal/controller/reverseproxyserver_controller_test.go @@ -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), ¤t) + current.Spec.ExternalPort = 25570 + current.Spec.BackendURL = "http://backend:3000/api" + current.Spec.APIKeySecretRef = "proxy-key" + current.Generation = 2 + if err := c.Update(context.Background(), ¤t); err != nil { + t.Fatalf("update proxy: %v", err) + } + if _, err := r.Reconcile(context.Background(), requestFor(¤t)); err != nil { + t.Fatalf("second reconcile: %v", err) + } + + key := client.ObjectKey{Name: resources.ProxyName(rp.Spec.Type, rp.Name), Namespace: rp.Namespace} + var svc corev1.Service + mustGet(t, c, key, &svc) + if svc.Spec.Ports[0].Port != 25570 { + t.Errorf("service port = %d", svc.Spec.Ports[0].Port) + } + var dep appsv1.Deployment + mustGet(t, c, key, &dep) + found := false + for _, env := range dep.Spec.Template.Spec.Containers[0].Env { + if env.Name == "MINIKURA_API_URL" && env.Value == "http://backend:3000/api" { + found = true + } + } + if !found { + t.Error("deployment was not updated with backend URL") + } +} + func TestProxiesForServerListError(t *testing.T) { c := newInterceptedClient(t, interceptor.Funcs{ List: func(ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { diff --git a/operator/internal/resources/common.go b/operator/internal/resources/common.go index 2224861..43be827 100644 --- a/operator/internal/resources/common.go +++ b/operator/internal/resources/common.go @@ -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 } diff --git a/operator/internal/resources/common_test.go b/operator/internal/resources/common_test.go index 1637692..627b534 100644 --- a/operator/internal/resources/common_test.go +++ b/operator/internal/resources/common_test.go @@ -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"}}) diff --git a/operator/internal/resources/minecraftserver.go b/operator/internal/resources/minecraftserver.go index 28f6901..123a697 100644 --- a/operator/internal/resources/minecraftserver.go +++ b/operator/internal/resources/minecraftserver.go @@ -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 { diff --git a/operator/internal/resources/minecraftserver_test.go b/operator/internal/resources/minecraftserver_test.go index 7278d0d..e1c3d31 100644 --- a/operator/internal/resources/minecraftserver_test.go +++ b/operator/internal/resources/minecraftserver_test.go @@ -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 = "" diff --git a/operator/internal/resources/naming.go b/operator/internal/resources/naming.go index b2bc715..35fdb81 100644 --- a/operator/internal/resources/naming.go +++ b/operator/internal/resources/naming.go @@ -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, } } diff --git a/operator/internal/resources/naming_test.go b/operator/internal/resources/naming_test.go index 0c4f1c3..92f5475 100644 --- a/operator/internal/resources/naming_test.go +++ b/operator/internal/resources/naming_test.go @@ -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"}, diff --git a/operator/internal/resources/reverseproxyserver.go b/operator/internal/resources/reverseproxyserver.go index 8c5d048..4adde61 100644 --- a/operator/internal/resources/reverseproxyserver.go +++ b/operator/internal/resources/reverseproxyserver.go @@ -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 { diff --git a/operator/internal/resources/reverseproxyserver_test.go b/operator/internal/resources/reverseproxyserver_test.go index a0b07d8..46e184f 100644 --- a/operator/internal/resources/reverseproxyserver_test.go +++ b/operator/internal/resources/reverseproxyserver_test.go @@ -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) + } +} diff --git a/package.json b/package.json index 0290f97..c01cb51 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "operator:test": "make -C operator test", "operator:crds": "make -C operator install-crds", "operator:deploy": "make -C operator deploy", + "operator:validate": "bash -n scripts/install.sh && kubectl create --dry-run=client --validate=false -f operator/config/crd -f operator/config/rbac/role.yaml -f operator/config/rbac/service_account.yaml -f operator/config/rbac/backend.yaml -f operator/config/manager/deployment.yaml -o name >/dev/null", "setup": "bash scripts/install.sh" }, "devDependencies": { diff --git a/scripts/install.sh b/scripts/install.sh index 9341a82..4e1c869 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,10 +1,20 @@ -#!/bin/bash -set -e +#!/usr/bin/env bash +set -euo pipefail NAMESPACE="${KUBERNETES_NAMESPACE:-minikura}" +OPERATOR_IMAGE="${OPERATOR_IMAGE:-minikura-operator:latest}" +ROLLOUT_TIMEOUT="${ROLLOUT_TIMEOUT:-120s}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +case "$NAMESPACE" in + ''|*[!a-z0-9-]*|-*|*-) echo "[ERROR] KUBERNETES_NAMESPACE must be a DNS label" >&2; exit 1 ;; +esac +if (( ${#NAMESPACE} > 63 )); then + echo "[ERROR] KUBERNETES_NAMESPACE must not exceed 63 characters" >&2 + exit 1 +fi + echo "╔════════════════════════════════════════════════╗" echo "║ Minikura Kubernetes Installer ║" echo "╚════════════════════════════════════════════════╝" @@ -12,15 +22,13 @@ echo "" echo "-> Checking prerequisites..." if ! command -v kubectl &> /dev/null; then - echo "[WARN] kubectl not found. Skipping k8s setup." - echo "[INFO] Install kubectl and run 'bash scripts/install.sh' manually when ready." - exit 0 + echo "[ERROR] kubectl is required" >&2 + exit 1 fi if ! kubectl cluster-info &> /dev/null; then - echo "[WARN] Cannot connect to Kubernetes cluster. Skipping k8s setup." - echo "[INFO] Run 'bash scripts/install.sh' manually when cluster is ready." - exit 0 + echo "[ERROR] Cannot connect to the current Kubernetes cluster" >&2 + exit 1 fi echo "[OK] kubectl found" @@ -28,23 +36,33 @@ echo "[OK] Connected to Kubernetes cluster" echo "" echo "-> Creating namespace: $NAMESPACE" -kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f - +kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - echo "" echo "-> Installing operator CRDs" -make -C "$PROJECT_ROOT/operator" install-crds +kubectl apply -f "$PROJECT_ROOT/operator/config/crd" echo "[OK] CRDs installed" echo "" -echo "-> Setting up Go operator RBAC" +echo "-> Configuring operator and backend RBAC" +# Remove bindings created by older releases before replacing them with namespaced access. +kubectl delete clusterrolebinding minikura-operator-rolebinding \ + minikura-backend-operator-resources --ignore-not-found +kubectl delete clusterrole minikura-backend-operator-resources --ignore-not-found kubectl apply -f "$PROJECT_ROOT/operator/config/rbac/role.yaml" -kubectl apply -n "$NAMESPACE" -f "$PROJECT_ROOT/operator/config/rbac/service_account.yaml" -kubectl apply -n "$NAMESPACE" -f "$PROJECT_ROOT/operator/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\"}]" -echo "[OK] Operator RBAC configured" +sed "s/namespace: minikura/namespace: $NAMESPACE/g" \ + "$PROJECT_ROOT/operator/config/rbac/service_account.yaml" | kubectl apply -n "$NAMESPACE" -f - +sed "s/namespace: minikura/namespace: $NAMESPACE/g" \ + "$PROJECT_ROOT/operator/config/rbac/backend.yaml" | kubectl apply -n "$NAMESPACE" -f - +echo "[OK] RBAC configured" +echo "" + +echo "-> Deploying operator: $OPERATOR_IMAGE" +kubectl set image -f "$PROJECT_ROOT/operator/config/manager/deployment.yaml" \ + operator="$OPERATOR_IMAGE" --local -o yaml | kubectl apply -n "$NAMESPACE" -f - +kubectl rollout restart -n "$NAMESPACE" deployment/minikura-operator +kubectl rollout status -n "$NAMESPACE" deployment/minikura-operator --timeout="$ROLLOUT_TIMEOUT" +echo "[OK] Operator deployed" echo "" echo "╔════════════════════════════════════════════════╗" @@ -54,8 +72,9 @@ echo "" echo "Resources created:" echo " [OK] Namespace: $NAMESPACE" echo " [OK] ServiceAccount: minikura-operator" -echo " [OK] ClusterRole + ClusterRoleBinding" +echo " [OK] ServiceAccount: minikura-backend" +echo " [OK] Operator Deployment: $OPERATOR_IMAGE" echo "" echo "Next steps:" -echo " bun run dev - Start backend, web, and Go operator" +echo " Configure an in-cluster backend Deployment to use serviceAccountName: minikura-backend" echo ""