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
+1
View File
@@ -0,0 +1 @@
bin/
+18
View File
@@ -0,0 +1,18 @@
FROM golang:1.26 AS build
WORKDIR /workspace
COPY go.mod go.sum ./
RUN go mod download
COPY cmd/ cmd/
COPY api/ api/
COPY internal/ internal/
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o operator ./cmd
FROM gcr.io/distroless/static:nonroot
WORKDIR /
COPY --from=build /workspace/operator .
USER 65532:65532
ENTRYPOINT ["/operator"]
+57
View File
@@ -0,0 +1,57 @@
IMG ?= minikura-operator:latest
NAMESPACE ?= minikura
CONTROLLER_GEN ?= $(shell go env GOPATH)/bin/controller-gen
.PHONY: all
all: generate manifests fmt vet test build
.PHONY: controller-gen
controller-gen:
@test -x $(CONTROLLER_GEN) || go install sigs.k8s.io/controller-tools/cmd/controller-gen@latest
.PHONY: generate
generate: controller-gen
$(CONTROLLER_GEN) object:headerFile=/dev/null paths=./api/...
.PHONY: manifests
manifests: controller-gen
$(CONTROLLER_GEN) crd paths=./api/... output:crd:artifacts:config=config/crd
$(CONTROLLER_GEN) rbac:roleName=minikura-operator-role paths=./internal/... output:rbac:artifacts:config=config/rbac
.PHONY: fmt
fmt:
go fmt ./...
.PHONY: vet
vet:
go vet ./...
.PHONY: test
test:
go test ./... -race
.PHONY: build
build:
go build -o bin/operator ./cmd
.PHONY: run
run:
go run ./cmd --leader-elect=false
.PHONY: docker-build
docker-build:
docker build -t $(IMG) .
.PHONY: install-crds
install-crds: manifests
kubectl apply -f config/crd
.PHONY: uninstall-crds
uninstall-crds:
kubectl delete --ignore-not-found -f config/crd
.PHONY: deploy
deploy: manifests
kubectl apply -f config/crd
kubectl apply -n $(NAMESPACE) -f config/rbac
kubectl apply -n $(NAMESPACE) -f config/manager
+51
View File
@@ -0,0 +1,51 @@
package v1alpha1
const (
Domain = "minikura.kirameki.cafe"
LabelPrefix = Domain
LabelServerType = LabelPrefix + "/server-type"
LabelServerID = LabelPrefix + "/server-id"
LabelProxyID = LabelPrefix + "/proxy-id"
LabelManagedBy = LabelPrefix + "/managed-by"
ManagerName = "minikura-operator"
PhasePending = "Pending"
PhaseRunning = "Running"
PhaseFailed = "Failed"
ConditionReady = "Ready"
)
type EnvVar struct {
Name string `json:"name"`
// +optional
Value string `json:"value,omitempty"`
}
// +kubebuilder:validation:Enum=ClusterIP;NodePort;LoadBalancer
type ServiceExposure string
const (
ExposureClusterIP ServiceExposure = "ClusterIP"
ExposureNodePort ServiceExposure = "NodePort"
ExposureLoadBalancer ServiceExposure = "LoadBalancer"
)
type Resources struct {
// +kubebuilder:validation:Minimum=256
// +kubebuilder:default=2048
MemoryLimitMB int32 `json:"memoryLimitMB,omitempty"`
// +optional
// +kubebuilder:validation:Minimum=256
MemoryRequestMB int32 `json:"memoryRequestMB,omitempty"`
// +kubebuilder:default="500m"
CPURequest string `json:"cpuRequest,omitempty"`
// +kubebuilder:default="2"
CPULimit string `json:"cpuLimit,omitempty"`
}
@@ -0,0 +1,16 @@
// +kubebuilder:object:generate=true
// +groupName=minikura.kirameki.cafe
package v1alpha1
import (
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/scheme"
)
var (
GroupVersion = schema.GroupVersion{Group: "minikura.kirameki.cafe", Version: "v1alpha1"}
SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
AddToScheme = SchemeBuilder.AddToScheme
)
@@ -0,0 +1,164 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// +kubebuilder:validation:Enum=STATEFUL;STATELESS
type ServerKind string
const (
ServerStateful ServerKind = "STATEFUL"
ServerStateless ServerKind = "STATELESS"
)
// +kubebuilder:validation:Enum=VANILLA;PAPER;SPIGOT;PURPUR;FABRIC;FORGE;FOLIA
type JarType string
// +kubebuilder:validation:Enum=PEACEFUL;EASY;NORMAL;HARD
type Difficulty string
// +kubebuilder:validation:Enum=SURVIVAL;CREATIVE;ADVENTURE;SPECTATOR
type GameMode string
type MinecraftProperties struct {
// +kubebuilder:default=EASY
Difficulty Difficulty `json:"difficulty,omitempty"`
// +kubebuilder:default=SURVIVAL
GameMode GameMode `json:"gameMode,omitempty"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:default=20
MaxPlayers int32 `json:"maxPlayers,omitempty"`
// +kubebuilder:default=true
PVP *bool `json:"pvp,omitempty"`
// +kubebuilder:default=true
OnlineMode *bool `json:"onlineMode,omitempty"`
// +optional
MOTD string `json:"motd,omitempty"`
// +optional
LevelSeed string `json:"levelSeed,omitempty"`
// +optional
LevelType string `json:"levelType,omitempty"`
}
type JVMOptions struct {
// +optional
Opts string `json:"opts,omitempty"`
// +optional
UseAikarFlags bool `json:"useAikarFlags,omitempty"`
// +optional
UseMeowIceFlags bool `json:"useMeowIceFlags,omitempty"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=100
// +kubebuilder:default=80
HeapPercent int32 `json:"heapPercent,omitempty"`
}
type MinecraftServerSpec struct {
Type ServerKind `json:"type"`
// +optional
Description string `json:"description,omitempty"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
// +kubebuilder:default=25565
ListenPort int32 `json:"listenPort,omitempty"`
// +kubebuilder:default=ClusterIP
ServiceType ServiceExposure `json:"serviceType,omitempty"`
// +optional
// +kubebuilder:validation:Minimum=30000
// +kubebuilder:validation:Maximum=32767
NodePort int32 `json:"nodePort,omitempty"`
// +kubebuilder:default=VANILLA
JarType JarType `json:"jarType,omitempty"`
// +kubebuilder:default="LATEST"
MinecraftVersion string `json:"minecraftVersion,omitempty"`
// +optional
Resources Resources `json:"resources,omitempty"`
// +optional
JVM JVMOptions `json:"jvm,omitempty"`
// +optional
Properties MinecraftProperties `json:"properties,omitempty"`
// +optional
Env []EnvVar `json:"env,omitempty"`
// +optional
APIKeySecretRef string `json:"apiKeySecretRef,omitempty"`
// +kubebuilder:default="1Gi"
StorageSize string `json:"storageSize,omitempty"`
}
type MinecraftServerStatus struct {
// +optional
Phase string `json:"phase,omitempty"`
// +optional
Message string `json:"message,omitempty"`
// +optional
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
// +optional
Replicas int32 `json:"replicas,omitempty"`
// +optional
Endpoint string `json:"endpoint,omitempty"`
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// +optional
// +patchMergeKey=type
// +patchStrategy=merge
// +listType=map
// +listMapKey=type
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:shortName=mcs
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
// +kubebuilder:printcolumn:name="Version",type=string,JSONPath=`.spec.minecraftVersion`
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.readyReplicas`
// +kubebuilder:printcolumn:name="Endpoint",type=string,JSONPath=`.status.endpoint`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
type MinecraftServer struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec MinecraftServerSpec `json:"spec,omitempty"`
Status MinecraftServerStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
type MinecraftServerList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []MinecraftServer `json:"items"`
}
func init() {
SchemeBuilder.Register(&MinecraftServer{}, &MinecraftServerList{})
}
@@ -0,0 +1,111 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// +kubebuilder:validation:Enum=VELOCITY;BUNGEECORD
type ProxyKind string
const (
ProxyVelocity ProxyKind = "VELOCITY"
ProxyBungeeCord ProxyKind = "BUNGEECORD"
)
type ReverseProxyServerSpec struct {
Type ProxyKind `json:"type"`
// +optional
Description string `json:"description,omitempty"`
ExternalAddress string `json:"externalAddress"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
ExternalPort int32 `json:"externalPort"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
// +kubebuilder:default=25565
ListenPort int32 `json:"listenPort,omitempty"`
// +kubebuilder:default=LoadBalancer
ServiceType ServiceExposure `json:"serviceType,omitempty"`
// +optional
// +kubebuilder:validation:Minimum=30000
// +kubebuilder:validation:Maximum=32767
NodePort int32 `json:"nodePort,omitempty"`
// +optional
Resources Resources `json:"resources,omitempty"`
// +optional
JVM JVMOptions `json:"jvm,omitempty"`
// +optional
Env []EnvVar `json:"env,omitempty"`
// +optional
APIKeySecretRef string `json:"apiKeySecretRef,omitempty"`
// +optional
BackendSelector *metav1.LabelSelector `json:"backendSelector,omitempty"`
}
type ReverseProxyServerStatus struct {
// +optional
Phase string `json:"phase,omitempty"`
// +optional
Message string `json:"message,omitempty"`
// +optional
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
// +optional
Replicas int32 `json:"replicas,omitempty"`
// +optional
Endpoint string `json:"endpoint,omitempty"`
// +optional
Backends []string `json:"backends,omitempty"`
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// +optional
// +patchMergeKey=type
// +patchStrategy=merge
// +listType=map
// +listMapKey=type
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:shortName=rps
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
// +kubebuilder:printcolumn:name="Address",type=string,JSONPath=`.spec.externalAddress`
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.readyReplicas`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
type ReverseProxyServer struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ReverseProxyServerSpec `json:"spec,omitempty"`
Status ReverseProxyServerStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
type ReverseProxyServerList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []ReverseProxyServer `json:"items"`
}
func init() {
SchemeBuilder.Register(&ReverseProxyServer{}, &ReverseProxyServerList{})
}
@@ -0,0 +1,297 @@
//go:build !ignore_autogenerated
// Code generated by controller-gen. DO NOT EDIT.
package v1alpha1
import (
"k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *EnvVar) DeepCopyInto(out *EnvVar) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvVar.
func (in *EnvVar) DeepCopy() *EnvVar {
if in == nil {
return nil
}
out := new(EnvVar)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *JVMOptions) DeepCopyInto(out *JVMOptions) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JVMOptions.
func (in *JVMOptions) DeepCopy() *JVMOptions {
if in == nil {
return nil
}
out := new(JVMOptions)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *MinecraftProperties) DeepCopyInto(out *MinecraftProperties) {
*out = *in
if in.PVP != nil {
in, out := &in.PVP, &out.PVP
*out = new(bool)
**out = **in
}
if in.OnlineMode != nil {
in, out := &in.OnlineMode, &out.OnlineMode
*out = new(bool)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MinecraftProperties.
func (in *MinecraftProperties) DeepCopy() *MinecraftProperties {
if in == nil {
return nil
}
out := new(MinecraftProperties)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *MinecraftServer) DeepCopyInto(out *MinecraftServer) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MinecraftServer.
func (in *MinecraftServer) DeepCopy() *MinecraftServer {
if in == nil {
return nil
}
out := new(MinecraftServer)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *MinecraftServer) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *MinecraftServerList) DeepCopyInto(out *MinecraftServerList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]MinecraftServer, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MinecraftServerList.
func (in *MinecraftServerList) DeepCopy() *MinecraftServerList {
if in == nil {
return nil
}
out := new(MinecraftServerList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *MinecraftServerList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *MinecraftServerSpec) DeepCopyInto(out *MinecraftServerSpec) {
*out = *in
out.Resources = in.Resources
out.JVM = in.JVM
in.Properties.DeepCopyInto(&out.Properties)
if in.Env != nil {
in, out := &in.Env, &out.Env
*out = make([]EnvVar, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MinecraftServerSpec.
func (in *MinecraftServerSpec) DeepCopy() *MinecraftServerSpec {
if in == nil {
return nil
}
out := new(MinecraftServerSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *MinecraftServerStatus) DeepCopyInto(out *MinecraftServerStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MinecraftServerStatus.
func (in *MinecraftServerStatus) DeepCopy() *MinecraftServerStatus {
if in == nil {
return nil
}
out := new(MinecraftServerStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Resources) DeepCopyInto(out *Resources) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Resources.
func (in *Resources) DeepCopy() *Resources {
if in == nil {
return nil
}
out := new(Resources)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ReverseProxyServer) DeepCopyInto(out *ReverseProxyServer) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReverseProxyServer.
func (in *ReverseProxyServer) DeepCopy() *ReverseProxyServer {
if in == nil {
return nil
}
out := new(ReverseProxyServer)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ReverseProxyServer) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ReverseProxyServerList) DeepCopyInto(out *ReverseProxyServerList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]ReverseProxyServer, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReverseProxyServerList.
func (in *ReverseProxyServerList) DeepCopy() *ReverseProxyServerList {
if in == nil {
return nil
}
out := new(ReverseProxyServerList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ReverseProxyServerList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ReverseProxyServerSpec) DeepCopyInto(out *ReverseProxyServerSpec) {
*out = *in
out.Resources = in.Resources
out.JVM = in.JVM
if in.Env != nil {
in, out := &in.Env, &out.Env
*out = make([]EnvVar, len(*in))
copy(*out, *in)
}
if in.BackendSelector != nil {
in, out := &in.BackendSelector, &out.BackendSelector
*out = new(v1.LabelSelector)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReverseProxyServerSpec.
func (in *ReverseProxyServerSpec) DeepCopy() *ReverseProxyServerSpec {
if in == nil {
return nil
}
out := new(ReverseProxyServerSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ReverseProxyServerStatus) DeepCopyInto(out *ReverseProxyServerStatus) {
*out = *in
if in.Backends != nil {
in, out := &in.Backends, &out.Backends
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReverseProxyServerStatus.
func (in *ReverseProxyServerStatus) DeepCopy() *ReverseProxyServerStatus {
if in == nil {
return nil
}
out := new(ReverseProxyServerStatus)
in.DeepCopyInto(out)
return out
}
+95
View File
@@ -0,0 +1,95 @@
package main
import (
"flag"
"os"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
"github.com/YuzuZensai/Minikura/operator/internal/controller"
)
var (
scheme = runtime.NewScheme()
setupLog = ctrl.Log.WithName("setup")
)
func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(v1alpha1.AddToScheme(scheme))
}
func main() {
var metricsAddr, probeAddr string
var enableLeaderElection bool
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "Address the metric endpoint binds to.")
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "Address the probe endpoint binds to.")
flag.BoolVar(&enableLeaderElection, "leader-elect", false,
"Enable leader election, ensuring only one active operator when running multiple replicas.")
opts := zap.Options{Development: os.Getenv("NODE_ENV") != "production"}
opts.BindFlags(flag.CommandLine)
flag.Parse()
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
namespace := os.Getenv("KUBERNETES_NAMESPACE")
if namespace == "" {
namespace = "minikura"
}
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{BindAddress: metricsAddr},
HealthProbeBindAddress: probeAddr,
LeaderElection: enableLeaderElection,
LeaderElectionID: "minikura-operator.minikura.kirameki.cafe",
Cache: cache.Options{
DefaultNamespaces: map[string]cache.Config{namespace: {}},
},
})
if err != nil {
setupLog.Error(err, "unable to start manager")
os.Exit(1)
}
if err := (&controller.MinecraftServerReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "MinecraftServer")
os.Exit(1)
}
if err := (&controller.ReverseProxyServerReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "ReverseProxyServer")
os.Exit(1)
}
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
setupLog.Error(err, "unable to set up health check")
os.Exit(1)
}
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
setupLog.Error(err, "unable to set up ready check")
os.Exit(1)
}
setupLog.Info("starting Minikura operator", "namespace", namespace)
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
setupLog.Error(err, "problem running manager")
os.Exit(1)
}
}
@@ -0,0 +1,269 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.21.0
name: minecraftservers.minikura.kirameki.cafe
spec:
group: minikura.kirameki.cafe
names:
kind: MinecraftServer
listKind: MinecraftServerList
plural: minecraftservers
shortNames:
- mcs
singular: minecraftserver
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.type
name: Type
type: string
- jsonPath: .spec.minecraftVersion
name: Version
type: string
- jsonPath: .status.phase
name: Phase
type: string
- jsonPath: .status.readyReplicas
name: Ready
type: string
- jsonPath: .status.endpoint
name: Endpoint
type: string
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
name: v1alpha1
schema:
openAPIV3Schema:
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
properties:
apiKeySecretRef:
type: string
description:
type: string
env:
items:
properties:
name:
type: string
value:
type: string
required:
- name
type: object
type: array
jarType:
default: VANILLA
enum:
- VANILLA
- PAPER
- SPIGOT
- PURPUR
- FABRIC
- FORGE
- FOLIA
type: string
jvm:
properties:
heapPercent:
default: 80
format: int32
maximum: 100
minimum: 1
type: integer
opts:
type: string
useAikarFlags:
type: boolean
useMeowIceFlags:
type: boolean
type: object
listenPort:
default: 25565
format: int32
maximum: 65535
minimum: 1
type: integer
minecraftVersion:
default: LATEST
type: string
nodePort:
format: int32
maximum: 32767
minimum: 30000
type: integer
properties:
properties:
difficulty:
default: EASY
enum:
- PEACEFUL
- EASY
- NORMAL
- HARD
type: string
gameMode:
default: SURVIVAL
enum:
- SURVIVAL
- CREATIVE
- ADVENTURE
- SPECTATOR
type: string
levelSeed:
type: string
levelType:
type: string
maxPlayers:
default: 20
format: int32
minimum: 1
type: integer
motd:
type: string
onlineMode:
default: true
type: boolean
pvp:
default: true
type: boolean
type: object
resources:
properties:
cpuLimit:
default: "2"
type: string
cpuRequest:
default: 500m
type: string
memoryLimitMB:
default: 2048
format: int32
minimum: 256
type: integer
memoryRequestMB:
format: int32
minimum: 256
type: integer
type: object
serviceType:
default: ClusterIP
enum:
- ClusterIP
- NodePort
- LoadBalancer
type: string
storageSize:
default: 1Gi
type: string
type:
enum:
- STATEFUL
- STATELESS
type: string
required:
- type
type: object
status:
properties:
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
endpoint:
type: string
message:
type: string
observedGeneration:
format: int64
type: integer
phase:
type: string
readyReplicas:
format: int32
type: integer
replicas:
format: int32
type: integer
type: object
type: object
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,275 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.21.0
name: reverseproxyservers.minikura.kirameki.cafe
spec:
group: minikura.kirameki.cafe
names:
kind: ReverseProxyServer
listKind: ReverseProxyServerList
plural: reverseproxyservers
shortNames:
- rps
singular: reverseproxyserver
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.type
name: Type
type: string
- jsonPath: .spec.externalAddress
name: Address
type: string
- jsonPath: .status.phase
name: Phase
type: string
- jsonPath: .status.readyReplicas
name: Ready
type: string
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
name: v1alpha1
schema:
openAPIV3Schema:
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
properties:
apiKeySecretRef:
type: string
backendSelector:
description: |-
A label selector is a label query over a set of resources. The result of matchLabels and
matchExpressions are ANDed. An empty label selector matches all objects. A null
label selector matches no objects.
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements.
The requirements are ANDed.
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
properties:
key:
description: key is the label key that the selector applies
to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
items:
type: string
type: array
x-kubernetes-list-type: atomic
required:
- key
- operator
type: object
type: array
x-kubernetes-list-type: atomic
matchLabels:
additionalProperties:
type: string
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
type: object
x-kubernetes-map-type: atomic
description:
type: string
env:
items:
properties:
name:
type: string
value:
type: string
required:
- name
type: object
type: array
externalAddress:
type: string
externalPort:
format: int32
maximum: 65535
minimum: 1
type: integer
jvm:
properties:
heapPercent:
default: 80
format: int32
maximum: 100
minimum: 1
type: integer
opts:
type: string
useAikarFlags:
type: boolean
useMeowIceFlags:
type: boolean
type: object
listenPort:
default: 25565
format: int32
maximum: 65535
minimum: 1
type: integer
nodePort:
format: int32
maximum: 32767
minimum: 30000
type: integer
resources:
properties:
cpuLimit:
default: "2"
type: string
cpuRequest:
default: 500m
type: string
memoryLimitMB:
default: 2048
format: int32
minimum: 256
type: integer
memoryRequestMB:
format: int32
minimum: 256
type: integer
type: object
serviceType:
default: LoadBalancer
enum:
- ClusterIP
- NodePort
- LoadBalancer
type: string
type:
enum:
- VELOCITY
- BUNGEECORD
type: string
required:
- externalAddress
- externalPort
- type
type: object
status:
properties:
backends:
items:
type: string
type: array
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
endpoint:
type: string
message:
type: string
observedGeneration:
format: int64
type: integer
phase:
type: string
readyReplicas:
format: int32
type: integer
replicas:
format: int32
type: integer
type: object
type: object
served: true
storage: true
subresources:
status: {}
+64
View File
@@ -0,0 +1,64 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: minikura-operator
labels:
app: minikura-operator
spec:
replicas: 1
selector:
matchLabels:
app: minikura-operator
template:
metadata:
labels:
app: minikura-operator
spec:
serviceAccountName: minikura-operator
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: operator
image: minikura-operator:latest
args:
- --leader-elect
env:
- name: KUBERNETES_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: NODE_ENV
value: production
ports:
- name: metrics
containerPort: 8080
- name: health
containerPort: 8081
livenessProbe:
httpGet:
path: /healthz
port: health
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /readyz
port: health
initialDelaySeconds: 5
periodSeconds: 10
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
terminationGracePeriodSeconds: 10
+88
View File
@@ -0,0 +1,88 @@
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: minikura-operator-role
rules:
- apiGroups:
- ""
resources:
- configmaps
- services
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- ""
resources:
- events
verbs:
- create
- patch
- apiGroups:
- ""
resources:
- pods
verbs:
- get
- list
- watch
- apiGroups:
- apps
resources:
- deployments
- statefulsets
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- coordination.k8s.io
resources:
- leases
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- minikura.kirameki.cafe
resources:
- minecraftservers
- reverseproxyservers
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- minikura.kirameki.cafe
resources:
- minecraftservers/finalizers
- reverseproxyservers/finalizers
verbs:
- update
- apiGroups:
- minikura.kirameki.cafe
resources:
- minecraftservers/status
- reverseproxyservers/status
verbs:
- get
- patch
- update
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: minikura-operator
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: minikura-operator-rolebinding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: minikura-operator-role
subjects:
- kind: ServiceAccount
name: minikura-operator
namespace: minikura
@@ -0,0 +1,44 @@
apiVersion: minikura.kirameki.cafe/v1alpha1
kind: MinecraftServer
metadata:
name: survival
spec:
type: STATEFUL
description: Example SMP server
jarType: PAPER
minecraftVersion: "1.20.4"
listenPort: 25565
serviceType: ClusterIP
storageSize: 5Gi
resources:
memoryLimitMB: 4096
memoryRequestMB: 2048
cpuRequest: 500m
cpuLimit: "2"
jvm:
useAikarFlags: true
heapPercent: 80
properties:
difficulty: HARD
gameMode: SURVIVAL
maxPlayers: 20
pvp: true
onlineMode: true
motd: A Minikura server
---
apiVersion: minikura.kirameki.cafe/v1alpha1
kind: MinecraftServer
metadata:
name: lobby
labels:
role: lobby
spec:
type: STATELESS
jarType: PAPER
minecraftVersion: "1.20.4"
serviceType: ClusterIP
resources:
memoryLimitMB: 2048
properties:
gameMode: ADVENTURE
maxPlayers: 100
@@ -0,0 +1,17 @@
apiVersion: minikura.kirameki.cafe/v1alpha1
kind: ReverseProxyServer
metadata:
name: edge
spec:
type: VELOCITY
description: Example Velocity proxy
externalAddress: play.example.com
externalPort: 25565
listenPort: 25577
serviceType: LoadBalancer
resources:
memoryLimitMB: 1024
cpuRequest: 250m
cpuLimit: "1"
jvm:
heapPercent: 80
+64
View File
@@ -0,0 +1,64 @@
module github.com/YuzuZensai/Minikura/operator
go 1.26.5
require (
k8s.io/api v0.36.0
k8s.io/apimachinery v0.36.0
k8s.io/client-go v0.36.0
sigs.k8s.io/controller-runtime v0.24.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/apiextensions-apiserver v0.36.0 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
+171
View File
@@ -0,0 +1,171 @@
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y=
github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80=
k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34=
k8s.io/apiextensions-apiserver v0.36.0 h1:Wt7E8J+VBCbj4FjiBfDTK/neXDDjyJVJc7xfuOHImZ0=
k8s.io/apiextensions-apiserver v0.36.0/go.mod h1:kGDjH0msuiIB3tgsYRV0kS9GqpMYMUsQ3GHv7TApyug=
k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ=
k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc=
k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c=
k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4=
sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=
sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+32
View File
@@ -0,0 +1,32 @@
package controller
import (
"context"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
)
var applyOpts = []client.PatchOption{
client.FieldOwner(v1alpha1.ManagerName),
client.ForceOwnership,
}
func apply(ctx context.Context, c client.Client, owner client.Object, obj client.Object, scheme *runtime.Scheme) error {
if err := setOwner(owner, obj, scheme); err != nil {
return err
}
gvk, err := apiutil.GVKForObject(obj, scheme)
if err != nil {
return err
}
obj.GetObjectKind().SetGroupVersionKind(gvk)
obj.SetManagedFields(nil)
obj.SetResourceVersion("")
return c.Patch(ctx, obj, client.Apply, applyOpts...)
}
@@ -0,0 +1,91 @@
package controller
import (
"encoding/json"
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
)
func testScheme(t *testing.T) *runtime.Scheme {
t.Helper()
s := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(s); err != nil {
t.Fatalf("add client-go scheme: %v", err)
}
if err := v1alpha1.AddToScheme(s); err != nil {
t.Fatalf("add v1alpha1 scheme: %v", err)
}
return s
}
func TestTypedObjectMarshalsWithoutTypeMeta(t *testing.T) {
cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "a", Namespace: "b"}}
var m map[string]any
raw, err := json.Marshal(cm)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, ok := m["apiVersion"]; ok {
t.Fatal("precondition changed: typed objects now carry apiVersion")
}
if _, ok := m["kind"]; ok {
t.Fatal("precondition changed: typed objects now carry kind")
}
}
func TestApplySetsTypeMeta(t *testing.T) {
scheme := testScheme(t)
owner := &v1alpha1.MinecraftServer{
ObjectMeta: metav1.ObjectMeta{Name: "smp", Namespace: "minikura", UID: "abc"},
}
cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "a", Namespace: "minikura"}}
if err := setOwner(owner, cm, scheme); err != nil {
t.Fatalf("setOwner: %v", err)
}
gvk, err := apiutil.GVKForObject(cm, scheme)
if err != nil {
t.Fatalf("GVKForObject: %v", err)
}
cm.GetObjectKind().SetGroupVersionKind(gvk)
raw, err := json.Marshal(cm)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if m["apiVersion"] != "v1" {
t.Errorf("apiVersion = %v, want v1", m["apiVersion"])
}
if m["kind"] != "ConfigMap" {
t.Errorf("kind = %v, want ConfigMap", m["kind"])
}
refs := cm.GetOwnerReferences()
if len(refs) != 1 {
t.Fatalf("ownerReferences = %d, want 1", len(refs))
}
if refs[0].Kind != "MinecraftServer" || refs[0].Name != "smp" {
t.Errorf("owner = %s/%s, want MinecraftServer/smp", refs[0].Kind, refs[0].Name)
}
if refs[0].Controller == nil || !*refs[0].Controller {
t.Error("owner reference should be a controller reference")
}
}
@@ -0,0 +1,184 @@
package controller
import (
"context"
"fmt"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
"github.com/YuzuZensai/Minikura/operator/internal/resources"
)
type MinecraftServerReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=minikura.kirameki.cafe,resources=minecraftservers,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=minikura.kirameki.cafe,resources=minecraftservers/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=minikura.kirameki.cafe,resources=minecraftservers/finalizers,verbs=update
// +kubebuilder:rbac:groups=apps,resources=deployments;statefulsets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=services;configmaps,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch
// +kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
func (r *MinecraftServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var mc v1alpha1.MinecraftServer
if err := r.Get(ctx, req.NamespacedName, &mc); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if !mc.DeletionTimestamp.IsZero() {
return ctrl.Result{}, nil
}
if err := apply(ctx, r.Client, &mc, resources.MinecraftConfigMap(&mc), r.Scheme); err != nil {
return r.fail(ctx, &mc, "ConfigMapFailed", err)
}
if err := apply(ctx, r.Client, &mc, resources.MinecraftService(&mc), r.Scheme); err != nil {
return r.fail(ctx, &mc, "ServiceFailed", err)
}
stateful := mc.Spec.Type == v1alpha1.ServerStateful
if err := r.reconcileWorkload(ctx, &mc, stateful); err != nil {
return r.fail(ctx, &mc, "WorkloadFailed", err)
}
if err := r.pruneOppositeWorkload(ctx, &mc, stateful); err != nil {
logger.Error(err, "failed to prune previous workload")
}
return ctrl.Result{}, r.updateStatus(ctx, &mc, stateful)
}
func (r *MinecraftServerReconciler) reconcileWorkload(ctx context.Context, mc *v1alpha1.MinecraftServer, stateful bool) error {
if !stateful {
return apply(ctx, r.Client, mc, resources.MinecraftDeployment(mc), r.Scheme)
}
sts, err := resources.MinecraftStatefulSet(mc)
if err != nil {
return err
}
return apply(ctx, r.Client, mc, sts, r.Scheme)
}
func (r *MinecraftServerReconciler) pruneOppositeWorkload(ctx context.Context, mc *v1alpha1.MinecraftServer, stateful bool) error {
name := resources.ServerName(mc.Name)
key := client.ObjectKey{Name: name, Namespace: mc.Namespace}
var stale client.Object
if stateful {
stale = &appsv1.Deployment{}
} else {
stale = &appsv1.StatefulSet{}
}
if err := r.Get(ctx, key, stale); err != nil {
return client.IgnoreNotFound(err)
}
if !metav1.IsControlledBy(stale, mc) {
return nil
}
return client.IgnoreNotFound(r.Delete(ctx, stale))
}
func (r *MinecraftServerReconciler) updateStatus(ctx context.Context, mc *v1alpha1.MinecraftServer, stateful bool) error {
name := resources.ServerName(mc.Name)
key := client.ObjectKey{Name: name, Namespace: mc.Namespace}
var ready, replicas int32
if stateful {
var sts appsv1.StatefulSet
if err := r.Get(ctx, key, &sts); err == nil {
ready, replicas = sts.Status.ReadyReplicas, sts.Status.Replicas
} else if !apierrors.IsNotFound(err) {
return err
}
} else {
var dep appsv1.Deployment
if err := r.Get(ctx, key, &dep); err == nil {
ready, replicas = dep.Status.ReadyReplicas, dep.Status.Replicas
} else if !apierrors.IsNotFound(err) {
return err
}
}
phase := v1alpha1.PhasePending
if ready > 0 {
phase = v1alpha1.PhaseRunning
}
endpoint, err := r.endpoint(ctx, mc)
if err != nil {
return err
}
patch := client.MergeFrom(mc.DeepCopy())
mc.Status.Phase = phase
mc.Status.ReadyReplicas = ready
mc.Status.Replicas = replicas
mc.Status.Endpoint = endpoint
mc.Status.ObservedGeneration = mc.Generation
mc.Status.Message = ""
setCondition(&mc.Status.Conditions, metav1.Condition{
Type: v1alpha1.ConditionReady,
Status: conditionStatus(ready > 0),
Reason: phase,
ObservedGeneration: mc.Generation,
})
return r.Status().Patch(ctx, mc, patch)
}
func (r *MinecraftServerReconciler) endpoint(ctx context.Context, mc *v1alpha1.MinecraftServer) (string, error) {
var svc corev1.Service
key := client.ObjectKey{Name: resources.ServerName(mc.Name), Namespace: mc.Namespace}
if err := r.Get(ctx, key, &svc); err != nil {
if apierrors.IsNotFound(err) {
return "", nil
}
return "", err
}
return serviceEndpoint(&svc, mc.Namespace), nil
}
func (r *MinecraftServerReconciler) fail(ctx context.Context, mc *v1alpha1.MinecraftServer, reason string, cause error) (ctrl.Result, error) {
patch := client.MergeFrom(mc.DeepCopy())
mc.Status.Phase = v1alpha1.PhaseFailed
mc.Status.Message = cause.Error()
setCondition(&mc.Status.Conditions, metav1.Condition{
Type: v1alpha1.ConditionReady,
Status: metav1.ConditionFalse,
Reason: reason,
Message: cause.Error(),
ObservedGeneration: mc.Generation,
})
if err := r.Status().Patch(ctx, mc, patch); err != nil {
return ctrl.Result{}, fmt.Errorf("%w (status patch failed: %v)", cause, err)
}
return ctrl.Result{}, cause
}
func (r *MinecraftServerReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&v1alpha1.MinecraftServer{}).
Owns(&appsv1.Deployment{}).
Owns(&appsv1.StatefulSet{}).
Owns(&corev1.Service{}).
Owns(&corev1.ConfigMap{}).
Complete(r)
}
+11
View File
@@ -0,0 +1,11 @@
package controller
import (
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
func setOwner(owner, obj client.Object, scheme *runtime.Scheme) error {
return controllerutil.SetControllerReference(owner, obj, scheme)
}
@@ -0,0 +1,172 @@
package controller
import (
"context"
"fmt"
"sort"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
"github.com/YuzuZensai/Minikura/operator/internal/resources"
)
type ReverseProxyServerReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=minikura.kirameki.cafe,resources=reverseproxyservers,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=minikura.kirameki.cafe,resources=reverseproxyservers/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=minikura.kirameki.cafe,resources=reverseproxyservers/finalizers,verbs=update
func (r *ReverseProxyServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var rp v1alpha1.ReverseProxyServer
if err := r.Get(ctx, req.NamespacedName, &rp); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if !rp.DeletionTimestamp.IsZero() {
return ctrl.Result{}, nil
}
if err := apply(ctx, r.Client, &rp, resources.ProxyConfigMap(&rp), r.Scheme); err != nil {
return r.fail(ctx, &rp, "ConfigMapFailed", err)
}
if err := apply(ctx, r.Client, &rp, resources.ProxyService(&rp), r.Scheme); err != nil {
return r.fail(ctx, &rp, "ServiceFailed", err)
}
if err := apply(ctx, r.Client, &rp, resources.ProxyDeployment(&rp), r.Scheme); err != nil {
return r.fail(ctx, &rp, "DeploymentFailed", err)
}
return ctrl.Result{}, r.updateStatus(ctx, &rp)
}
func (r *ReverseProxyServerReconciler) backends(ctx context.Context, rp *v1alpha1.ReverseProxyServer) ([]string, error) {
selector := labels.Everything()
if rp.Spec.BackendSelector != nil {
s, err := metav1.LabelSelectorAsSelector(rp.Spec.BackendSelector)
if err != nil {
return nil, fmt.Errorf("invalid backendSelector: %w", err)
}
selector = s
}
var list v1alpha1.MinecraftServerList
if err := r.List(ctx, &list,
client.InNamespace(rp.Namespace),
client.MatchingLabelsSelector{Selector: selector},
); err != nil {
return nil, err
}
names := make([]string, 0, len(list.Items))
for _, mc := range list.Items {
names = append(names, mc.Name)
}
sort.Strings(names)
return names, nil
}
func (r *ReverseProxyServerReconciler) updateStatus(ctx context.Context, rp *v1alpha1.ReverseProxyServer) error {
name := resources.ProxyName(rp.Spec.Type, rp.Name)
key := client.ObjectKey{Name: name, Namespace: rp.Namespace}
var ready, replicas int32
var dep appsv1.Deployment
if err := r.Get(ctx, key, &dep); err == nil {
ready, replicas = dep.Status.ReadyReplicas, dep.Status.Replicas
} else if !apierrors.IsNotFound(err) {
return err
}
backends, err := r.backends(ctx, rp)
if err != nil {
return err
}
endpoint := ""
var svc corev1.Service
if err := r.Get(ctx, key, &svc); err == nil {
endpoint = serviceEndpoint(&svc, rp.Namespace)
} else if !apierrors.IsNotFound(err) {
return err
}
phase := v1alpha1.PhasePending
if ready > 0 {
phase = v1alpha1.PhaseRunning
}
patch := client.MergeFrom(rp.DeepCopy())
rp.Status.Phase = phase
rp.Status.ReadyReplicas = ready
rp.Status.Replicas = replicas
rp.Status.Endpoint = endpoint
rp.Status.Backends = backends
rp.Status.ObservedGeneration = rp.Generation
rp.Status.Message = ""
setCondition(&rp.Status.Conditions, metav1.Condition{
Type: v1alpha1.ConditionReady,
Status: conditionStatus(ready > 0),
Reason: phase,
ObservedGeneration: rp.Generation,
})
return r.Status().Patch(ctx, rp, patch)
}
func (r *ReverseProxyServerReconciler) fail(ctx context.Context, rp *v1alpha1.ReverseProxyServer, reason string, cause error) (ctrl.Result, error) {
patch := client.MergeFrom(rp.DeepCopy())
rp.Status.Phase = v1alpha1.PhaseFailed
rp.Status.Message = cause.Error()
setCondition(&rp.Status.Conditions, metav1.Condition{
Type: v1alpha1.ConditionReady,
Status: metav1.ConditionFalse,
Reason: reason,
Message: cause.Error(),
ObservedGeneration: rp.Generation,
})
if err := r.Status().Patch(ctx, rp, patch); err != nil {
return ctrl.Result{}, fmt.Errorf("%w (status patch failed: %v)", cause, err)
}
return ctrl.Result{}, cause
}
func (r *ReverseProxyServerReconciler) proxiesForServer(ctx context.Context, obj client.Object) []reconcile.Request {
var list v1alpha1.ReverseProxyServerList
if err := r.List(ctx, &list, client.InNamespace(obj.GetNamespace())); err != nil {
return nil
}
reqs := make([]reconcile.Request, 0, len(list.Items))
for _, rp := range list.Items {
reqs = append(reqs, reconcile.Request{
NamespacedName: client.ObjectKey{Name: rp.Name, Namespace: rp.Namespace},
})
}
return reqs
}
func (r *ReverseProxyServerReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&v1alpha1.ReverseProxyServer{}).
Owns(&appsv1.Deployment{}).
Owns(&corev1.Service{}).
Owns(&corev1.ConfigMap{}).
Watches(&v1alpha1.MinecraftServer{}, handler.EnqueueRequestsFromMapFunc(r.proxiesForServer)).
Complete(r)
}
+53
View File
@@ -0,0 +1,53 @@
package controller
import (
"fmt"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func conditionStatus(ok bool) metav1.ConditionStatus {
if ok {
return metav1.ConditionTrue
}
return metav1.ConditionFalse
}
func setCondition(conditions *[]metav1.Condition, c metav1.Condition) {
if c.Reason == "" {
c.Reason = "Unknown"
}
meta.SetStatusCondition(conditions, c)
}
func serviceEndpoint(svc *corev1.Service, namespace string) string {
internal := fmt.Sprintf("%s.%s.svc.cluster.local", svc.Name, namespace)
port := int32(0)
if len(svc.Spec.Ports) > 0 {
port = svc.Spec.Ports[0].Port
}
switch svc.Spec.Type {
case corev1.ServiceTypeLoadBalancer:
for _, ing := range svc.Status.LoadBalancer.Ingress {
if host := ing.Hostname; host != "" {
return fmt.Sprintf("%s:%d", host, port)
}
if ip := ing.IP; ip != "" {
return fmt.Sprintf("%s:%d", ip, port)
}
}
return ""
case corev1.ServiceTypeNodePort:
if len(svc.Spec.Ports) > 0 && svc.Spec.Ports[0].NodePort != 0 {
return fmt.Sprintf("<node-ip>:%d", svc.Spec.Ports[0].NodePort)
}
return internal
default:
return fmt.Sprintf("%s:%d", internal, port)
}
}
+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),
},
},
},
}},
},
},
},
}
}