mirror of
https://github.com/YuzuZensai/Minikura.git
synced 2026-09-13 10:49:21 +00:00
✨ feat: add Go Kubernetes operator
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
|
||||
|
||||
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
var applyOpts = []client.PatchOption{
|
||||
client.FieldOwner(v1alpha1.ManagerName),
|
||||
client.ForceOwnership,
|
||||
}
|
||||
|
||||
func apply(ctx context.Context, c client.Client, owner client.Object, obj client.Object, scheme *runtime.Scheme) error {
|
||||
if err := setOwner(owner, obj, scheme); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gvk, err := apiutil.GVKForObject(obj, scheme)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
obj.GetObjectKind().SetGroupVersionKind(gvk)
|
||||
|
||||
obj.SetManagedFields(nil)
|
||||
obj.SetResourceVersion("")
|
||||
return c.Patch(ctx, obj, client.Apply, applyOpts...)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
|
||||
|
||||
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
func testScheme(t *testing.T) *runtime.Scheme {
|
||||
t.Helper()
|
||||
s := runtime.NewScheme()
|
||||
if err := clientgoscheme.AddToScheme(s); err != nil {
|
||||
t.Fatalf("add client-go scheme: %v", err)
|
||||
}
|
||||
if err := v1alpha1.AddToScheme(s); err != nil {
|
||||
t.Fatalf("add v1alpha1 scheme: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestTypedObjectMarshalsWithoutTypeMeta(t *testing.T) {
|
||||
cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "a", Namespace: "b"}}
|
||||
|
||||
var m map[string]any
|
||||
raw, err := json.Marshal(cm)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := m["apiVersion"]; ok {
|
||||
t.Fatal("precondition changed: typed objects now carry apiVersion")
|
||||
}
|
||||
if _, ok := m["kind"]; ok {
|
||||
t.Fatal("precondition changed: typed objects now carry kind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySetsTypeMeta(t *testing.T) {
|
||||
scheme := testScheme(t)
|
||||
owner := &v1alpha1.MinecraftServer{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "smp", Namespace: "minikura", UID: "abc"},
|
||||
}
|
||||
|
||||
cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "a", Namespace: "minikura"}}
|
||||
if err := setOwner(owner, cm, scheme); err != nil {
|
||||
t.Fatalf("setOwner: %v", err)
|
||||
}
|
||||
|
||||
gvk, err := apiutil.GVKForObject(cm, scheme)
|
||||
if err != nil {
|
||||
t.Fatalf("GVKForObject: %v", err)
|
||||
}
|
||||
cm.GetObjectKind().SetGroupVersionKind(gvk)
|
||||
|
||||
raw, err := json.Marshal(cm)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if m["apiVersion"] != "v1" {
|
||||
t.Errorf("apiVersion = %v, want v1", m["apiVersion"])
|
||||
}
|
||||
if m["kind"] != "ConfigMap" {
|
||||
t.Errorf("kind = %v, want ConfigMap", m["kind"])
|
||||
}
|
||||
|
||||
refs := cm.GetOwnerReferences()
|
||||
if len(refs) != 1 {
|
||||
t.Fatalf("ownerReferences = %d, want 1", len(refs))
|
||||
}
|
||||
if refs[0].Kind != "MinecraftServer" || refs[0].Name != "smp" {
|
||||
t.Errorf("owner = %s/%s, want MinecraftServer/smp", refs[0].Kind, refs[0].Name)
|
||||
}
|
||||
if refs[0].Controller == nil || !*refs[0].Controller {
|
||||
t.Error("owner reference should be a controller reference")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
|
||||
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
|
||||
"github.com/YuzuZensai/Minikura/operator/internal/resources"
|
||||
)
|
||||
|
||||
type MinecraftServerReconciler struct {
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
}
|
||||
|
||||
// +kubebuilder:rbac:groups=minikura.kirameki.cafe,resources=minecraftservers,verbs=get;list;watch;create;update;patch;delete
|
||||
// +kubebuilder:rbac:groups=minikura.kirameki.cafe,resources=minecraftservers/status,verbs=get;update;patch
|
||||
// +kubebuilder:rbac:groups=minikura.kirameki.cafe,resources=minecraftservers/finalizers,verbs=update
|
||||
// +kubebuilder:rbac:groups=apps,resources=deployments;statefulsets,verbs=get;list;watch;create;update;patch;delete
|
||||
// +kubebuilder:rbac:groups="",resources=services;configmaps,verbs=get;list;watch;create;update;patch;delete
|
||||
// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch
|
||||
// +kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=get;list;watch;create;update;patch;delete
|
||||
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
|
||||
|
||||
func (r *MinecraftServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
var mc v1alpha1.MinecraftServer
|
||||
if err := r.Get(ctx, req.NamespacedName, &mc); err != nil {
|
||||
return ctrl.Result{}, client.IgnoreNotFound(err)
|
||||
}
|
||||
|
||||
if !mc.DeletionTimestamp.IsZero() {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
if err := apply(ctx, r.Client, &mc, resources.MinecraftConfigMap(&mc), r.Scheme); err != nil {
|
||||
return r.fail(ctx, &mc, "ConfigMapFailed", err)
|
||||
}
|
||||
|
||||
if err := apply(ctx, r.Client, &mc, resources.MinecraftService(&mc), r.Scheme); err != nil {
|
||||
return r.fail(ctx, &mc, "ServiceFailed", err)
|
||||
}
|
||||
|
||||
stateful := mc.Spec.Type == v1alpha1.ServerStateful
|
||||
if err := r.reconcileWorkload(ctx, &mc, stateful); err != nil {
|
||||
return r.fail(ctx, &mc, "WorkloadFailed", err)
|
||||
}
|
||||
|
||||
if err := r.pruneOppositeWorkload(ctx, &mc, stateful); err != nil {
|
||||
logger.Error(err, "failed to prune previous workload")
|
||||
}
|
||||
|
||||
return ctrl.Result{}, r.updateStatus(ctx, &mc, stateful)
|
||||
}
|
||||
|
||||
func (r *MinecraftServerReconciler) reconcileWorkload(ctx context.Context, mc *v1alpha1.MinecraftServer, stateful bool) error {
|
||||
if !stateful {
|
||||
return apply(ctx, r.Client, mc, resources.MinecraftDeployment(mc), r.Scheme)
|
||||
}
|
||||
|
||||
sts, err := resources.MinecraftStatefulSet(mc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return apply(ctx, r.Client, mc, sts, r.Scheme)
|
||||
}
|
||||
|
||||
func (r *MinecraftServerReconciler) pruneOppositeWorkload(ctx context.Context, mc *v1alpha1.MinecraftServer, stateful bool) error {
|
||||
name := resources.ServerName(mc.Name)
|
||||
key := client.ObjectKey{Name: name, Namespace: mc.Namespace}
|
||||
|
||||
var stale client.Object
|
||||
if stateful {
|
||||
stale = &appsv1.Deployment{}
|
||||
} else {
|
||||
stale = &appsv1.StatefulSet{}
|
||||
}
|
||||
|
||||
if err := r.Get(ctx, key, stale); err != nil {
|
||||
return client.IgnoreNotFound(err)
|
||||
}
|
||||
if !metav1.IsControlledBy(stale, mc) {
|
||||
return nil
|
||||
}
|
||||
return client.IgnoreNotFound(r.Delete(ctx, stale))
|
||||
}
|
||||
|
||||
func (r *MinecraftServerReconciler) updateStatus(ctx context.Context, mc *v1alpha1.MinecraftServer, stateful bool) error {
|
||||
name := resources.ServerName(mc.Name)
|
||||
key := client.ObjectKey{Name: name, Namespace: mc.Namespace}
|
||||
|
||||
var ready, replicas int32
|
||||
if stateful {
|
||||
var sts appsv1.StatefulSet
|
||||
if err := r.Get(ctx, key, &sts); err == nil {
|
||||
ready, replicas = sts.Status.ReadyReplicas, sts.Status.Replicas
|
||||
} else if !apierrors.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
var dep appsv1.Deployment
|
||||
if err := r.Get(ctx, key, &dep); err == nil {
|
||||
ready, replicas = dep.Status.ReadyReplicas, dep.Status.Replicas
|
||||
} else if !apierrors.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
phase := v1alpha1.PhasePending
|
||||
if ready > 0 {
|
||||
phase = v1alpha1.PhaseRunning
|
||||
}
|
||||
|
||||
endpoint, err := r.endpoint(ctx, mc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
patch := client.MergeFrom(mc.DeepCopy())
|
||||
mc.Status.Phase = phase
|
||||
mc.Status.ReadyReplicas = ready
|
||||
mc.Status.Replicas = replicas
|
||||
mc.Status.Endpoint = endpoint
|
||||
mc.Status.ObservedGeneration = mc.Generation
|
||||
mc.Status.Message = ""
|
||||
setCondition(&mc.Status.Conditions, metav1.Condition{
|
||||
Type: v1alpha1.ConditionReady,
|
||||
Status: conditionStatus(ready > 0),
|
||||
Reason: phase,
|
||||
ObservedGeneration: mc.Generation,
|
||||
})
|
||||
|
||||
return r.Status().Patch(ctx, mc, patch)
|
||||
}
|
||||
|
||||
func (r *MinecraftServerReconciler) endpoint(ctx context.Context, mc *v1alpha1.MinecraftServer) (string, error) {
|
||||
var svc corev1.Service
|
||||
key := client.ObjectKey{Name: resources.ServerName(mc.Name), Namespace: mc.Namespace}
|
||||
if err := r.Get(ctx, key, &svc); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return serviceEndpoint(&svc, mc.Namespace), nil
|
||||
}
|
||||
|
||||
func (r *MinecraftServerReconciler) fail(ctx context.Context, mc *v1alpha1.MinecraftServer, reason string, cause error) (ctrl.Result, error) {
|
||||
patch := client.MergeFrom(mc.DeepCopy())
|
||||
mc.Status.Phase = v1alpha1.PhaseFailed
|
||||
mc.Status.Message = cause.Error()
|
||||
setCondition(&mc.Status.Conditions, metav1.Condition{
|
||||
Type: v1alpha1.ConditionReady,
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: reason,
|
||||
Message: cause.Error(),
|
||||
ObservedGeneration: mc.Generation,
|
||||
})
|
||||
|
||||
if err := r.Status().Patch(ctx, mc, patch); err != nil {
|
||||
return ctrl.Result{}, fmt.Errorf("%w (status patch failed: %v)", cause, err)
|
||||
}
|
||||
return ctrl.Result{}, cause
|
||||
}
|
||||
|
||||
func (r *MinecraftServerReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(&v1alpha1.MinecraftServer{}).
|
||||
Owns(&appsv1.Deployment{}).
|
||||
Owns(&appsv1.StatefulSet{}).
|
||||
Owns(&corev1.Service{}).
|
||||
Owns(&corev1.ConfigMap{}).
|
||||
Complete(r)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||
)
|
||||
|
||||
func setOwner(owner, obj client.Object, scheme *runtime.Scheme) error {
|
||||
return controllerutil.SetControllerReference(owner, obj, scheme)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/handler"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
|
||||
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
|
||||
"github.com/YuzuZensai/Minikura/operator/internal/resources"
|
||||
)
|
||||
|
||||
type ReverseProxyServerReconciler struct {
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
}
|
||||
|
||||
// +kubebuilder:rbac:groups=minikura.kirameki.cafe,resources=reverseproxyservers,verbs=get;list;watch;create;update;patch;delete
|
||||
// +kubebuilder:rbac:groups=minikura.kirameki.cafe,resources=reverseproxyservers/status,verbs=get;update;patch
|
||||
// +kubebuilder:rbac:groups=minikura.kirameki.cafe,resources=reverseproxyservers/finalizers,verbs=update
|
||||
|
||||
func (r *ReverseProxyServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
var rp v1alpha1.ReverseProxyServer
|
||||
if err := r.Get(ctx, req.NamespacedName, &rp); err != nil {
|
||||
return ctrl.Result{}, client.IgnoreNotFound(err)
|
||||
}
|
||||
|
||||
if !rp.DeletionTimestamp.IsZero() {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
if err := apply(ctx, r.Client, &rp, resources.ProxyConfigMap(&rp), r.Scheme); err != nil {
|
||||
return r.fail(ctx, &rp, "ConfigMapFailed", err)
|
||||
}
|
||||
|
||||
if err := apply(ctx, r.Client, &rp, resources.ProxyService(&rp), r.Scheme); err != nil {
|
||||
return r.fail(ctx, &rp, "ServiceFailed", err)
|
||||
}
|
||||
|
||||
if err := apply(ctx, r.Client, &rp, resources.ProxyDeployment(&rp), r.Scheme); err != nil {
|
||||
return r.fail(ctx, &rp, "DeploymentFailed", err)
|
||||
}
|
||||
|
||||
return ctrl.Result{}, r.updateStatus(ctx, &rp)
|
||||
}
|
||||
|
||||
func (r *ReverseProxyServerReconciler) backends(ctx context.Context, rp *v1alpha1.ReverseProxyServer) ([]string, error) {
|
||||
selector := labels.Everything()
|
||||
if rp.Spec.BackendSelector != nil {
|
||||
s, err := metav1.LabelSelectorAsSelector(rp.Spec.BackendSelector)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid backendSelector: %w", err)
|
||||
}
|
||||
selector = s
|
||||
}
|
||||
|
||||
var list v1alpha1.MinecraftServerList
|
||||
if err := r.List(ctx, &list,
|
||||
client.InNamespace(rp.Namespace),
|
||||
client.MatchingLabelsSelector{Selector: selector},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(list.Items))
|
||||
for _, mc := range list.Items {
|
||||
names = append(names, mc.Name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (r *ReverseProxyServerReconciler) updateStatus(ctx context.Context, rp *v1alpha1.ReverseProxyServer) error {
|
||||
name := resources.ProxyName(rp.Spec.Type, rp.Name)
|
||||
key := client.ObjectKey{Name: name, Namespace: rp.Namespace}
|
||||
|
||||
var ready, replicas int32
|
||||
var dep appsv1.Deployment
|
||||
if err := r.Get(ctx, key, &dep); err == nil {
|
||||
ready, replicas = dep.Status.ReadyReplicas, dep.Status.Replicas
|
||||
} else if !apierrors.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
backends, err := r.backends(ctx, rp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endpoint := ""
|
||||
var svc corev1.Service
|
||||
if err := r.Get(ctx, key, &svc); err == nil {
|
||||
endpoint = serviceEndpoint(&svc, rp.Namespace)
|
||||
} else if !apierrors.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
phase := v1alpha1.PhasePending
|
||||
if ready > 0 {
|
||||
phase = v1alpha1.PhaseRunning
|
||||
}
|
||||
|
||||
patch := client.MergeFrom(rp.DeepCopy())
|
||||
rp.Status.Phase = phase
|
||||
rp.Status.ReadyReplicas = ready
|
||||
rp.Status.Replicas = replicas
|
||||
rp.Status.Endpoint = endpoint
|
||||
rp.Status.Backends = backends
|
||||
rp.Status.ObservedGeneration = rp.Generation
|
||||
rp.Status.Message = ""
|
||||
setCondition(&rp.Status.Conditions, metav1.Condition{
|
||||
Type: v1alpha1.ConditionReady,
|
||||
Status: conditionStatus(ready > 0),
|
||||
Reason: phase,
|
||||
ObservedGeneration: rp.Generation,
|
||||
})
|
||||
|
||||
return r.Status().Patch(ctx, rp, patch)
|
||||
}
|
||||
|
||||
func (r *ReverseProxyServerReconciler) fail(ctx context.Context, rp *v1alpha1.ReverseProxyServer, reason string, cause error) (ctrl.Result, error) {
|
||||
patch := client.MergeFrom(rp.DeepCopy())
|
||||
rp.Status.Phase = v1alpha1.PhaseFailed
|
||||
rp.Status.Message = cause.Error()
|
||||
setCondition(&rp.Status.Conditions, metav1.Condition{
|
||||
Type: v1alpha1.ConditionReady,
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: reason,
|
||||
Message: cause.Error(),
|
||||
ObservedGeneration: rp.Generation,
|
||||
})
|
||||
|
||||
if err := r.Status().Patch(ctx, rp, patch); err != nil {
|
||||
return ctrl.Result{}, fmt.Errorf("%w (status patch failed: %v)", cause, err)
|
||||
}
|
||||
return ctrl.Result{}, cause
|
||||
}
|
||||
|
||||
func (r *ReverseProxyServerReconciler) proxiesForServer(ctx context.Context, obj client.Object) []reconcile.Request {
|
||||
var list v1alpha1.ReverseProxyServerList
|
||||
if err := r.List(ctx, &list, client.InNamespace(obj.GetNamespace())); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
reqs := make([]reconcile.Request, 0, len(list.Items))
|
||||
for _, rp := range list.Items {
|
||||
reqs = append(reqs, reconcile.Request{
|
||||
NamespacedName: client.ObjectKey{Name: rp.Name, Namespace: rp.Namespace},
|
||||
})
|
||||
}
|
||||
return reqs
|
||||
}
|
||||
|
||||
func (r *ReverseProxyServerReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(&v1alpha1.ReverseProxyServer{}).
|
||||
Owns(&appsv1.Deployment{}).
|
||||
Owns(&corev1.Service{}).
|
||||
Owns(&corev1.ConfigMap{}).
|
||||
Watches(&v1alpha1.MinecraftServer{}, handler.EnqueueRequestsFromMapFunc(r.proxiesForServer)).
|
||||
Complete(r)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func conditionStatus(ok bool) metav1.ConditionStatus {
|
||||
if ok {
|
||||
return metav1.ConditionTrue
|
||||
}
|
||||
return metav1.ConditionFalse
|
||||
}
|
||||
|
||||
func setCondition(conditions *[]metav1.Condition, c metav1.Condition) {
|
||||
if c.Reason == "" {
|
||||
c.Reason = "Unknown"
|
||||
}
|
||||
meta.SetStatusCondition(conditions, c)
|
||||
}
|
||||
|
||||
func serviceEndpoint(svc *corev1.Service, namespace string) string {
|
||||
internal := fmt.Sprintf("%s.%s.svc.cluster.local", svc.Name, namespace)
|
||||
port := int32(0)
|
||||
if len(svc.Spec.Ports) > 0 {
|
||||
port = svc.Spec.Ports[0].Port
|
||||
}
|
||||
|
||||
switch svc.Spec.Type {
|
||||
case corev1.ServiceTypeLoadBalancer:
|
||||
for _, ing := range svc.Status.LoadBalancer.Ingress {
|
||||
if host := ing.Hostname; host != "" {
|
||||
return fmt.Sprintf("%s:%d", host, port)
|
||||
}
|
||||
if ip := ing.IP; ip != "" {
|
||||
return fmt.Sprintf("%s:%d", ip, port)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
case corev1.ServiceTypeNodePort:
|
||||
if len(svc.Spec.Ports) > 0 && svc.Spec.Ports[0].NodePort != 0 {
|
||||
return fmt.Sprintf("<node-ip>:%d", svc.Spec.Ports[0].NodePort)
|
||||
}
|
||||
return internal
|
||||
|
||||
default:
|
||||
return fmt.Sprintf("%s:%d", internal, port)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package resources
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
|
||||
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
const (
|
||||
MinecraftImage = "itzg/minecraft-server"
|
||||
ContainerPort = 25565
|
||||
DefaultHeapPercent = 80
|
||||
)
|
||||
|
||||
func ServiceType(exposure v1alpha1.ServiceExposure, fallback corev1.ServiceType) corev1.ServiceType {
|
||||
switch exposure {
|
||||
case v1alpha1.ExposureClusterIP:
|
||||
return corev1.ServiceTypeClusterIP
|
||||
case v1alpha1.ExposureNodePort:
|
||||
return corev1.ServiceTypeNodePort
|
||||
case v1alpha1.ExposureLoadBalancer:
|
||||
return corev1.ServiceTypeLoadBalancer
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func HeapMB(limitMB int32, heapPercent int32) string {
|
||||
if heapPercent <= 0 || heapPercent > 100 {
|
||||
heapPercent = DefaultHeapPercent
|
||||
}
|
||||
heap := int64(limitMB) * int64(heapPercent) / 100
|
||||
if heap < 256 {
|
||||
heap = 256
|
||||
}
|
||||
return fmt.Sprintf("%dM", heap)
|
||||
}
|
||||
|
||||
func ResourceRequirements(r v1alpha1.Resources) corev1.ResourceRequirements {
|
||||
limitMB := r.MemoryLimitMB
|
||||
if limitMB <= 0 {
|
||||
limitMB = 2048
|
||||
}
|
||||
requestMB := r.MemoryRequestMB
|
||||
if requestMB <= 0 || requestMB > limitMB {
|
||||
requestMB = limitMB
|
||||
}
|
||||
|
||||
requests := corev1.ResourceList{
|
||||
corev1.ResourceMemory: resource.MustParse(fmt.Sprintf("%dMi", requestMB)),
|
||||
}
|
||||
limits := corev1.ResourceList{
|
||||
corev1.ResourceMemory: resource.MustParse(fmt.Sprintf("%dMi", limitMB)),
|
||||
}
|
||||
|
||||
if r.CPURequest != "" {
|
||||
if q, err := resource.ParseQuantity(r.CPURequest); err == nil {
|
||||
requests[corev1.ResourceCPU] = q
|
||||
}
|
||||
}
|
||||
if r.CPULimit != "" {
|
||||
if q, err := resource.ParseQuantity(r.CPULimit); err == nil {
|
||||
limits[corev1.ResourceCPU] = q
|
||||
}
|
||||
}
|
||||
|
||||
return corev1.ResourceRequirements{Requests: requests, Limits: limits}
|
||||
}
|
||||
|
||||
func JVMEnv(jvm v1alpha1.JVMOptions, limitMB int32) []corev1.EnvVar {
|
||||
env := []corev1.EnvVar{
|
||||
{Name: "MEMORY", Value: HeapMB(limitMB, jvm.HeapPercent)},
|
||||
}
|
||||
if jvm.Opts != "" {
|
||||
env = append(env, corev1.EnvVar{Name: "JVM_OPTS", Value: jvm.Opts})
|
||||
}
|
||||
if jvm.UseAikarFlags {
|
||||
env = append(env, corev1.EnvVar{Name: "USE_AIKAR_FLAGS", Value: "true"})
|
||||
}
|
||||
if jvm.UseMeowIceFlags {
|
||||
env = append(env, corev1.EnvVar{Name: "USE_MEOWICE_FLAGS", Value: "true"})
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func UserEnv(base []corev1.EnvVar, extra []v1alpha1.EnvVar) []corev1.EnvVar {
|
||||
for _, e := range extra {
|
||||
base = append(base, corev1.EnvVar{Name: e.Name, Value: e.Value})
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func TCPProbe(initialDelay int32) *corev1.Probe {
|
||||
return &corev1.Probe{
|
||||
ProbeHandler: corev1.ProbeHandler{
|
||||
TCPSocket: &corev1.TCPSocketAction{
|
||||
Port: intstr.FromInt32(ContainerPort),
|
||||
},
|
||||
},
|
||||
InitialDelaySeconds: initialDelay,
|
||||
PeriodSeconds: 10,
|
||||
}
|
||||
}
|
||||
|
||||
func ObjectMeta(name, namespace string, labels map[string]string) metav1.ObjectMeta {
|
||||
return metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Labels: labels,
|
||||
}
|
||||
}
|
||||
|
||||
func BoolValue(b *bool, fallback bool) bool {
|
||||
if b == nil {
|
||||
return fallback
|
||||
}
|
||||
return *b
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package resources
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
|
||||
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
func TestHeapMB(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
limitMB int32
|
||||
heapPercent int32
|
||||
want string
|
||||
}{
|
||||
{"default percent when unset", 2048, 0, "1638M"},
|
||||
{"explicit percent", 2048, 50, "1024M"},
|
||||
{"out of range falls back", 1024, 150, "819M"},
|
||||
{"floor applies to tiny limits", 128, 80, "256M"},
|
||||
{"full allocation", 1000, 100, "1000M"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := HeapMB(tt.limitMB, tt.heapPercent); got != tt.want {
|
||||
t.Errorf("HeapMB(%d, %d) = %q, want %q", tt.limitMB, tt.heapPercent, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceRequirements(t *testing.T) {
|
||||
t.Run("request defaults to limit", func(t *testing.T) {
|
||||
got := ResourceRequirements(v1alpha1.Resources{MemoryLimitMB: 2048})
|
||||
want := resource.MustParse("2048Mi")
|
||||
if got.Requests.Memory().Cmp(want) != 0 {
|
||||
t.Errorf("request memory = %v, want %v", got.Requests.Memory(), &want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("request above limit is clamped", func(t *testing.T) {
|
||||
got := ResourceRequirements(v1alpha1.Resources{MemoryLimitMB: 1024, MemoryRequestMB: 4096})
|
||||
want := resource.MustParse("1024Mi")
|
||||
if got.Requests.Memory().Cmp(want) != 0 {
|
||||
t.Errorf("request memory = %v, want %v", got.Requests.Memory(), &want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid cpu strings are dropped", func(t *testing.T) {
|
||||
got := ResourceRequirements(v1alpha1.Resources{
|
||||
MemoryLimitMB: 1024,
|
||||
CPURequest: "not-a-quantity",
|
||||
CPULimit: "500m",
|
||||
})
|
||||
if _, ok := got.Requests[corev1.ResourceCPU]; ok {
|
||||
t.Error("expected invalid cpu request to be omitted")
|
||||
}
|
||||
if got.Limits.Cpu().String() != "500m" {
|
||||
t.Errorf("cpu limit = %v, want 500m", got.Limits.Cpu())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceType(t *testing.T) {
|
||||
if got := ServiceType("", corev1.ServiceTypeLoadBalancer); got != corev1.ServiceTypeLoadBalancer {
|
||||
t.Errorf("empty exposure = %v, want fallback LoadBalancer", got)
|
||||
}
|
||||
if got := ServiceType(v1alpha1.ExposureNodePort, corev1.ServiceTypeClusterIP); got != corev1.ServiceTypeNodePort {
|
||||
t.Errorf("NodePort exposure = %v, want NodePort", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEnvOverridesDefaults(t *testing.T) {
|
||||
base := []corev1.EnvVar{{Name: "TYPE", Value: "VANILLA"}}
|
||||
got := UserEnv(base, []v1alpha1.EnvVar{{Name: "TYPE", Value: "PAPER"}})
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
if got[len(got)-1].Value != "PAPER" {
|
||||
t.Errorf("last TYPE = %q, want PAPER", got[len(got)-1].Value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package resources
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
|
||||
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
func MinecraftConfigMap(mc *v1alpha1.MinecraftServer) *corev1.ConfigMap {
|
||||
name := ServerName(mc.Name)
|
||||
return &corev1.ConfigMap{
|
||||
ObjectMeta: ObjectMeta(ConfigMapName(name), mc.Namespace, ServerLabels(mc)),
|
||||
Data: map[string]string{
|
||||
"server-type": string(mc.Spec.Type),
|
||||
"minecraft-version": mc.Spec.MinecraftVersion,
|
||||
"jar-type": string(mc.Spec.JarType),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func MinecraftService(mc *v1alpha1.MinecraftServer) *corev1.Service {
|
||||
name := ServerName(mc.Name)
|
||||
port := corev1.ServicePort{
|
||||
Name: "minecraft",
|
||||
Port: mc.Spec.ListenPort,
|
||||
TargetPort: intstr.FromInt32(ContainerPort),
|
||||
Protocol: corev1.ProtocolTCP,
|
||||
}
|
||||
|
||||
svcType := ServiceType(mc.Spec.ServiceType, corev1.ServiceTypeClusterIP)
|
||||
if svcType == corev1.ServiceTypeNodePort && mc.Spec.NodePort != 0 {
|
||||
port.NodePort = mc.Spec.NodePort
|
||||
}
|
||||
|
||||
return &corev1.Service{
|
||||
ObjectMeta: ObjectMeta(name, mc.Namespace, ServerLabels(mc)),
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: SelectorLabels(name),
|
||||
Ports: []corev1.ServicePort{port},
|
||||
Type: svcType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func minecraftEnv(mc *v1alpha1.MinecraftServer) []corev1.EnvVar {
|
||||
p := mc.Spec.Properties
|
||||
|
||||
env := []corev1.EnvVar{
|
||||
{Name: "EULA", Value: "TRUE"},
|
||||
{Name: "TYPE", Value: string(mc.Spec.JarType)},
|
||||
{Name: "VERSION", Value: mc.Spec.MinecraftVersion},
|
||||
{Name: "OVERRIDE_SERVER_PROPERTIES", Value: "true"},
|
||||
{Name: "ENABLE_RCON", Value: "false"},
|
||||
{Name: "DIFFICULTY", Value: string(p.Difficulty)},
|
||||
{Name: "MODE", Value: string(p.GameMode)},
|
||||
{Name: "MAX_PLAYERS", Value: strconv.Itoa(int(p.MaxPlayers))},
|
||||
{Name: "PVP", Value: strconv.FormatBool(BoolValue(p.PVP, true))},
|
||||
{Name: "ONLINE_MODE", Value: strconv.FormatBool(BoolValue(p.OnlineMode, true))},
|
||||
}
|
||||
|
||||
if p.MOTD != "" {
|
||||
env = append(env, corev1.EnvVar{Name: "MOTD", Value: p.MOTD})
|
||||
}
|
||||
if p.LevelSeed != "" {
|
||||
env = append(env, corev1.EnvVar{Name: "SEED", Value: p.LevelSeed})
|
||||
}
|
||||
if p.LevelType != "" {
|
||||
env = append(env, corev1.EnvVar{Name: "LEVEL_TYPE", Value: p.LevelType})
|
||||
}
|
||||
|
||||
env = append(env, JVMEnv(mc.Spec.JVM, mc.Spec.Resources.MemoryLimitMB)...)
|
||||
|
||||
if mc.Spec.APIKeySecretRef != "" {
|
||||
env = append(env, corev1.EnvVar{
|
||||
Name: "MINIKURA_API_KEY",
|
||||
ValueFrom: &corev1.EnvVarSource{
|
||||
SecretKeyRef: &corev1.SecretKeySelector{
|
||||
LocalObjectReference: corev1.LocalObjectReference{Name: mc.Spec.APIKeySecretRef},
|
||||
Key: "api-key",
|
||||
Optional: ptr(true),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return UserEnv(env, mc.Spec.Env)
|
||||
}
|
||||
|
||||
func minecraftPodSpec(mc *v1alpha1.MinecraftServer, stateful bool) corev1.PodSpec {
|
||||
name := ServerName(mc.Name)
|
||||
|
||||
mounts := []corev1.VolumeMount{{Name: "config", MountPath: "/config"}}
|
||||
volumes := []corev1.Volume{{
|
||||
Name: "config",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
ConfigMap: &corev1.ConfigMapVolumeSource{
|
||||
LocalObjectReference: corev1.LocalObjectReference{Name: ConfigMapName(name)},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
initialDelay := int32(30)
|
||||
if stateful {
|
||||
mounts = append(mounts, corev1.VolumeMount{Name: "data", MountPath: "/data"})
|
||||
initialDelay = 60
|
||||
}
|
||||
|
||||
return corev1.PodSpec{
|
||||
Containers: []corev1.Container{{
|
||||
Name: "minecraft",
|
||||
Image: MinecraftImage,
|
||||
Ports: []corev1.ContainerPort{{
|
||||
Name: "minecraft",
|
||||
ContainerPort: ContainerPort,
|
||||
}},
|
||||
Env: minecraftEnv(mc),
|
||||
VolumeMounts: mounts,
|
||||
ReadinessProbe: TCPProbe(initialDelay),
|
||||
Resources: ResourceRequirements(mc.Spec.Resources),
|
||||
}},
|
||||
Volumes: volumes,
|
||||
}
|
||||
}
|
||||
|
||||
func MinecraftDeployment(mc *v1alpha1.MinecraftServer) *appsv1.Deployment {
|
||||
name := ServerName(mc.Name)
|
||||
labels := ServerLabels(mc)
|
||||
|
||||
return &appsv1.Deployment{
|
||||
ObjectMeta: ObjectMeta(name, mc.Namespace, labels),
|
||||
Spec: appsv1.DeploymentSpec{
|
||||
Replicas: ptr(int32(1)),
|
||||
Selector: &metav1.LabelSelector{MatchLabels: SelectorLabels(name)},
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{Labels: labels},
|
||||
Spec: minecraftPodSpec(mc, false),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func MinecraftStatefulSet(mc *v1alpha1.MinecraftServer) (*appsv1.StatefulSet, error) {
|
||||
name := ServerName(mc.Name)
|
||||
labels := ServerLabels(mc)
|
||||
|
||||
size := mc.Spec.StorageSize
|
||||
if size == "" {
|
||||
size = "1Gi"
|
||||
}
|
||||
qty, err := resource.ParseQuantity(size)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid storageSize %q: %w", size, err)
|
||||
}
|
||||
|
||||
return &appsv1.StatefulSet{
|
||||
ObjectMeta: ObjectMeta(name, mc.Namespace, labels),
|
||||
Spec: appsv1.StatefulSetSpec{
|
||||
ServiceName: name,
|
||||
Replicas: ptr(int32(1)),
|
||||
Selector: &metav1.LabelSelector{MatchLabels: SelectorLabels(name)},
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{Labels: labels},
|
||||
Spec: minecraftPodSpec(mc, true),
|
||||
},
|
||||
VolumeClaimTemplates: []corev1.PersistentVolumeClaim{{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "data"},
|
||||
Spec: corev1.PersistentVolumeClaimSpec{
|
||||
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
|
||||
Resources: corev1.VolumeResourceRequirements{
|
||||
Requests: corev1.ResourceList{corev1.ResourceStorage: qty},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
@@ -0,0 +1,154 @@
|
||||
package resources
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
func testServer() *v1alpha1.MinecraftServer {
|
||||
return &v1alpha1.MinecraftServer{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "smp", Namespace: "minikura"},
|
||||
Spec: v1alpha1.MinecraftServerSpec{
|
||||
Type: v1alpha1.ServerStateful,
|
||||
ListenPort: 25565,
|
||||
ServiceType: v1alpha1.ExposureClusterIP,
|
||||
JarType: "PAPER",
|
||||
MinecraftVersion: "1.20.4",
|
||||
StorageSize: "10Gi",
|
||||
Resources: v1alpha1.Resources{MemoryLimitMB: 4096},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func envValue(env []corev1.EnvVar, name string) (string, bool) {
|
||||
for i := len(env) - 1; i >= 0; i-- {
|
||||
if env[i].Name == name {
|
||||
return env[i].Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestMinecraftEnvCarriesSpecConfig(t *testing.T) {
|
||||
mc := testServer()
|
||||
mc.Spec.Properties = v1alpha1.MinecraftProperties{
|
||||
Difficulty: "HARD",
|
||||
GameMode: "SURVIVAL",
|
||||
MaxPlayers: 50,
|
||||
MOTD: "welcome",
|
||||
}
|
||||
|
||||
env := minecraftEnv(mc)
|
||||
|
||||
for _, want := range []struct{ key, value string }{
|
||||
{"TYPE", "PAPER"},
|
||||
{"VERSION", "1.20.4"},
|
||||
{"DIFFICULTY", "HARD"},
|
||||
{"MAX_PLAYERS", "50"},
|
||||
{"MOTD", "welcome"},
|
||||
} {
|
||||
got, ok := envValue(env, want.key)
|
||||
if !ok {
|
||||
t.Errorf("%s missing from env", want.key)
|
||||
continue
|
||||
}
|
||||
if got != want.value {
|
||||
t.Errorf("%s = %q, want %q", want.key, got, want.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalPropertiesOmitted(t *testing.T) {
|
||||
env := minecraftEnv(testServer())
|
||||
for _, key := range []string{"MOTD", "SEED", "LEVEL_TYPE"} {
|
||||
if _, ok := envValue(env, key); ok {
|
||||
t.Errorf("%s should be omitted when unset", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceNodePortPinned(t *testing.T) {
|
||||
mc := testServer()
|
||||
mc.Spec.ServiceType = v1alpha1.ExposureNodePort
|
||||
mc.Spec.NodePort = 30123
|
||||
|
||||
svc := MinecraftService(mc)
|
||||
if svc.Spec.Type != corev1.ServiceTypeNodePort {
|
||||
t.Fatalf("type = %v, want NodePort", svc.Spec.Type)
|
||||
}
|
||||
if svc.Spec.Ports[0].NodePort != 30123 {
|
||||
t.Errorf("nodePort = %d, want 30123", svc.Spec.Ports[0].NodePort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceNodePortUnsetLeavesAllocation(t *testing.T) {
|
||||
mc := testServer()
|
||||
mc.Spec.ServiceType = v1alpha1.ExposureNodePort
|
||||
|
||||
svc := MinecraftService(mc)
|
||||
if svc.Spec.Ports[0].NodePort != 0 {
|
||||
t.Errorf("nodePort = %d, want 0 so the API server allocates one", svc.Spec.Ports[0].NodePort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectorExcludesMutableLabels(t *testing.T) {
|
||||
mc := testServer()
|
||||
sts, err := MinecraftStatefulSet(mc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
selector := sts.Spec.Selector.MatchLabels
|
||||
if _, ok := selector[v1alpha1.LabelServerType]; ok {
|
||||
t.Error("selector must not include the mutable server-type label")
|
||||
}
|
||||
if selector["app"] != ServerName(mc.Name) {
|
||||
t.Errorf("selector app = %q, want %q", selector["app"], ServerName(mc.Name))
|
||||
}
|
||||
|
||||
for k, v := range selector {
|
||||
if sts.Spec.Template.Labels[k] != v {
|
||||
t.Errorf("pod template missing selector label %s=%s", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatefulSetStorage(t *testing.T) {
|
||||
sts, err := MinecraftStatefulSet(testServer())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
claims := sts.Spec.VolumeClaimTemplates
|
||||
if len(claims) != 1 {
|
||||
t.Fatalf("volumeClaimTemplates = %d, want 1", len(claims))
|
||||
}
|
||||
if got := claims[0].Spec.Resources.Requests.Storage().String(); got != "10Gi" {
|
||||
t.Errorf("storage = %s, want 10Gi", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatefulSetRejectsBadStorageSize(t *testing.T) {
|
||||
mc := testServer()
|
||||
mc.Spec.StorageSize = "ten-gigs"
|
||||
|
||||
if _, err := MinecraftStatefulSet(mc); err == nil {
|
||||
t.Error("expected an error for an unparseable storageSize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatelessHasNoDataVolume(t *testing.T) {
|
||||
mc := testServer()
|
||||
mc.Spec.Type = v1alpha1.ServerStateless
|
||||
|
||||
dep := MinecraftDeployment(mc)
|
||||
for _, m := range dep.Spec.Template.Spec.Containers[0].VolumeMounts {
|
||||
if m.Name == "data" {
|
||||
t.Error("stateless server should not mount a data volume")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package resources
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
func ServerName(name string) string { return fmt.Sprintf("minecraft-%s", name) }
|
||||
func ProxyName(kind v1alpha1.ProxyKind, name string) string {
|
||||
return fmt.Sprintf("%s-%s", strings.ToLower(string(kind)), name)
|
||||
}
|
||||
func ConfigMapName(base string) string { return base + "-config" }
|
||||
|
||||
func ServerLabels(mc *v1alpha1.MinecraftServer) map[string]string {
|
||||
return map[string]string{
|
||||
"app": ServerName(mc.Name),
|
||||
v1alpha1.LabelServerType: strings.ToLower(string(mc.Spec.Type)),
|
||||
v1alpha1.LabelServerID: mc.Name,
|
||||
v1alpha1.LabelManagedBy: v1alpha1.ManagerName,
|
||||
}
|
||||
}
|
||||
|
||||
func ProxyLabels(rp *v1alpha1.ReverseProxyServer) map[string]string {
|
||||
return map[string]string{
|
||||
"app": ProxyName(rp.Spec.Type, rp.Name),
|
||||
v1alpha1.LabelServerType: strings.ToLower(string(rp.Spec.Type)),
|
||||
v1alpha1.LabelProxyID: rp.Name,
|
||||
v1alpha1.LabelManagedBy: v1alpha1.ManagerName,
|
||||
}
|
||||
}
|
||||
|
||||
func SelectorLabels(app string) map[string]string {
|
||||
return map[string]string{"app": app}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package resources
|
||||
|
||||
import (
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
|
||||
v1alpha1 "github.com/YuzuZensai/Minikura/operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
func ProxyConfigMap(rp *v1alpha1.ReverseProxyServer) *corev1.ConfigMap {
|
||||
name := ProxyName(rp.Spec.Type, rp.Name)
|
||||
return &corev1.ConfigMap{
|
||||
ObjectMeta: ObjectMeta(ConfigMapName(name), rp.Namespace, ProxyLabels(rp)),
|
||||
Data: map[string]string{
|
||||
"proxy-type": string(rp.Spec.Type),
|
||||
"external-address": rp.Spec.ExternalAddress,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ProxyService(rp *v1alpha1.ReverseProxyServer) *corev1.Service {
|
||||
name := ProxyName(rp.Spec.Type, rp.Name)
|
||||
port := corev1.ServicePort{
|
||||
Name: "minecraft",
|
||||
Port: rp.Spec.ExternalPort,
|
||||
TargetPort: intstr.FromInt32(rp.Spec.ListenPort),
|
||||
Protocol: corev1.ProtocolTCP,
|
||||
}
|
||||
|
||||
svcType := ServiceType(rp.Spec.ServiceType, corev1.ServiceTypeLoadBalancer)
|
||||
if svcType == corev1.ServiceTypeNodePort && rp.Spec.NodePort != 0 {
|
||||
port.NodePort = rp.Spec.NodePort
|
||||
}
|
||||
|
||||
return &corev1.Service{
|
||||
ObjectMeta: ObjectMeta(name, rp.Namespace, ProxyLabels(rp)),
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: SelectorLabels(name),
|
||||
Ports: []corev1.ServicePort{port},
|
||||
Type: svcType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func proxyEnv(rp *v1alpha1.ReverseProxyServer) []corev1.EnvVar {
|
||||
env := []corev1.EnvVar{
|
||||
{Name: "EULA", Value: "TRUE"},
|
||||
{Name: "TYPE", Value: string(rp.Spec.Type)},
|
||||
{Name: "MINIKURA_EXTERNAL_ADDRESS", Value: rp.Spec.ExternalAddress},
|
||||
}
|
||||
|
||||
env = append(env, JVMEnv(rp.Spec.JVM, rp.Spec.Resources.MemoryLimitMB)...)
|
||||
|
||||
if rp.Spec.APIKeySecretRef != "" {
|
||||
env = append(env, corev1.EnvVar{
|
||||
Name: "MINIKURA_API_KEY",
|
||||
ValueFrom: &corev1.EnvVarSource{
|
||||
SecretKeyRef: &corev1.SecretKeySelector{
|
||||
LocalObjectReference: corev1.LocalObjectReference{Name: rp.Spec.APIKeySecretRef},
|
||||
Key: "api-key",
|
||||
Optional: ptr(true),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return UserEnv(env, rp.Spec.Env)
|
||||
}
|
||||
|
||||
func ProxyDeployment(rp *v1alpha1.ReverseProxyServer) *appsv1.Deployment {
|
||||
name := ProxyName(rp.Spec.Type, rp.Name)
|
||||
labels := ProxyLabels(rp)
|
||||
|
||||
return &appsv1.Deployment{
|
||||
ObjectMeta: ObjectMeta(name, rp.Namespace, labels),
|
||||
Spec: appsv1.DeploymentSpec{
|
||||
Replicas: ptr(int32(1)),
|
||||
Selector: &metav1.LabelSelector{MatchLabels: SelectorLabels(name)},
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{Labels: labels},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{{
|
||||
Name: "proxy",
|
||||
Image: MinecraftImage,
|
||||
Ports: []corev1.ContainerPort{{
|
||||
Name: "minecraft",
|
||||
ContainerPort: rp.Spec.ListenPort,
|
||||
}},
|
||||
Env: proxyEnv(rp),
|
||||
VolumeMounts: []corev1.VolumeMount{
|
||||
{Name: "config", MountPath: "/config"},
|
||||
},
|
||||
ReadinessProbe: &corev1.Probe{
|
||||
ProbeHandler: corev1.ProbeHandler{
|
||||
TCPSocket: &corev1.TCPSocketAction{
|
||||
Port: intstr.FromInt32(rp.Spec.ListenPort),
|
||||
},
|
||||
},
|
||||
InitialDelaySeconds: 30,
|
||||
PeriodSeconds: 10,
|
||||
},
|
||||
Resources: ResourceRequirements(rp.Spec.Resources),
|
||||
}},
|
||||
Volumes: []corev1.Volume{{
|
||||
Name: "config",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
ConfigMap: &corev1.ConfigMapVolumeSource{
|
||||
LocalObjectReference: corev1.LocalObjectReference{
|
||||
Name: ConfigMapName(name),
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user