🐛 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
+6
View File
@@ -11,3 +11,9 @@ API_URL="http://localhost:3000"
# Kubernetes Configuration # Kubernetes Configuration
KUBERNETES_NAMESPACE="minikura" 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"
+14
View File
@@ -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. 🚧 **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 ## 🚀 Planned Feature Set
+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 WORKDIR /workspace
COPY go.mod go.sum ./ COPY go.mod go.sum ./
+3 -10
View File
@@ -44,7 +44,7 @@ docker-build:
docker build -t $(IMG) . docker build -t $(IMG) .
.PHONY: install-crds .PHONY: install-crds
install-crds: manifests install-crds:
kubectl apply -f config/crd kubectl apply -f config/crd
.PHONY: uninstall-crds .PHONY: uninstall-crds
@@ -52,12 +52,5 @@ uninstall-crds:
kubectl delete --ignore-not-found -f config/crd kubectl delete --ignore-not-found -f config/crd
.PHONY: deploy .PHONY: deploy
deploy: manifests deploy:
kubectl create namespace $(NAMESPACE) --dry-run=client -o yaml | kubectl apply -f - KUBERNETES_NAMESPACE="$(NAMESPACE)" OPERATOR_IMAGE="$(IMG)" bash ../scripts/install.sh
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 -
+6
View File
@@ -1,5 +1,7 @@
package v1alpha1 package v1alpha1
import corev1 "k8s.io/api/core/v1"
const ( const (
Domain = "minikura.kirameki.cafe" Domain = "minikura.kirameki.cafe"
LabelPrefix = Domain LabelPrefix = Domain
@@ -18,11 +20,15 @@ const (
ConditionReady = "Ready" ConditionReady = "Ready"
) )
// +kubebuilder:validation:XValidation:rule="!(has(self.value) && has(self.valueFrom))",message="value and valueFrom are mutually exclusive"
type EnvVar struct { type EnvVar struct {
Name string `json:"name"` Name string `json:"name"`
// +optional // +optional
Value string `json:"value,omitempty"` Value string `json:"value,omitempty"`
// +optional
ValueFrom *corev1.EnvVarSource `json:"valueFrom,omitempty"`
} }
// +kubebuilder:validation:Enum=ClusterIP;NodePort;LoadBalancer // +kubebuilder:validation:Enum=ClusterIP;NodePort;LoadBalancer
@@ -46,8 +46,17 @@ type ReverseProxyServerSpec struct {
// +optional // +optional
Env []EnvVar `json:"env,omitempty"` 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 // +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 // +optional
BackendSelector *metav1.LabelSelector `json:"backendSelector,omitempty"` BackendSelector *metav1.LabelSelector `json:"backendSelector,omitempty"`
+16 -6
View File
@@ -5,13 +5,19 @@
package v1alpha1 package v1alpha1
import ( 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" runtime "k8s.io/apimachinery/pkg/runtime"
) )
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *EnvVar) DeepCopyInto(out *EnvVar) { func (in *EnvVar) DeepCopyInto(out *EnvVar) {
*out = *in *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. // 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 { if in.Env != nil {
in, out := &in.Env, &out.Env in, out := &in.Env, &out.Env
*out = make([]EnvVar, len(*in)) *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 *out = *in
if in.Conditions != nil { if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in)) *out = make([]metav1.Condition, len(*in))
for i := range *in { for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i]) (*in)[i].DeepCopyInto(&(*out)[i])
} }
@@ -250,11 +258,13 @@ func (in *ReverseProxyServerSpec) DeepCopyInto(out *ReverseProxyServerSpec) {
if in.Env != nil { if in.Env != nil {
in, out := &in.Env, &out.Env in, out := &in.Env, &out.Env
*out = make([]EnvVar, len(*in)) *out = make([]EnvVar, len(*in))
copy(*out, *in) for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
} }
if in.BackendSelector != nil { if in.BackendSelector != nil {
in, out := &in.BackendSelector, &out.BackendSelector in, out := &in.BackendSelector, &out.BackendSelector
*out = new(v1.LabelSelector) *out = new(metav1.LabelSelector)
(*in).DeepCopyInto(*out) (*in).DeepCopyInto(*out)
} }
} }
@@ -279,7 +289,7 @@ func (in *ReverseProxyServerStatus) DeepCopyInto(out *ReverseProxyServerStatus)
} }
if in.Conditions != nil { if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in)) *out = make([]metav1.Condition, len(*in))
for i := range *in { for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i]) (*in)[i].DeepCopyInto(&(*out)[i])
} }
@@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition kind: CustomResourceDefinition
metadata: metadata:
annotations: annotations:
controller-gen.kubebuilder.io/version: v0.21.0 controller-gen.kubebuilder.io/version: v0.20.0
name: minecraftservers.minikura.kirameki.cafe name: minecraftservers.minikura.kirameki.cafe
spec: spec:
group: minikura.kirameki.cafe group: minikura.kirameki.cafe
@@ -69,9 +69,142 @@ spec:
type: string type: string
value: value:
type: string 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: required:
- name - name
type: object type: object
x-kubernetes-validations:
- message: value and valueFrom are mutually exclusive
rule: '!(has(self.value) && has(self.valueFrom))'
type: array type: array
jarType: jarType:
default: VANILLA default: VANILLA
@@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition kind: CustomResourceDefinition
metadata: metadata:
annotations: annotations:
controller-gen.kubebuilder.io/version: v0.21.0 controller-gen.kubebuilder.io/version: v0.20.0
name: reverseproxyservers.minikura.kirameki.cafe name: reverseproxyservers.minikura.kirameki.cafe
spec: spec:
group: minikura.kirameki.cafe group: minikura.kirameki.cafe
@@ -106,6 +106,11 @@ spec:
type: object type: object
type: object type: object
x-kubernetes-map-type: atomic 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: description:
type: string type: string
env: env:
@@ -115,9 +120,142 @@ spec:
type: string type: string
value: value:
type: string 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: required:
- name - name
type: object type: object
x-kubernetes-validations:
- message: value and valueFrom are mutually exclusive
rule: '!(has(self.value) && has(self.valueFrom))'
type: array type: array
externalAddress: externalAddress:
type: string type: string
@@ -152,6 +290,11 @@ spec:
maximum: 32767 maximum: 32767
minimum: 30000 minimum: 30000
type: integer 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: resources:
properties: properties:
cpuLimit: cpuLimit:
@@ -183,6 +326,7 @@ spec:
- BUNGEECORD - BUNGEECORD
type: string type: string
required: required:
- apiKeySecretRef
- externalAddress - externalAddress
- externalPort - externalPort
- type - type
+1
View File
@@ -22,6 +22,7 @@ spec:
containers: containers:
- name: operator - name: operator
image: minikura-operator:latest image: minikura-operator:latest
imagePullPolicy: Always
args: args:
- --leader-elect - --leader-elect
env: env:
+36 -8
View File
@@ -4,41 +4,69 @@ metadata:
name: minikura-backend name: minikura-backend
--- ---
apiVersion: rbac.authorization.k8s.io/v1 apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole kind: Role
metadata: metadata:
name: minikura-backend-operator-resources name: minikura-backend-operator-resources
rules: rules:
- apiGroups: [""] - apiGroups: [""]
resources: ["secrets"] resources: ["secrets"]
verbs: ["get", "list", "create", "update", "delete"] verbs: ["get", "create", "update", "delete"]
- apiGroups: [""] - apiGroups: [""]
resources: ["pods", "services", "configmaps", "nodes"] resources: ["pods", "services", "configmaps"]
verbs: ["get", "list", "watch"] verbs: ["get", "list", "watch"]
- apiGroups: [""] - apiGroups: [""]
resources: ["pods/log"] resources: ["pods/log"]
verbs: ["get", "list"] verbs: ["get"]
- apiGroups: [""] - apiGroups: [""]
resources: ["pods/exec"] resources: ["pods/attach", "pods/exec"]
verbs: ["get", "create"] verbs: ["get", "create"]
- apiGroups: ["apps"] - apiGroups: ["apps"]
resources: ["deployments", "statefulsets"] resources: ["deployments", "statefulsets"]
verbs: ["get", "list", "watch"] verbs: ["get", "list", "watch"]
- apiGroups: ["metrics.k8s.io"] - apiGroups: ["metrics.k8s.io"]
resources: ["pods", "nodes"] resources: ["pods"]
verbs: ["get", "list"] verbs: ["get", "list"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch"]
- apiGroups: ["minikura.kirameki.cafe"] - apiGroups: ["minikura.kirameki.cafe"]
resources: ["minecraftservers", "reverseproxyservers"] resources: ["minecraftservers", "reverseproxyservers"]
verbs: ["get", "list", "watch", "create", "update", "delete"] verbs: ["get", "list", "watch", "create", "update", "delete"]
--- ---
apiVersion: rbac.authorization.k8s.io/v1 apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding kind: RoleBinding
metadata: metadata:
name: minikura-backend-operator-resources name: minikura-backend-operator-resources
roleRef: roleRef:
apiGroup: rbac.authorization.k8s.io apiGroup: rbac.authorization.k8s.io
kind: ClusterRole kind: Role
name: minikura-backend-operator-resources name: minikura-backend-operator-resources
subjects: subjects:
- kind: ServiceAccount - kind: ServiceAccount
name: minikura-backend name: minikura-backend
namespace: minikura 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 name: minikura-operator
--- ---
apiVersion: rbac.authorization.k8s.io/v1 apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding kind: RoleBinding
metadata: metadata:
name: minikura-operator-rolebinding name: minikura-operator-rolebinding
roleRef: roleRef:
+31
View File
@@ -3,6 +3,7 @@ package controller
import ( import (
"context" "context"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/apiutil" "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 { 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 { if err := setOwner(owner, obj, scheme); err != nil {
return err return err
} }
@@ -30,3 +36,28 @@ func apply(ctx context.Context, c client.Client, owner client.Object, obj client
obj.SetResourceVersion("") obj.SetResourceVersion("")
return c.Patch(ctx, obj, client.Apply, applyOpts...) 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" "sigs.k8s.io/controller-runtime/pkg/client/apiutil"
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1" v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
"github.com/YuzuZensai/Minikura/operator/internal/resources"
) )
func TestTypedObjectMarshalsWithoutTypeMeta(t *testing.T) { 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) { func TestApplySetsTypeMeta(t *testing.T) {
scheme := testScheme(t) scheme := testScheme(t)
owner := &v1alpha1.MinecraftServer{ owner := &v1alpha1.MinecraftServer{
@@ -2,6 +2,7 @@ package controller
import ( import (
"context" "context"
"fmt"
appsv1 "k8s.io/api/apps/v1" appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
@@ -10,7 +11,6 @@ import (
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime" ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1" v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
"github.com/YuzuZensai/Minikura/operator/internal/resources" "github.com/YuzuZensai/Minikura/operator/internal/resources"
@@ -31,8 +31,6 @@ type MinecraftServerReconciler struct {
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
func (r *MinecraftServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { func (r *MinecraftServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var mc v1alpha1.MinecraftServer var mc v1alpha1.MinecraftServer
if err := r.Get(ctx, req.NamespacedName, &mc); err != nil { if err := r.Get(ctx, req.NamespacedName, &mc); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err) return ctrl.Result{}, client.IgnoreNotFound(err)
@@ -41,6 +39,12 @@ func (r *MinecraftServerReconciler) Reconcile(ctx context.Context, req ctrl.Requ
if !mc.DeletionTimestamp.IsZero() { if !mc.DeletionTimestamp.IsZero() {
return ctrl.Result{}, nil 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 { if err := apply(ctx, r.Client, &mc, resources.MinecraftConfigMap(&mc), r.Scheme); err != nil {
return r.fail(ctx, &mc, "ConfigMapFailed", err) 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 { 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 { 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() { return failWithStatus(ctx, r.Client, mc, cause, func() {
mc.Status.Phase = v1alpha1.PhaseFailed mc.Status.Phase = v1alpha1.PhaseFailed
mc.Status.Message = cause.Error() mc.Status.Message = cause.Error()
mc.Status.ObservedGeneration = mc.Generation
setCondition(&mc.Status.Conditions, metav1.Condition{ setCondition(&mc.Status.Conditions, metav1.Condition{
Type: v1alpha1.ConditionReady, Type: v1alpha1.ConditionReady,
Status: metav1.ConditionFalse, 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) { func TestMinecraftReconcileStatefulCreatesStatefulSet(t *testing.T) {
mc := testMinecraft("smp", v1alpha1.ServerStateful) mc := testMinecraft("smp", v1alpha1.ServerStateful)
c := newFakeClient(t, mc) c := newFakeClient(t, mc)
@@ -156,6 +174,9 @@ func TestMinecraftReconcileInvalidStorageFails(t *testing.T) {
if got.Status.Message == "" { if got.Status.Message == "" {
t.Error("expected a failure 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) { 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) { func TestMinecraftReconcileApplyFailure(t *testing.T) {
mc := testMinecraft("lobby", v1alpha1.ServerStateless) mc := testMinecraft("lobby", v1alpha1.ServerStateless)
c := newInterceptedClient(t, interceptor.Funcs{ 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) mc := testMinecraft("lobby", v1alpha1.ServerStateless)
c := newInterceptedClient(t, interceptor.Funcs{ c := newInterceptedClient(t, interceptor.Funcs{
Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { 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) }, mc)
r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)} r := &MinecraftServerReconciler{Client: c, Scheme: testScheme(t)}
if _, err := r.Reconcile(context.Background(), requestFor(mc)); err != nil { if _, err := r.Reconcile(context.Background(), requestFor(mc)); err == nil {
t.Fatalf("prune errors should not fail reconcile: %v", err) t.Fatal("expected prune failure")
} }
} }
@@ -41,6 +41,12 @@ func (r *ReverseProxyServerReconciler) Reconcile(ctx context.Context, req ctrl.R
if !rp.DeletionTimestamp.IsZero() { if !rp.DeletionTimestamp.IsZero() {
return ctrl.Result{}, nil 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 { if err := apply(ctx, r.Client, &rp, resources.ProxyConfigMap(&rp), r.Scheme); err != nil {
return r.fail(ctx, &rp, "ConfigMapFailed", err) 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 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 { 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() { return failWithStatus(ctx, r.Client, rp, cause, func() {
rp.Status.Phase = v1alpha1.PhaseFailed rp.Status.Phase = v1alpha1.PhaseFailed
rp.Status.Message = cause.Error() rp.Status.Message = cause.Error()
rp.Status.ObservedGeneration = rp.Generation
setCondition(&rp.Status.Conditions, metav1.Condition{ setCondition(&rp.Status.Conditions, metav1.Condition{
Type: v1alpha1.ConditionReady, Type: v1alpha1.ConditionReady,
Status: metav1.ConditionFalse, 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) { func TestProxyReconcilePrunesStaleType(t *testing.T) {
rp := testProxy("edge", v1alpha1.ProxyVelocity) rp := testProxy("edge", v1alpha1.ProxyVelocity)
c := newFakeClient(t, rp) 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) { func TestProxiesForServerListError(t *testing.T) {
c := newInterceptedClient(t, interceptor.Funcs{ c := newInterceptedClient(t, interceptor.Funcs{
List: func(ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { 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 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)) 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 { for i, env := range base {
index[env.Name] = i index[env.Name] = i
} }
for _, e := range extra { 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 { if i, ok := index[e.Name]; ok {
base[i].Value = e.Value base[i].Value = value
base[i].ValueFrom = nil base[i].ValueFrom = valueFrom
continue continue
} }
index[e.Name] = len(base) 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 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) { func TestUserEnvAppendsUnknownKeys(t *testing.T) {
base := []corev1.EnvVar{{Name: "TYPE", Value: "PAPER"}} base := []corev1.EnvVar{{Name: "TYPE", Value: "PAPER"}}
got := UserEnv(base, []v1alpha1.EnvVar{{Name: "EXTRA", Value: "1"}}) got := UserEnv(base, []v1alpha1.EnvVar{{Name: "EXTRA", Value: "1"}})
@@ -84,13 +84,12 @@ func minecraftEnv(mc *v1alpha1.MinecraftServer) []corev1.EnvVar {
SecretKeyRef: &corev1.SecretKeySelector{ SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: mc.Spec.APIKeySecretRef}, LocalObjectReference: corev1.LocalObjectReference{Name: mc.Spec.APIKeySecretRef},
Key: "api-key", 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 { 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" { if e.ValueFrom.SecretKeyRef.Name != "minikura-key" || e.ValueFrom.SecretKeyRef.Key != "api-key" {
t.Errorf("secret ref = %+v", e.ValueFrom.SecretKeyRef) t.Errorf("secret ref = %+v", e.ValueFrom.SecretKeyRef)
} }
if e.ValueFrom.SecretKeyRef.Optional != nil {
t.Error("API key Secret must be required")
}
} }
} }
if !found { 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) { func TestStatefulSetDefaultStorage(t *testing.T) {
mc := testServer() mc := testServer()
mc.Spec.StorageSize = "" mc.Spec.StorageSize = ""
+21 -5
View File
@@ -1,23 +1,39 @@
package resources package resources
import ( import (
"crypto/sha256"
"fmt" "fmt"
"strings" "strings"
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1" 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 { 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 { func ServerLabels(mc *v1alpha1.MinecraftServer) map[string]string {
return map[string]string{ return map[string]string{
"app": ServerName(mc.Name), "app": ServerName(mc.Name),
v1alpha1.LabelServerType: strings.ToLower(string(mc.Spec.Type)), v1alpha1.LabelServerType: strings.ToLower(string(mc.Spec.Type)),
v1alpha1.LabelServerID: mc.Name, v1alpha1.LabelServerID: labelValue(mc.Name),
v1alpha1.LabelManagedBy: v1alpha1.ManagerName, v1alpha1.LabelManagedBy: v1alpha1.ManagerName,
} }
} }
@@ -26,7 +42,7 @@ func ProxyLabels(rp *v1alpha1.ReverseProxyServer) map[string]string {
return map[string]string{ return map[string]string{
"app": ProxyName(rp.Spec.Type, rp.Name), "app": ProxyName(rp.Spec.Type, rp.Name),
v1alpha1.LabelServerType: strings.ToLower(string(rp.Spec.Type)), v1alpha1.LabelServerType: strings.ToLower(string(rp.Spec.Type)),
v1alpha1.LabelProxyID: rp.Name, v1alpha1.LabelProxyID: labelValue(rp.Name),
v1alpha1.LabelManagedBy: v1alpha1.ManagerName, v1alpha1.LabelManagedBy: v1alpha1.ManagerName,
} }
} }
@@ -1,6 +1,7 @@
package resources package resources
import ( import (
"strings"
"testing" "testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 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) { func TestServerLabels(t *testing.T) {
mc := &v1alpha1.MinecraftServer{ mc := &v1alpha1.MinecraftServer{
ObjectMeta: metav1.ObjectMeta{Name: "smp"}, ObjectMeta: metav1.ObjectMeta{Name: "smp"},
@@ -1,6 +1,8 @@
package resources package resources
import ( import (
"strings"
appsv1 "k8s.io/api/apps/v1" appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -60,13 +62,36 @@ func proxyEnv(rp *v1alpha1.ReverseProxyServer) []corev1.EnvVar {
SecretKeyRef: &corev1.SecretKeySelector{ SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: rp.Spec.APIKeySecretRef}, LocalObjectReference: corev1.LocalObjectReference{Name: rp.Spec.APIKeySecretRef},
Key: "api-key", 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 { 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" { if e.ValueFrom == nil || e.ValueFrom.SecretKeyRef.Name != "proxy-key" {
t.Errorf("secret ref = %+v", e.ValueFrom) t.Errorf("secret ref = %+v", e.ValueFrom)
} }
if e.ValueFrom.SecretKeyRef.Optional != nil {
t.Error("API key Secret must be required")
}
} }
} }
if !found { if !found {
t.Error("MINIKURA_API_KEY missing") 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)
}
}
+1
View File
@@ -26,6 +26,7 @@
"operator:test": "make -C operator test", "operator:test": "make -C operator test",
"operator:crds": "make -C operator install-crds", "operator:crds": "make -C operator install-crds",
"operator:deploy": "make -C operator deploy", "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" "setup": "bash scripts/install.sh"
}, },
"devDependencies": { "devDependencies": {
+39 -20
View File
@@ -1,10 +1,20 @@
#!/bin/bash #!/usr/bin/env bash
set -e set -euo pipefail
NAMESPACE="${KUBERNETES_NAMESPACE:-minikura}" NAMESPACE="${KUBERNETES_NAMESPACE:-minikura}"
OPERATOR_IMAGE="${OPERATOR_IMAGE:-minikura-operator:latest}"
ROLLOUT_TIMEOUT="${ROLLOUT_TIMEOUT:-120s}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && 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 "╔════════════════════════════════════════════════╗"
echo "║ Minikura Kubernetes Installer ║" echo "║ Minikura Kubernetes Installer ║"
echo "╚════════════════════════════════════════════════╝" echo "╚════════════════════════════════════════════════╝"
@@ -12,15 +22,13 @@ echo ""
echo "-> Checking prerequisites..." echo "-> Checking prerequisites..."
if ! command -v kubectl &> /dev/null; then if ! command -v kubectl &> /dev/null; then
echo "[WARN] kubectl not found. Skipping k8s setup." echo "[ERROR] kubectl is required" >&2
echo "[INFO] Install kubectl and run 'bash scripts/install.sh' manually when ready." exit 1
exit 0
fi fi
if ! kubectl cluster-info &> /dev/null; then if ! kubectl cluster-info &> /dev/null; then
echo "[WARN] Cannot connect to Kubernetes cluster. Skipping k8s setup." echo "[ERROR] Cannot connect to the current Kubernetes cluster" >&2
echo "[INFO] Run 'bash scripts/install.sh' manually when cluster is ready." exit 1
exit 0
fi fi
echo "[OK] kubectl found" echo "[OK] kubectl found"
@@ -28,23 +36,33 @@ echo "[OK] Connected to Kubernetes cluster"
echo "" echo ""
echo "-> Creating namespace: $NAMESPACE" 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 ""
echo "-> Installing operator CRDs" echo "-> Installing operator CRDs"
make -C "$PROJECT_ROOT/operator" install-crds kubectl apply -f "$PROJECT_ROOT/operator/config/crd"
echo "[OK] CRDs installed" echo "[OK] CRDs installed"
echo "" 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 -f "$PROJECT_ROOT/operator/config/rbac/role.yaml"
kubectl apply -n "$NAMESPACE" -f "$PROJECT_ROOT/operator/config/rbac/service_account.yaml" sed "s/namespace: minikura/namespace: $NAMESPACE/g" \
kubectl apply -n "$NAMESPACE" -f "$PROJECT_ROOT/operator/config/rbac/backend.yaml" "$PROJECT_ROOT/operator/config/rbac/service_account.yaml" | kubectl apply -n "$NAMESPACE" -f -
kubectl patch clusterrolebinding minikura-operator-rolebinding --type=json \ sed "s/namespace: minikura/namespace: $NAMESPACE/g" \
-p="[{\"op\":\"replace\",\"path\":\"/subjects/0/namespace\",\"value\":\"$NAMESPACE\"}]" "$PROJECT_ROOT/operator/config/rbac/backend.yaml" | kubectl apply -n "$NAMESPACE" -f -
kubectl patch clusterrolebinding minikura-backend-operator-resources --type=json \ echo "[OK] RBAC configured"
-p="[{\"op\":\"replace\",\"path\":\"/subjects/0/namespace\",\"value\":\"$NAMESPACE\"}]" echo ""
echo "[OK] Operator RBAC configured"
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 ""
echo "╔════════════════════════════════════════════════╗" echo "╔════════════════════════════════════════════════╗"
@@ -54,8 +72,9 @@ echo ""
echo "Resources created:" echo "Resources created:"
echo " [OK] Namespace: $NAMESPACE" echo " [OK] Namespace: $NAMESPACE"
echo " [OK] ServiceAccount: minikura-operator" echo " [OK] ServiceAccount: minikura-operator"
echo " [OK] ClusterRole + ClusterRoleBinding" echo " [OK] ServiceAccount: minikura-backend"
echo " [OK] Operator Deployment: $OPERATOR_IMAGE"
echo "" echo ""
echo "Next steps:" 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 "" echo ""