feat: add Go Kubernetes operator

This commit is contained in:
2026-08-13 02:19:14 +07:00
parent 31a2b8ef28
commit da6eafcae6
30 changed files with 3066 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
package resources
import (
"fmt"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
)
const (
MinecraftImage = "itzg/minecraft-server"
ContainerPort = 25565
DefaultHeapPercent = 80
)
func ServiceType(exposure v1alpha1.ServiceExposure, fallback corev1.ServiceType) corev1.ServiceType {
switch exposure {
case v1alpha1.ExposureClusterIP:
return corev1.ServiceTypeClusterIP
case v1alpha1.ExposureNodePort:
return corev1.ServiceTypeNodePort
case v1alpha1.ExposureLoadBalancer:
return corev1.ServiceTypeLoadBalancer
default:
return fallback
}
}
func HeapMB(limitMB int32, heapPercent int32) string {
if heapPercent <= 0 || heapPercent > 100 {
heapPercent = DefaultHeapPercent
}
heap := int64(limitMB) * int64(heapPercent) / 100
if heap < 256 {
heap = 256
}
return fmt.Sprintf("%dM", heap)
}
func ResourceRequirements(r v1alpha1.Resources) corev1.ResourceRequirements {
limitMB := r.MemoryLimitMB
if limitMB <= 0 {
limitMB = 2048
}
requestMB := r.MemoryRequestMB
if requestMB <= 0 || requestMB > limitMB {
requestMB = limitMB
}
requests := corev1.ResourceList{
corev1.ResourceMemory: resource.MustParse(fmt.Sprintf("%dMi", requestMB)),
}
limits := corev1.ResourceList{
corev1.ResourceMemory: resource.MustParse(fmt.Sprintf("%dMi", limitMB)),
}
if r.CPURequest != "" {
if q, err := resource.ParseQuantity(r.CPURequest); err == nil {
requests[corev1.ResourceCPU] = q
}
}
if r.CPULimit != "" {
if q, err := resource.ParseQuantity(r.CPULimit); err == nil {
limits[corev1.ResourceCPU] = q
}
}
return corev1.ResourceRequirements{Requests: requests, Limits: limits}
}
func JVMEnv(jvm v1alpha1.JVMOptions, limitMB int32) []corev1.EnvVar {
env := []corev1.EnvVar{
{Name: "MEMORY", Value: HeapMB(limitMB, jvm.HeapPercent)},
}
if jvm.Opts != "" {
env = append(env, corev1.EnvVar{Name: "JVM_OPTS", Value: jvm.Opts})
}
if jvm.UseAikarFlags {
env = append(env, corev1.EnvVar{Name: "USE_AIKAR_FLAGS", Value: "true"})
}
if jvm.UseMeowIceFlags {
env = append(env, corev1.EnvVar{Name: "USE_MEOWICE_FLAGS", Value: "true"})
}
return env
}
func UserEnv(base []corev1.EnvVar, extra []v1alpha1.EnvVar) []corev1.EnvVar {
for _, e := range extra {
base = append(base, corev1.EnvVar{Name: e.Name, Value: e.Value})
}
return base
}
func TCPProbe(initialDelay int32) *corev1.Probe {
return &corev1.Probe{
ProbeHandler: corev1.ProbeHandler{
TCPSocket: &corev1.TCPSocketAction{
Port: intstr.FromInt32(ContainerPort),
},
},
InitialDelaySeconds: initialDelay,
PeriodSeconds: 10,
}
}
func ObjectMeta(name, namespace string, labels map[string]string) metav1.ObjectMeta {
return metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Labels: labels,
}
}
func BoolValue(b *bool, fallback bool) bool {
if b == nil {
return fallback
}
return *b
}
@@ -0,0 +1,86 @@
package resources
import (
"testing"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
)
func TestHeapMB(t *testing.T) {
tests := []struct {
name string
limitMB int32
heapPercent int32
want string
}{
{"default percent when unset", 2048, 0, "1638M"},
{"explicit percent", 2048, 50, "1024M"},
{"out of range falls back", 1024, 150, "819M"},
{"floor applies to tiny limits", 128, 80, "256M"},
{"full allocation", 1000, 100, "1000M"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := HeapMB(tt.limitMB, tt.heapPercent); got != tt.want {
t.Errorf("HeapMB(%d, %d) = %q, want %q", tt.limitMB, tt.heapPercent, got, tt.want)
}
})
}
}
func TestResourceRequirements(t *testing.T) {
t.Run("request defaults to limit", func(t *testing.T) {
got := ResourceRequirements(v1alpha1.Resources{MemoryLimitMB: 2048})
want := resource.MustParse("2048Mi")
if got.Requests.Memory().Cmp(want) != 0 {
t.Errorf("request memory = %v, want %v", got.Requests.Memory(), &want)
}
})
t.Run("request above limit is clamped", func(t *testing.T) {
got := ResourceRequirements(v1alpha1.Resources{MemoryLimitMB: 1024, MemoryRequestMB: 4096})
want := resource.MustParse("1024Mi")
if got.Requests.Memory().Cmp(want) != 0 {
t.Errorf("request memory = %v, want %v", got.Requests.Memory(), &want)
}
})
t.Run("invalid cpu strings are dropped", func(t *testing.T) {
got := ResourceRequirements(v1alpha1.Resources{
MemoryLimitMB: 1024,
CPURequest: "not-a-quantity",
CPULimit: "500m",
})
if _, ok := got.Requests[corev1.ResourceCPU]; ok {
t.Error("expected invalid cpu request to be omitted")
}
if got.Limits.Cpu().String() != "500m" {
t.Errorf("cpu limit = %v, want 500m", got.Limits.Cpu())
}
})
}
func TestServiceType(t *testing.T) {
if got := ServiceType("", corev1.ServiceTypeLoadBalancer); got != corev1.ServiceTypeLoadBalancer {
t.Errorf("empty exposure = %v, want fallback LoadBalancer", got)
}
if got := ServiceType(v1alpha1.ExposureNodePort, corev1.ServiceTypeClusterIP); got != corev1.ServiceTypeNodePort {
t.Errorf("NodePort exposure = %v, want NodePort", got)
}
}
func TestUserEnvOverridesDefaults(t *testing.T) {
base := []corev1.EnvVar{{Name: "TYPE", Value: "VANILLA"}}
got := UserEnv(base, []v1alpha1.EnvVar{{Name: "TYPE", Value: "PAPER"}})
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got[len(got)-1].Value != "PAPER" {
t.Errorf("last TYPE = %q, want PAPER", got[len(got)-1].Value)
}
}
@@ -0,0 +1,185 @@
package resources
import (
"fmt"
"strconv"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
)
func MinecraftConfigMap(mc *v1alpha1.MinecraftServer) *corev1.ConfigMap {
name := ServerName(mc.Name)
return &corev1.ConfigMap{
ObjectMeta: ObjectMeta(ConfigMapName(name), mc.Namespace, ServerLabels(mc)),
Data: map[string]string{
"server-type": string(mc.Spec.Type),
"minecraft-version": mc.Spec.MinecraftVersion,
"jar-type": string(mc.Spec.JarType),
},
}
}
func MinecraftService(mc *v1alpha1.MinecraftServer) *corev1.Service {
name := ServerName(mc.Name)
port := corev1.ServicePort{
Name: "minecraft",
Port: mc.Spec.ListenPort,
TargetPort: intstr.FromInt32(ContainerPort),
Protocol: corev1.ProtocolTCP,
}
svcType := ServiceType(mc.Spec.ServiceType, corev1.ServiceTypeClusterIP)
if svcType == corev1.ServiceTypeNodePort && mc.Spec.NodePort != 0 {
port.NodePort = mc.Spec.NodePort
}
return &corev1.Service{
ObjectMeta: ObjectMeta(name, mc.Namespace, ServerLabels(mc)),
Spec: corev1.ServiceSpec{
Selector: SelectorLabels(name),
Ports: []corev1.ServicePort{port},
Type: svcType,
},
}
}
func minecraftEnv(mc *v1alpha1.MinecraftServer) []corev1.EnvVar {
p := mc.Spec.Properties
env := []corev1.EnvVar{
{Name: "EULA", Value: "TRUE"},
{Name: "TYPE", Value: string(mc.Spec.JarType)},
{Name: "VERSION", Value: mc.Spec.MinecraftVersion},
{Name: "OVERRIDE_SERVER_PROPERTIES", Value: "true"},
{Name: "ENABLE_RCON", Value: "false"},
{Name: "DIFFICULTY", Value: string(p.Difficulty)},
{Name: "MODE", Value: string(p.GameMode)},
{Name: "MAX_PLAYERS", Value: strconv.Itoa(int(p.MaxPlayers))},
{Name: "PVP", Value: strconv.FormatBool(BoolValue(p.PVP, true))},
{Name: "ONLINE_MODE", Value: strconv.FormatBool(BoolValue(p.OnlineMode, true))},
}
if p.MOTD != "" {
env = append(env, corev1.EnvVar{Name: "MOTD", Value: p.MOTD})
}
if p.LevelSeed != "" {
env = append(env, corev1.EnvVar{Name: "SEED", Value: p.LevelSeed})
}
if p.LevelType != "" {
env = append(env, corev1.EnvVar{Name: "LEVEL_TYPE", Value: p.LevelType})
}
env = append(env, JVMEnv(mc.Spec.JVM, mc.Spec.Resources.MemoryLimitMB)...)
if mc.Spec.APIKeySecretRef != "" {
env = append(env, corev1.EnvVar{
Name: "MINIKURA_API_KEY",
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: mc.Spec.APIKeySecretRef},
Key: "api-key",
Optional: ptr(true),
},
},
})
}
return UserEnv(env, mc.Spec.Env)
}
func minecraftPodSpec(mc *v1alpha1.MinecraftServer, stateful bool) corev1.PodSpec {
name := ServerName(mc.Name)
mounts := []corev1.VolumeMount{{Name: "config", MountPath: "/config"}}
volumes := []corev1.Volume{{
Name: "config",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{Name: ConfigMapName(name)},
},
},
}}
initialDelay := int32(30)
if stateful {
mounts = append(mounts, corev1.VolumeMount{Name: "data", MountPath: "/data"})
initialDelay = 60
}
return corev1.PodSpec{
Containers: []corev1.Container{{
Name: "minecraft",
Image: MinecraftImage,
Ports: []corev1.ContainerPort{{
Name: "minecraft",
ContainerPort: ContainerPort,
}},
Env: minecraftEnv(mc),
VolumeMounts: mounts,
ReadinessProbe: TCPProbe(initialDelay),
Resources: ResourceRequirements(mc.Spec.Resources),
}},
Volumes: volumes,
}
}
func MinecraftDeployment(mc *v1alpha1.MinecraftServer) *appsv1.Deployment {
name := ServerName(mc.Name)
labels := ServerLabels(mc)
return &appsv1.Deployment{
ObjectMeta: ObjectMeta(name, mc.Namespace, labels),
Spec: appsv1.DeploymentSpec{
Replicas: ptr(int32(1)),
Selector: &metav1.LabelSelector{MatchLabels: SelectorLabels(name)},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels},
Spec: minecraftPodSpec(mc, false),
},
},
}
}
func MinecraftStatefulSet(mc *v1alpha1.MinecraftServer) (*appsv1.StatefulSet, error) {
name := ServerName(mc.Name)
labels := ServerLabels(mc)
size := mc.Spec.StorageSize
if size == "" {
size = "1Gi"
}
qty, err := resource.ParseQuantity(size)
if err != nil {
return nil, fmt.Errorf("invalid storageSize %q: %w", size, err)
}
return &appsv1.StatefulSet{
ObjectMeta: ObjectMeta(name, mc.Namespace, labels),
Spec: appsv1.StatefulSetSpec{
ServiceName: name,
Replicas: ptr(int32(1)),
Selector: &metav1.LabelSelector{MatchLabels: SelectorLabels(name)},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels},
Spec: minecraftPodSpec(mc, true),
},
VolumeClaimTemplates: []corev1.PersistentVolumeClaim{{
ObjectMeta: metav1.ObjectMeta{Name: "data"},
Spec: corev1.PersistentVolumeClaimSpec{
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
Resources: corev1.VolumeResourceRequirements{
Requests: corev1.ResourceList{corev1.ResourceStorage: qty},
},
},
}},
},
}, nil
}
func ptr[T any](v T) *T { return &v }
@@ -0,0 +1,154 @@
package resources
import (
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
)
func testServer() *v1alpha1.MinecraftServer {
return &v1alpha1.MinecraftServer{
ObjectMeta: metav1.ObjectMeta{Name: "smp", Namespace: "minikura"},
Spec: v1alpha1.MinecraftServerSpec{
Type: v1alpha1.ServerStateful,
ListenPort: 25565,
ServiceType: v1alpha1.ExposureClusterIP,
JarType: "PAPER",
MinecraftVersion: "1.20.4",
StorageSize: "10Gi",
Resources: v1alpha1.Resources{MemoryLimitMB: 4096},
},
}
}
func envValue(env []corev1.EnvVar, name string) (string, bool) {
for i := len(env) - 1; i >= 0; i-- {
if env[i].Name == name {
return env[i].Value, true
}
}
return "", false
}
func TestMinecraftEnvCarriesSpecConfig(t *testing.T) {
mc := testServer()
mc.Spec.Properties = v1alpha1.MinecraftProperties{
Difficulty: "HARD",
GameMode: "SURVIVAL",
MaxPlayers: 50,
MOTD: "welcome",
}
env := minecraftEnv(mc)
for _, want := range []struct{ key, value string }{
{"TYPE", "PAPER"},
{"VERSION", "1.20.4"},
{"DIFFICULTY", "HARD"},
{"MAX_PLAYERS", "50"},
{"MOTD", "welcome"},
} {
got, ok := envValue(env, want.key)
if !ok {
t.Errorf("%s missing from env", want.key)
continue
}
if got != want.value {
t.Errorf("%s = %q, want %q", want.key, got, want.value)
}
}
}
func TestOptionalPropertiesOmitted(t *testing.T) {
env := minecraftEnv(testServer())
for _, key := range []string{"MOTD", "SEED", "LEVEL_TYPE"} {
if _, ok := envValue(env, key); ok {
t.Errorf("%s should be omitted when unset", key)
}
}
}
func TestServiceNodePortPinned(t *testing.T) {
mc := testServer()
mc.Spec.ServiceType = v1alpha1.ExposureNodePort
mc.Spec.NodePort = 30123
svc := MinecraftService(mc)
if svc.Spec.Type != corev1.ServiceTypeNodePort {
t.Fatalf("type = %v, want NodePort", svc.Spec.Type)
}
if svc.Spec.Ports[0].NodePort != 30123 {
t.Errorf("nodePort = %d, want 30123", svc.Spec.Ports[0].NodePort)
}
}
func TestServiceNodePortUnsetLeavesAllocation(t *testing.T) {
mc := testServer()
mc.Spec.ServiceType = v1alpha1.ExposureNodePort
svc := MinecraftService(mc)
if svc.Spec.Ports[0].NodePort != 0 {
t.Errorf("nodePort = %d, want 0 so the API server allocates one", svc.Spec.Ports[0].NodePort)
}
}
func TestSelectorExcludesMutableLabels(t *testing.T) {
mc := testServer()
sts, err := MinecraftStatefulSet(mc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
selector := sts.Spec.Selector.MatchLabels
if _, ok := selector[v1alpha1.LabelServerType]; ok {
t.Error("selector must not include the mutable server-type label")
}
if selector["app"] != ServerName(mc.Name) {
t.Errorf("selector app = %q, want %q", selector["app"], ServerName(mc.Name))
}
for k, v := range selector {
if sts.Spec.Template.Labels[k] != v {
t.Errorf("pod template missing selector label %s=%s", k, v)
}
}
}
func TestStatefulSetStorage(t *testing.T) {
sts, err := MinecraftStatefulSet(testServer())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
claims := sts.Spec.VolumeClaimTemplates
if len(claims) != 1 {
t.Fatalf("volumeClaimTemplates = %d, want 1", len(claims))
}
if got := claims[0].Spec.Resources.Requests.Storage().String(); got != "10Gi" {
t.Errorf("storage = %s, want 10Gi", got)
}
}
func TestStatefulSetRejectsBadStorageSize(t *testing.T) {
mc := testServer()
mc.Spec.StorageSize = "ten-gigs"
if _, err := MinecraftStatefulSet(mc); err == nil {
t.Error("expected an error for an unparseable storageSize")
}
}
func TestStatelessHasNoDataVolume(t *testing.T) {
mc := testServer()
mc.Spec.Type = v1alpha1.ServerStateless
dep := MinecraftDeployment(mc)
for _, m := range dep.Spec.Template.Spec.Containers[0].VolumeMounts {
if m.Name == "data" {
t.Error("stateless server should not mount a data volume")
}
}
}
+36
View File
@@ -0,0 +1,36 @@
package resources
import (
"fmt"
"strings"
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
)
func ServerName(name string) string { return fmt.Sprintf("minecraft-%s", name) }
func ProxyName(kind v1alpha1.ProxyKind, name string) string {
return fmt.Sprintf("%s-%s", strings.ToLower(string(kind)), name)
}
func ConfigMapName(base string) string { return base + "-config" }
func ServerLabels(mc *v1alpha1.MinecraftServer) map[string]string {
return map[string]string{
"app": ServerName(mc.Name),
v1alpha1.LabelServerType: strings.ToLower(string(mc.Spec.Type)),
v1alpha1.LabelServerID: mc.Name,
v1alpha1.LabelManagedBy: v1alpha1.ManagerName,
}
}
func ProxyLabels(rp *v1alpha1.ReverseProxyServer) map[string]string {
return map[string]string{
"app": ProxyName(rp.Spec.Type, rp.Name),
v1alpha1.LabelServerType: strings.ToLower(string(rp.Spec.Type)),
v1alpha1.LabelProxyID: rp.Name,
v1alpha1.LabelManagedBy: v1alpha1.ManagerName,
}
}
func SelectorLabels(app string) map[string]string {
return map[string]string{"app": app}
}
@@ -0,0 +1,120 @@
package resources
import (
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
)
func ProxyConfigMap(rp *v1alpha1.ReverseProxyServer) *corev1.ConfigMap {
name := ProxyName(rp.Spec.Type, rp.Name)
return &corev1.ConfigMap{
ObjectMeta: ObjectMeta(ConfigMapName(name), rp.Namespace, ProxyLabels(rp)),
Data: map[string]string{
"proxy-type": string(rp.Spec.Type),
"external-address": rp.Spec.ExternalAddress,
},
}
}
func ProxyService(rp *v1alpha1.ReverseProxyServer) *corev1.Service {
name := ProxyName(rp.Spec.Type, rp.Name)
port := corev1.ServicePort{
Name: "minecraft",
Port: rp.Spec.ExternalPort,
TargetPort: intstr.FromInt32(rp.Spec.ListenPort),
Protocol: corev1.ProtocolTCP,
}
svcType := ServiceType(rp.Spec.ServiceType, corev1.ServiceTypeLoadBalancer)
if svcType == corev1.ServiceTypeNodePort && rp.Spec.NodePort != 0 {
port.NodePort = rp.Spec.NodePort
}
return &corev1.Service{
ObjectMeta: ObjectMeta(name, rp.Namespace, ProxyLabels(rp)),
Spec: corev1.ServiceSpec{
Selector: SelectorLabels(name),
Ports: []corev1.ServicePort{port},
Type: svcType,
},
}
}
func proxyEnv(rp *v1alpha1.ReverseProxyServer) []corev1.EnvVar {
env := []corev1.EnvVar{
{Name: "EULA", Value: "TRUE"},
{Name: "TYPE", Value: string(rp.Spec.Type)},
{Name: "MINIKURA_EXTERNAL_ADDRESS", Value: rp.Spec.ExternalAddress},
}
env = append(env, JVMEnv(rp.Spec.JVM, rp.Spec.Resources.MemoryLimitMB)...)
if rp.Spec.APIKeySecretRef != "" {
env = append(env, corev1.EnvVar{
Name: "MINIKURA_API_KEY",
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: rp.Spec.APIKeySecretRef},
Key: "api-key",
Optional: ptr(true),
},
},
})
}
return UserEnv(env, rp.Spec.Env)
}
func ProxyDeployment(rp *v1alpha1.ReverseProxyServer) *appsv1.Deployment {
name := ProxyName(rp.Spec.Type, rp.Name)
labels := ProxyLabels(rp)
return &appsv1.Deployment{
ObjectMeta: ObjectMeta(name, rp.Namespace, labels),
Spec: appsv1.DeploymentSpec{
Replicas: ptr(int32(1)),
Selector: &metav1.LabelSelector{MatchLabels: SelectorLabels(name)},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "proxy",
Image: MinecraftImage,
Ports: []corev1.ContainerPort{{
Name: "minecraft",
ContainerPort: rp.Spec.ListenPort,
}},
Env: proxyEnv(rp),
VolumeMounts: []corev1.VolumeMount{
{Name: "config", MountPath: "/config"},
},
ReadinessProbe: &corev1.Probe{
ProbeHandler: corev1.ProbeHandler{
TCPSocket: &corev1.TCPSocketAction{
Port: intstr.FromInt32(rp.Spec.ListenPort),
},
},
InitialDelaySeconds: 30,
PeriodSeconds: 10,
},
Resources: ResourceRequirements(rp.Spec.Resources),
}},
Volumes: []corev1.Volume{{
Name: "config",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: ConfigMapName(name),
},
},
},
}},
},
},
},
}
}