mirror of
https://github.com/YuzuZensai/Minikura.git
synced 2026-09-13 10:49:21 +00:00
🐛 fix: harden operator reconciliation and install
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 -
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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), ¤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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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), ¤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) {
|
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), ¤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) {
|
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 {
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 ""
|
||||||
|
|||||||
Reference in New Issue
Block a user