Unverified Commit 78b8cf6d authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

feat(operator): add MinecraftServer controller and reconcilers

The Kubernetes controller that drives MinecraftServer resources through their lifecycle and issues RCON where readiness requires it.
parent 43ab9215
Loading
Loading
Loading
Loading
+287 −0
Changes for internal/operator/builders.go: 287 added lines, 0 removed lines.
Original line number Diff line number Diff line
package operator

import (
	"fmt"
	"strconv"

	"felis.lolicon.best/internal/apis/felis/v1alpha1"
	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"
)

// Workload constants shared by the builders.
const (
	// GamePort is the Minecraft TCP port the proxy and readiness probe target.
	GamePort int32 = 25565

	// DefaultRconPort is the RCON port used when a server does not override
	// spec.rcon.port (see rconPort). It is exported because the platform package's
	// allow-rcon NetworkPolicy opens this port for the control plane — sharing the
	// constant keeps the policy port and the container's default RCON port a single
	// source of truth, so the operator's prober can always reach a default-port
	// server through the fence.
	DefaultRconPort int32 = 25575

	containerName  = "minecraft"
	dataVolumeName = "world"
	dataMountPath  = "/data"

	// ManagedByValue / ComponentValue are the values of the LabelManagedBy /
	// LabelComponent labels stamped on every per-server pod (see labelsFor). They
	// are exported because the platform package's minecraft-namespace
	// NetworkPolicies select server pods by exactly these labels — keeping the
	// selector and the pod labels a single source of truth, so an isolation policy
	// can never silently stop matching the pods it is meant to fence.
	ManagedByValue = "felis-operator"
	ComponentValue = "server"

	defaultGraceSeconds int64  = 300
	defaultStorageSize  string = "8Gi"
)

// selectorFor returns the immutable selector labels (a StatefulSet selector
// must never change after creation, so it carries only the server identity).
func selectorFor(server *v1alpha1.MinecraftServer) map[string]string {
	return map[string]string{v1alpha1.LabelServer: server.Name}
}

// labelsFor returns the full label set applied to managed objects.
func labelsFor(server *v1alpha1.MinecraftServer) map[string]string {
	return map[string]string{
		v1alpha1.LabelServer:    server.Name,
		v1alpha1.LabelManagedBy: ManagedByValue,
		v1alpha1.LabelComponent: ComponentValue,
	}
}

func headlessServiceName(name string) string { return name + "-hl" }

// rconPort resolves the RCON port, defaulting to the conventional DefaultRconPort.
func rconPort(server *v1alpha1.MinecraftServer) int32 {
	if server.Spec.Rcon.Port > 0 {
		return server.Spec.Rcon.Port
	}
	return DefaultRconPort
}

// graceSeconds resolves the pod termination grace period (spec §7).
func graceSeconds(server *v1alpha1.MinecraftServer) int64 {
	if server.Spec.Lifecycle.TerminationGracePeriodSeconds > 0 {
		return server.Spec.Lifecycle.TerminationGracePeriodSeconds
	}
	return defaultGraceSeconds
}

// rconAddress is the in-cluster RCON endpoint the operator probes for readiness.
func rconAddress(server *v1alpha1.MinecraftServer) string {
	return fmt.Sprintf("%s.%s.svc.cluster.local:%d", server.Name, server.Namespace, rconPort(server))
}

// gameAddress is the in-cluster game endpoint advertised when Running.
func gameAddress(server *v1alpha1.MinecraftServer) string {
	return fmt.Sprintf("%s.%s.svc.cluster.local:%d", server.Name, server.Namespace, GamePort)
}

// preStopScript is the operator-injected graceful-shutdown sequence (spec §7):
// flush the world, then stop the server, both over RCON. It relies on rcon-cli
// being present in the Felis base image and reading the RCON_* env injected
// alongside it.
func preStopScript(server *v1alpha1.MinecraftServer) string {
	port := rconPort(server)
	return fmt.Sprintf(
		`rcon-cli --port %d --password "$RCON_PASSWORD" save-all flush; rcon-cli --port %d --password "$RCON_PASSWORD" stop`,
		port, port,
	)
}

// buildHeadlessService backs the StatefulSet's stable network identity.
func buildHeadlessService(server *v1alpha1.MinecraftServer) *corev1.Service {
	svc := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:      headlessServiceName(server.Name),
			Namespace: server.Namespace,
			Labels:    labelsFor(server),
		},
		Spec: corev1.ServiceSpec{
			ClusterIP: corev1.ClusterIPNone,
			Selector:  selectorFor(server),
			Ports:     servicePorts(server),
		},
	}
	return svc
}

// buildClientService is the stable ClusterIP the proxy and operator dial.
func buildClientService(server *v1alpha1.MinecraftServer) *corev1.Service {
	return &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:      server.Name,
			Namespace: server.Namespace,
			Labels:    labelsFor(server),
		},
		Spec: corev1.ServiceSpec{
			Selector: selectorFor(server),
			Ports:    servicePorts(server),
		},
	}
}

func servicePorts(server *v1alpha1.MinecraftServer) []corev1.ServicePort {
	ports := []corev1.ServicePort{{
		Name:       "game",
		Port:       GamePort,
		TargetPort: intstr.FromInt32(GamePort),
		Protocol:   corev1.ProtocolTCP,
	}}
	if server.Spec.Rcon.Enabled {
		p := rconPort(server)
		ports = append(ports, corev1.ServicePort{
			Name:       "rcon",
			Port:       p,
			TargetPort: intstr.FromInt32(p),
			Protocol:   corev1.ProtocolTCP,
		})
	}
	return ports
}

// buildStatefulSet renders the workload for replicas in {0,1}. It is where
// graceful shutdown is injected: the pod gets terminationGracePeriodSeconds and
// (when enabled) a preStop RCON save+stop hook.
func buildStatefulSet(server *v1alpha1.MinecraftServer, replicas int32) (*appsv1.StatefulSet, error) {
	storageSize := server.Spec.Storage.Size
	if storageSize == "" {
		storageSize = defaultStorageSize
	}
	storageQty, err := resource.ParseQuantity(storageSize)
	if err != nil {
		return nil, fmt.Errorf("invalid storage size %q: %w", storageSize, err)
	}

	container := corev1.Container{
		Name:      containerName,
		Image:     server.Spec.Image,
		Resources: server.Spec.Resources,
		Env:       buildEnv(server),
		Ports: []corev1.ContainerPort{
			{Name: "game", ContainerPort: GamePort, Protocol: corev1.ProtocolTCP},
		},
		VolumeMounts: []corev1.VolumeMount{
			{Name: dataVolumeName, MountPath: dataMountPath},
		},
		// Readiness here is a plain TCP check (spec §5: readinessProbe is only
		// tcpSocket; the RCON gate is enforced by the operator, not the kubelet).
		ReadinessProbe: &corev1.Probe{
			ProbeHandler: corev1.ProbeHandler{
				TCPSocket: &corev1.TCPSocketAction{Port: intstr.FromInt32(GamePort)},
			},
			InitialDelaySeconds: 20,
			PeriodSeconds:       10,
			FailureThreshold:    6,
		},
	}
	if len(server.Spec.Args) > 0 {
		container.Args = append([]string(nil), server.Spec.Args...)
	}
	if server.Spec.Rcon.Enabled {
		container.Ports = append(container.Ports, corev1.ContainerPort{
			Name: "rcon", ContainerPort: rconPort(server), Protocol: corev1.ProtocolTCP,
		})
		if server.Spec.Lifecycle.PreStopSaveAndStop {
			container.Lifecycle = &corev1.Lifecycle{
				PreStop: &corev1.LifecycleHandler{
					Exec: &corev1.ExecAction{
						Command: []string{"/bin/sh", "-c", preStopScript(server)},
					},
				},
			}
		}
	}

	grace := graceSeconds(server)
	pvc := corev1.PersistentVolumeClaim{
		ObjectMeta: metav1.ObjectMeta{Name: dataVolumeName},
		Spec: corev1.PersistentVolumeClaimSpec{
			AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
			Resources: corev1.VolumeResourceRequirements{
				Requests: corev1.ResourceList{corev1.ResourceStorage: storageQty},
			},
		},
	}
	if sc := server.Spec.Storage.StorageClassName; sc != "" {
		pvc.Spec.StorageClassName = &sc
	}

	sts := &appsv1.StatefulSet{
		ObjectMeta: metav1.ObjectMeta{
			Name:      server.Name,
			Namespace: server.Namespace,
			Labels:    labelsFor(server),
		},
		Spec: appsv1.StatefulSetSpec{
			Replicas:    &replicas,
			ServiceName: headlessServiceName(server.Name),
			Selector:    &metav1.LabelSelector{MatchLabels: selectorFor(server)},
			Template: corev1.PodTemplateSpec{
				ObjectMeta: metav1.ObjectMeta{Labels: labelsFor(server)},
				Spec: corev1.PodSpec{
					TerminationGracePeriodSeconds: &grace,
					Containers:                    []corev1.Container{container},
					// A Minecraft server runs untrusted user worlds and plugins and
					// has no business calling the K8s API, so its pod must NOT carry the
					// default ServiceAccount token: a compromised plugin could otherwise
					// authenticate as the namespace default SA (spec §21: user servers
					// default to no SA-token mount). The pod keeps the default SA but
					// with automounting explicitly disabled.
					AutomountServiceAccountToken: boolPtr(false),
				},
			},
			VolumeClaimTemplates: []corev1.PersistentVolumeClaim{pvc},
		},
	}
	return sts, nil
}

// buildEnv assembles the container environment: heap sizing, user-supplied
// vars, and the RCON_* pair (password sourced from the referenced Secret, never
// inlined into the CRD).
func buildEnv(server *v1alpha1.MinecraftServer) []corev1.EnvVar {
	var env []corev1.EnvVar
	if mem := server.Spec.JavaMemory; mem != "" {
		env = append(env, corev1.EnvVar{Name: "JAVA_MEMORY", Value: mem})
	}
	if len(server.Spec.JavaFlags) > 0 {
		env = append(env, corev1.EnvVar{Name: "JAVA_FLAGS", Value: joinFlags(server.Spec.JavaFlags)})
	}
	for _, e := range server.Spec.Env {
		env = append(env, corev1.EnvVar{Name: e.Name, Value: e.Value})
	}
	if server.Spec.Rcon.Enabled && server.Spec.Rcon.SecretRef.Name != "" {
		env = append(env,
			corev1.EnvVar{Name: "RCON_PORT", Value: strconv.Itoa(int(rconPort(server)))},
			corev1.EnvVar{Name: "RCON_PASSWORD", ValueFrom: &corev1.EnvVarSource{
				SecretKeyRef: &corev1.SecretKeySelector{
					LocalObjectReference: corev1.LocalObjectReference{Name: server.Spec.Rcon.SecretRef.Name},
					Key:                  server.Spec.Rcon.SecretRef.Key,
				},
			}},
		)
	}
	return env
}

func boolPtr(b bool) *bool { return &b }

func joinFlags(flags []string) string {
	out := ""
	for i, f := range flags {
		if i > 0 {
			out += " "
		}
		out += f
	}
	return out
}
+41 −0
Changes for internal/operator/prober.go: 41 added lines, 0 removed lines.
Original line number Diff line number Diff line
package operator

import (
	"context"
	"time"

	"felis.lolicon.best/internal/rcon"
)

// Prober reports whether a server's RCON endpoint is reachable and accepts the
// password. A nil error is the loader-agnostic readiness gate (spec §5). It is
// an interface so the reconciler can be tested without a live server.
type Prober interface {
	Probe(ctx context.Context, addr, password string) error
}

// RconProber is the production Prober: a successful Dial (TCP connect + auth)
// is sufficient; the connection is closed immediately.
type RconProber struct {
	// Timeout bounds a single probe. Defaults to 5s.
	Timeout time.Duration
}

// Probe dials addr and authenticates with password, honoring the smaller of the
// configured timeout and any deadline already on ctx.
func (p RconProber) Probe(ctx context.Context, addr, password string) error {
	timeout := p.Timeout
	if timeout <= 0 {
		timeout = 5 * time.Second
	}
	if dl, ok := ctx.Deadline(); ok {
		if remaining := time.Until(dl); remaining > 0 && remaining < timeout {
			timeout = remaining
		}
	}
	conn, err := rcon.Dial(addr, password, timeout)
	if err != nil {
		return err
	}
	return conn.Close()
}
+296 −0
Changes for internal/operator/reconciler.go: 296 added lines, 0 removed lines.
Original line number Diff line number Diff line
// Package operator reconciles MinecraftServer objects (spec §4, §5, §7). The
// CRD is the lifecycle source-of-truth; this controller renders the
// StatefulSet/Service/PVC from it, gates readiness on an RCON probe, and injects
// graceful shutdown. It never reads or writes business-layer (Postgres) fields.
package operator

import (
	"context"
	"fmt"
	"time"

	"felis.lolicon.best/internal/apis/felis/v1alpha1"
	appsv1 "k8s.io/api/apps/v1"
	corev1 "k8s.io/api/core/v1"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	"k8s.io/apimachinery/pkg/api/meta"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/types"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)

// Requeue cadences for the transient phases.
const (
	requeueStarting = 5 * time.Second
	requeueStopping = 5 * time.Second
	requeueSecret   = 10 * time.Second
)

// Reconciler reconciles a MinecraftServer with its managed children.
type Reconciler struct {
	client.Client
	Scheme *runtime.Scheme
	// Prober gates readiness on RCON reachability.
	Prober Prober
	// Now is injectable for deterministic timestamps in tests; defaults to
	// metav1.Now.
	Now func() metav1.Time
}

func (r *Reconciler) now() metav1.Time {
	if r.Now != nil {
		return r.Now()
	}
	return metav1.Now()
}

// SetupWithManager wires the controller to watch MinecraftServers and the
// children it owns.
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&v1alpha1.MinecraftServer{}).
		Owns(&appsv1.StatefulSet{}).
		Owns(&corev1.Service{}).
		Complete(r)
}

// Reconcile drives a single MinecraftServer toward spec.desiredState.
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var server v1alpha1.MinecraftServer
	if err := r.Get(ctx, req.NamespacedName, &server); err != nil {
		// Deletion is handled by owner references on the children.
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	desired := server.Spec.DesiredState
	if desired == "" {
		desired = v1alpha1.DesiredStopped
	}
	if desired == v1alpha1.DesiredStopped {
		return r.reconcileStopped(ctx, &server)
	}
	return r.reconcileRunning(ctx, &server)
}

func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.MinecraftServer) (ctrl.Result, error) {
	if err := r.ensureServices(ctx, server); err != nil {
		return ctrl.Result{}, err
	}

	desired, err := buildStatefulSet(server, 1)
	if err != nil {
		// A malformed spec (e.g. bad storage quantity) is terminal until edited.
		r.markFailed(server, "InvalidSpec", err.Error())
		return ctrl.Result{}, r.patchStatus(ctx, server)
	}
	if err := controllerutil.SetControllerReference(server, desired, r.Scheme); err != nil {
		return ctrl.Result{}, err
	}
	if err := r.applyStatefulSet(ctx, desired); err != nil {
		return ctrl.Result{}, err
	}

	var current appsv1.StatefulSet
	if err := r.Get(ctx, client.ObjectKeyFromObject(desired), &current); err != nil {
		return ctrl.Result{}, err
	}

	// The pod must first pass its tcpSocket readiness (readyReplicas >= 1).
	if current.Status.ReadyReplicas < 1 {
		r.markStarting(server, "PodNotReady", "waiting for pod TCP readiness")
		if err := r.patchStatus(ctx, server); err != nil {
			return ctrl.Result{}, err
		}
		return ctrl.Result{RequeueAfter: requeueStarting}, nil
	}

	// Then the operator gates true readiness on an RCON probe (spec §5).
	if server.Spec.Rcon.Enabled {
		password, err := r.rconPassword(ctx, server)
		if err != nil {
			r.markStarting(server, "RconSecretUnavailable", err.Error())
			if perr := r.patchStatus(ctx, server); perr != nil {
				return ctrl.Result{}, perr
			}
			return ctrl.Result{RequeueAfter: requeueSecret}, nil
		}
		if err := r.Prober.Probe(ctx, rconAddress(server), password); err != nil {
			r.markStarting(server, "RconNotReachable", err.Error())
			if perr := r.patchStatus(ctx, server); perr != nil {
				return ctrl.Result{}, perr
			}
			return ctrl.Result{RequeueAfter: requeueStarting}, nil
		}
	}

	r.markRunningReady(server)
	return ctrl.Result{}, r.patchStatus(ctx, server)
}

func (r *Reconciler) reconcileStopped(ctx context.Context, server *v1alpha1.MinecraftServer) (ctrl.Result, error) {
	var sts appsv1.StatefulSet
	err := r.Get(ctx, types.NamespacedName{Namespace: server.Namespace, Name: server.Name}, &sts)
	if apierrors.IsNotFound(err) {
		r.markStopped(server)
		return ctrl.Result{}, r.patchStatus(ctx, server)
	}
	if err != nil {
		return ctrl.Result{}, err
	}

	// Scaling to zero triggers each pod's preStop RCON save+stop (spec §7).
	if sts.Spec.Replicas == nil || *sts.Spec.Replicas != 0 {
		zero := int32(0)
		sts.Spec.Replicas = &zero
		if err := r.Update(ctx, &sts); err != nil {
			return ctrl.Result{}, err
		}
	}

	if sts.Status.Replicas > 0 || sts.Status.ReadyReplicas > 0 {
		r.markStopping(server)
		if err := r.patchStatus(ctx, server); err != nil {
			return ctrl.Result{}, err
		}
		return ctrl.Result{RequeueAfter: requeueStopping}, nil
	}

	r.markStopped(server)
	return ctrl.Result{}, r.patchStatus(ctx, server)
}

func (r *Reconciler) ensureServices(ctx context.Context, server *v1alpha1.MinecraftServer) error {
	for _, svc := range []*corev1.Service{buildHeadlessService(server), buildClientService(server)} {
		if err := controllerutil.SetControllerReference(server, svc, r.Scheme); err != nil {
			return err
		}
		if err := r.applyService(ctx, svc); err != nil {
			return err
		}
	}
	return nil
}

func (r *Reconciler) rconPassword(ctx context.Context, server *v1alpha1.MinecraftServer) (string, error) {
	ref := server.Spec.Rcon.SecretRef
	if ref.Name == "" || ref.Key == "" {
		return "", fmt.Errorf("rcon.secretRef.name and .key are required when rcon is enabled")
	}
	var secret corev1.Secret
	if err := r.Get(ctx, types.NamespacedName{Namespace: server.Namespace, Name: ref.Name}, &secret); err != nil {
		return "", err
	}
	b, ok := secret.Data[ref.Key]
	if !ok {
		return "", fmt.Errorf("secret %q has no key %q", ref.Name, ref.Key)
	}
	return string(b), nil
}

// applyStatefulSet creates the StatefulSet or, if it exists, updates only its
// mutable fields (StatefulSet selector/serviceName/volumeClaimTemplates are
// immutable and must not be re-sent).
func (r *Reconciler) applyStatefulSet(ctx context.Context, desired *appsv1.StatefulSet) error {
	var existing appsv1.StatefulSet
	err := r.Get(ctx, client.ObjectKeyFromObject(desired), &existing)
	if apierrors.IsNotFound(err) {
		return r.Create(ctx, desired)
	}
	if err != nil {
		return err
	}
	existing.Labels = desired.Labels
	existing.Spec.Replicas = desired.Spec.Replicas
	existing.Spec.Template = desired.Spec.Template
	return r.Update(ctx, &existing)
}

// applyService creates or updates a Service, preserving the cluster-assigned
// ClusterIP so the update does not orphan the address.
func (r *Reconciler) applyService(ctx context.Context, desired *corev1.Service) error {
	var existing corev1.Service
	err := r.Get(ctx, client.ObjectKeyFromObject(desired), &existing)
	if apierrors.IsNotFound(err) {
		return r.Create(ctx, desired)
	}
	if err != nil {
		return err
	}
	desired.ResourceVersion = existing.ResourceVersion
	desired.Spec.ClusterIP = existing.Spec.ClusterIP
	desired.Spec.ClusterIPs = existing.Spec.ClusterIPs
	return r.Update(ctx, desired)
}

func (r *Reconciler) patchStatus(ctx context.Context, server *v1alpha1.MinecraftServer) error {
	return r.Status().Update(ctx, server)
}

// --- status mutators -------------------------------------------------------

func (r *Reconciler) markStarting(server *v1alpha1.MinecraftServer, reason, msg string) {
	server.Status.Phase = v1alpha1.PhaseStarting
	server.Status.Ready = false
	server.Status.ObservedGeneration = server.Generation
	server.Status.Endpoint = v1alpha1.EndpointStatus{Mode: v1alpha1.EndpointFallback, Address: server.Spec.FallbackServer}
	server.Status.LiveMotd = server.Spec.Motd.Starting
	r.setCondition(server, v1alpha1.ConditionReady, metav1.ConditionFalse, reason, msg)
	r.setCondition(server, v1alpha1.ConditionRconReached, metav1.ConditionFalse, reason, msg)
}

func (r *Reconciler) markRunningReady(server *v1alpha1.MinecraftServer) {
	server.Status.Phase = v1alpha1.PhaseRunning
	server.Status.Ready = true
	server.Status.ObservedGeneration = server.Generation
	if server.Status.ReadySignalAt == nil {
		t := r.now()
		server.Status.ReadySignalAt = &t
	}
	server.Status.Endpoint = v1alpha1.EndpointStatus{Mode: v1alpha1.EndpointDirect, Address: gameAddress(server)}
	server.Status.LiveMotd = server.Spec.Motd.Running
	r.setCondition(server, v1alpha1.ConditionRconReached, metav1.ConditionTrue, "Probed", "RCON probe succeeded")
	r.setCondition(server, v1alpha1.ConditionReady, metav1.ConditionTrue, "RconReached", "server is accepting RCON")
}

func (r *Reconciler) markStopping(server *v1alpha1.MinecraftServer) {
	server.Status.Phase = v1alpha1.PhaseStopping
	server.Status.Ready = false
	server.Status.ObservedGeneration = server.Generation
	server.Status.Endpoint = v1alpha1.EndpointStatus{Mode: v1alpha1.EndpointFallback, Address: server.Spec.FallbackServer}
	server.Status.LiveMotd = server.Spec.Motd.Stopped
	r.setCondition(server, v1alpha1.ConditionReady, metav1.ConditionFalse, "Stopping", "scaling down")
}

func (r *Reconciler) markStopped(server *v1alpha1.MinecraftServer) {
	server.Status.Phase = v1alpha1.PhaseStopped
	server.Status.Ready = false
	server.Status.ObservedGeneration = server.Generation
	server.Status.ReadySignalAt = nil
	server.Status.Players = v1alpha1.PlayersStatus{}
	server.Status.Endpoint = v1alpha1.EndpointStatus{Mode: v1alpha1.EndpointFallback, Address: server.Spec.FallbackServer}
	server.Status.LiveMotd = server.Spec.Motd.Stopped
	r.setCondition(server, v1alpha1.ConditionReady, metav1.ConditionFalse, "Stopped", "desiredState is Stopped")
	r.setCondition(server, v1alpha1.ConditionRconReached, metav1.ConditionFalse, "Stopped", "server is stopped")
}

func (r *Reconciler) markFailed(server *v1alpha1.MinecraftServer, reason, msg string) {
	server.Status.Phase = v1alpha1.PhaseFailed
	server.Status.Ready = false
	server.Status.ObservedGeneration = server.Generation
	r.setCondition(server, v1alpha1.ConditionReady, metav1.ConditionFalse, reason, msg)
	r.setCondition(server, v1alpha1.ConditionProvisioned, metav1.ConditionFalse, reason, msg)
}

func (r *Reconciler) setCondition(server *v1alpha1.MinecraftServer, condType string, status metav1.ConditionStatus, reason, msg string) {
	meta.SetStatusCondition(&server.Status.Conditions, metav1.Condition{
		Type:               condType,
		Status:             status,
		Reason:             reason,
		Message:            msg,
		ObservedGeneration: server.Generation,
		LastTransitionTime: r.now(),
	})
}
+298 −0

File added.

Preview size limit exceeded, changes collapsed.