From da6eafcae6ea55513f8e9db73edfc26bc711e4cb Mon Sep 17 00:00:00 2001 From: Yuzu Date: Thu, 13 Aug 2026 01:57:26 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20Go=20Kubernetes=20ope?= =?UTF-8?q?rator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- operator/.gitignore | 1 + operator/Dockerfile | 18 ++ operator/Makefile | 57 ++++ operator/api/v1alpha1/common.go | 51 +++ operator/api/v1alpha1/groupversion_info.go | 16 + .../api/v1alpha1/minecraftserver_types.go | 164 ++++++++++ .../api/v1alpha1/reverseproxyserver_types.go | 111 +++++++ .../api/v1alpha1/zz_generated.deepcopy.go | 297 ++++++++++++++++++ operator/cmd/main.go | 95 ++++++ ...nikura.kirameki.cafe_minecraftservers.yaml | 269 ++++++++++++++++ ...ura.kirameki.cafe_reverseproxyservers.yaml | 275 ++++++++++++++++ operator/config/manager/deployment.yaml | 64 ++++ operator/config/rbac/role.yaml | 88 ++++++ operator/config/rbac/service_account.yaml | 17 + operator/config/samples/minecraftserver.yaml | 44 +++ .../config/samples/reverseproxyserver.yaml | 17 + operator/go.mod | 64 ++++ operator/go.sum | 171 ++++++++++ operator/internal/controller/apply.go | 32 ++ operator/internal/controller/apply_test.go | 91 ++++++ .../controller/minecraftserver_controller.go | 184 +++++++++++ operator/internal/controller/owner.go | 11 + .../reverseproxyserver_controller.go | 172 ++++++++++ operator/internal/controller/status.go | 53 ++++ operator/internal/resources/common.go | 123 ++++++++ operator/internal/resources/common_test.go | 86 +++++ .../internal/resources/minecraftserver.go | 185 +++++++++++ .../resources/minecraftserver_test.go | 154 +++++++++ operator/internal/resources/naming.go | 36 +++ .../internal/resources/reverseproxyserver.go | 120 +++++++ 30 files changed, 3066 insertions(+) create mode 100644 operator/.gitignore create mode 100644 operator/Dockerfile create mode 100644 operator/Makefile create mode 100644 operator/api/v1alpha1/common.go create mode 100644 operator/api/v1alpha1/groupversion_info.go create mode 100644 operator/api/v1alpha1/minecraftserver_types.go create mode 100644 operator/api/v1alpha1/reverseproxyserver_types.go create mode 100644 operator/api/v1alpha1/zz_generated.deepcopy.go create mode 100644 operator/cmd/main.go create mode 100644 operator/config/crd/minikura.kirameki.cafe_minecraftservers.yaml create mode 100644 operator/config/crd/minikura.kirameki.cafe_reverseproxyservers.yaml create mode 100644 operator/config/manager/deployment.yaml create mode 100644 operator/config/rbac/role.yaml create mode 100644 operator/config/rbac/service_account.yaml create mode 100644 operator/config/samples/minecraftserver.yaml create mode 100644 operator/config/samples/reverseproxyserver.yaml create mode 100644 operator/go.mod create mode 100644 operator/go.sum create mode 100644 operator/internal/controller/apply.go create mode 100644 operator/internal/controller/apply_test.go create mode 100644 operator/internal/controller/minecraftserver_controller.go create mode 100644 operator/internal/controller/owner.go create mode 100644 operator/internal/controller/reverseproxyserver_controller.go create mode 100644 operator/internal/controller/status.go create mode 100644 operator/internal/resources/common.go create mode 100644 operator/internal/resources/common_test.go create mode 100644 operator/internal/resources/minecraftserver.go create mode 100644 operator/internal/resources/minecraftserver_test.go create mode 100644 operator/internal/resources/naming.go create mode 100644 operator/internal/resources/reverseproxyserver.go diff --git a/operator/.gitignore b/operator/.gitignore new file mode 100644 index 0000000..e660fd9 --- /dev/null +++ b/operator/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/operator/Dockerfile b/operator/Dockerfile new file mode 100644 index 0000000..2296571 --- /dev/null +++ b/operator/Dockerfile @@ -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"] diff --git a/operator/Makefile b/operator/Makefile new file mode 100644 index 0000000..a105dd0 --- /dev/null +++ b/operator/Makefile @@ -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 diff --git a/operator/api/v1alpha1/common.go b/operator/api/v1alpha1/common.go new file mode 100644 index 0000000..84dd949 --- /dev/null +++ b/operator/api/v1alpha1/common.go @@ -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"` +} diff --git a/operator/api/v1alpha1/groupversion_info.go b/operator/api/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..4b650a0 --- /dev/null +++ b/operator/api/v1alpha1/groupversion_info.go @@ -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 +) diff --git a/operator/api/v1alpha1/minecraftserver_types.go b/operator/api/v1alpha1/minecraftserver_types.go new file mode 100644 index 0000000..556b9b7 --- /dev/null +++ b/operator/api/v1alpha1/minecraftserver_types.go @@ -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{}) +} diff --git a/operator/api/v1alpha1/reverseproxyserver_types.go b/operator/api/v1alpha1/reverseproxyserver_types.go new file mode 100644 index 0000000..44f99c1 --- /dev/null +++ b/operator/api/v1alpha1/reverseproxyserver_types.go @@ -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{}) +} diff --git a/operator/api/v1alpha1/zz_generated.deepcopy.go b/operator/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..30e4a69 --- /dev/null +++ b/operator/api/v1alpha1/zz_generated.deepcopy.go @@ -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 +} diff --git a/operator/cmd/main.go b/operator/cmd/main.go new file mode 100644 index 0000000..cb86701 --- /dev/null +++ b/operator/cmd/main.go @@ -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) + } +} diff --git a/operator/config/crd/minikura.kirameki.cafe_minecraftservers.yaml b/operator/config/crd/minikura.kirameki.cafe_minecraftservers.yaml new file mode 100644 index 0000000..9e450be --- /dev/null +++ b/operator/config/crd/minikura.kirameki.cafe_minecraftservers.yaml @@ -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: {} diff --git a/operator/config/crd/minikura.kirameki.cafe_reverseproxyservers.yaml b/operator/config/crd/minikura.kirameki.cafe_reverseproxyservers.yaml new file mode 100644 index 0000000..67f02d9 --- /dev/null +++ b/operator/config/crd/minikura.kirameki.cafe_reverseproxyservers.yaml @@ -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: {} diff --git a/operator/config/manager/deployment.yaml b/operator/config/manager/deployment.yaml new file mode 100644 index 0000000..4dc554c --- /dev/null +++ b/operator/config/manager/deployment.yaml @@ -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 diff --git a/operator/config/rbac/role.yaml b/operator/config/rbac/role.yaml new file mode 100644 index 0000000..94f6eb2 --- /dev/null +++ b/operator/config/rbac/role.yaml @@ -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 diff --git a/operator/config/rbac/service_account.yaml b/operator/config/rbac/service_account.yaml new file mode 100644 index 0000000..3d6737a --- /dev/null +++ b/operator/config/rbac/service_account.yaml @@ -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 diff --git a/operator/config/samples/minecraftserver.yaml b/operator/config/samples/minecraftserver.yaml new file mode 100644 index 0000000..84fb9a6 --- /dev/null +++ b/operator/config/samples/minecraftserver.yaml @@ -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 diff --git a/operator/config/samples/reverseproxyserver.yaml b/operator/config/samples/reverseproxyserver.yaml new file mode 100644 index 0000000..7d5a6cc --- /dev/null +++ b/operator/config/samples/reverseproxyserver.yaml @@ -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 diff --git a/operator/go.mod b/operator/go.mod new file mode 100644 index 0000000..8266482 --- /dev/null +++ b/operator/go.mod @@ -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 +) diff --git a/operator/go.sum b/operator/go.sum new file mode 100644 index 0000000..dbc04b4 --- /dev/null +++ b/operator/go.sum @@ -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= diff --git a/operator/internal/controller/apply.go b/operator/internal/controller/apply.go new file mode 100644 index 0000000..fe506c8 --- /dev/null +++ b/operator/internal/controller/apply.go @@ -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...) +} diff --git a/operator/internal/controller/apply_test.go b/operator/internal/controller/apply_test.go new file mode 100644 index 0000000..24ec645 --- /dev/null +++ b/operator/internal/controller/apply_test.go @@ -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") + } +} diff --git a/operator/internal/controller/minecraftserver_controller.go b/operator/internal/controller/minecraftserver_controller.go new file mode 100644 index 0000000..7efb580 --- /dev/null +++ b/operator/internal/controller/minecraftserver_controller.go @@ -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) +} diff --git a/operator/internal/controller/owner.go b/operator/internal/controller/owner.go new file mode 100644 index 0000000..13a7134 --- /dev/null +++ b/operator/internal/controller/owner.go @@ -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) +} diff --git a/operator/internal/controller/reverseproxyserver_controller.go b/operator/internal/controller/reverseproxyserver_controller.go new file mode 100644 index 0000000..2377e94 --- /dev/null +++ b/operator/internal/controller/reverseproxyserver_controller.go @@ -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) +} diff --git a/operator/internal/controller/status.go b/operator/internal/controller/status.go new file mode 100644 index 0000000..f39dd88 --- /dev/null +++ b/operator/internal/controller/status.go @@ -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(":%d", svc.Spec.Ports[0].NodePort) + } + return internal + + default: + return fmt.Sprintf("%s:%d", internal, port) + } +} diff --git a/operator/internal/resources/common.go b/operator/internal/resources/common.go new file mode 100644 index 0000000..477f958 --- /dev/null +++ b/operator/internal/resources/common.go @@ -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 +} diff --git a/operator/internal/resources/common_test.go b/operator/internal/resources/common_test.go new file mode 100644 index 0000000..d43215c --- /dev/null +++ b/operator/internal/resources/common_test.go @@ -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) + } +} diff --git a/operator/internal/resources/minecraftserver.go b/operator/internal/resources/minecraftserver.go new file mode 100644 index 0000000..0f6b2e1 --- /dev/null +++ b/operator/internal/resources/minecraftserver.go @@ -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 } diff --git a/operator/internal/resources/minecraftserver_test.go b/operator/internal/resources/minecraftserver_test.go new file mode 100644 index 0000000..0bd81d8 --- /dev/null +++ b/operator/internal/resources/minecraftserver_test.go @@ -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") + } + } +} diff --git a/operator/internal/resources/naming.go b/operator/internal/resources/naming.go new file mode 100644 index 0000000..b2bc715 --- /dev/null +++ b/operator/internal/resources/naming.go @@ -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} +} diff --git a/operator/internal/resources/reverseproxyserver.go b/operator/internal/resources/reverseproxyserver.go new file mode 100644 index 0000000..cfafc22 --- /dev/null +++ b/operator/internal/resources/reverseproxyserver.go @@ -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), + }, + }, + }, + }}, + }, + }, + }, + } +}