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

feat(operator): system-server pod readiness probe and login service-token env

buildStatefulSet gates readiness on an HTTP probe when HealthHTTPPort is set (exposing it as a named container port). buildEnv injects FELIS_SERVICE_TOKEN into the login server only — keyed off the reserved name so it can never leak into a user pod — sourced from a Secret via secretKeyRef, never inlined into the CRD.
parent 159107b4
Loading
Loading
Loading
Loading
+64 −9
Changes for internal/operator/builders.go: 64 added lines, 9 removed lines.
Original line number Diff line number Diff line
@@ -5,6 +5,7 @@ import (
	"strconv"

	"felis.lolicon.best/internal/apis/felis/v1alpha1"
	"felis.lolicon.best/internal/naming"
	appsv1 "k8s.io/api/apps/v1"
	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/api/resource"
@@ -12,6 +13,11 @@ import (
	"k8s.io/apimachinery/pkg/util/intstr"
)

// envServiceToken is the environment variable the felis-limbo login plugin reads
// its internal-API bearer credential from. It is injected ONLY into the login
// system server (see buildEnv), sourced from a Secret, never a literal.
const envServiceToken = "FELIS_SERVICE_TOKEN"

// Workload constants shared by the builders.
const (
	// GamePort is the Minecraft TCP port the proxy and readiness probe target.
@@ -148,6 +154,33 @@ func servicePorts(server *v1alpha1.MinecraftServer) []corev1.ServicePort {
	return ports
}

// readinessProbe selects the pod readiness probe. By default it is a plain TCP
// check on the game port; when the server declares an HTTP health port
// (StartupSpec.HealthHTTPPort > 0) it becomes an HTTP GET on that port, so an
// RCON-less loader's own "started" signal — not the mere fact that the game
// socket is bound — gates readiness. Timings are identical across both modes.
func readinessProbe(server *v1alpha1.MinecraftServer) *corev1.Probe {
	probe := &corev1.Probe{
		InitialDelaySeconds: 20,
		PeriodSeconds:       10,
		FailureThreshold:    6,
	}
	if hp := server.Spec.Startup.HealthHTTPPort; hp > 0 {
		path := server.Spec.Startup.HealthHTTPPath
		if path == "" {
			path = "/healthz"
		}
		probe.ProbeHandler = corev1.ProbeHandler{
			HTTPGet: &corev1.HTTPGetAction{Path: path, Port: intstr.FromInt32(hp)},
		}
		return probe
	}
	probe.ProbeHandler = corev1.ProbeHandler{
		TCPSocket: &corev1.TCPSocketAction{Port: intstr.FromInt32(GamePort)},
	}
	return probe
}

// 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.
@@ -172,16 +205,17 @@ func buildStatefulSet(server *v1alpha1.MinecraftServer, replicas int32) (*appsv1
		VolumeMounts: []corev1.VolumeMount{
			{Name: dataVolumeName, MountPath: dataMountPath},
		},
		// Readiness here is a plain TCP check (spec §5: readinessProbe is only
		// Readiness defaults to 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,
		},
		// An RCON-less loader may instead publish an HTTP health endpoint (see
		// StartupSpec.HealthHTTPPort) that reports true readiness — used below when
		// set.
		ReadinessProbe: readinessProbe(server),
	}
	if hp := server.Spec.Startup.HealthHTTPPort; hp > 0 {
		container.Ports = append(container.Ports, corev1.ContainerPort{
			Name: "health", ContainerPort: hp, Protocol: corev1.ProtocolTCP,
		})
	}
	if len(server.Spec.Args) > 0 {
		container.Args = append([]string(nil), server.Spec.Args...)
@@ -270,6 +304,27 @@ func buildEnv(server *v1alpha1.MinecraftServer) []corev1.EnvVar {
			}},
		)
	}
	// The login system server is the ONE workload that authenticates to the
	// felis-api internal face (its felis-limbo plugin mints bind codes and polls
	// link status), so it — and only it — receives the service token. Injected
	// from a Secret in this namespace, never inlined into the CRD (the same
	// discipline as RCON_PASSWORD above; the CRD's EnvVar type has no valueFrom
	// precisely so a user server cannot mount an arbitrary secret). Keyed off the
	// reserved "login" name, which naming.ValidateServerName forbids any user
	// server from claiming — so this can never leak the token into a user's pod.
	// The Secret must exist in this (minecraft) namespace; `felis setup` replicates
	// it there from the control namespace before creating this server.
	if server.Name == naming.SystemLoginServer {
		env = append(env, corev1.EnvVar{
			Name: envServiceToken,
			ValueFrom: &corev1.EnvVarSource{
				SecretKeyRef: &corev1.SecretKeySelector{
					LocalObjectReference: corev1.LocalObjectReference{Name: naming.ServiceTokenSecretName},
					Key:                  naming.ServiceTokenSecretKey,
				},
			},
		})
	}
	return env
}

+113 −0
Changes for internal/operator/builders_internal_test.go: 113 added lines, 0 removed lines.
Original line number Diff line number Diff line
package operator

import (
	"testing"

	"felis.lolicon.best/internal/apis/felis/v1alpha1"
	"felis.lolicon.best/internal/naming"
	corev1 "k8s.io/api/core/v1"
)

// findEnv returns the env var with the given name, or nil.
func findEnv(env []corev1.EnvVar, name string) *corev1.EnvVar {
	for i := range env {
		if env[i].Name == name {
			return &env[i]
		}
	}
	return nil
}

// By default readiness is a plain TCP check on the game port (spec §5).
func TestReadinessProbeDefaultsToTCP(t *testing.T) {
	p := readinessProbe(&v1alpha1.MinecraftServer{})
	if p.TCPSocket == nil || p.HTTPGet != nil {
		t.Fatalf("default probe should be TCPSocket, got %+v", p.ProbeHandler)
	}
	if p.TCPSocket.Port.IntVal != GamePort {
		t.Errorf("TCP probe port = %d, want %d", p.TCPSocket.Port.IntVal, GamePort)
	}
}

// When a server declares an HTTP health port, readiness gates on an HTTP GET so
// an RCON-less loader's own "started" signal (felis-limbo) marks it Ready.
func TestReadinessProbeHTTPWhenHealthPortSet(t *testing.T) {
	s := &v1alpha1.MinecraftServer{}
	s.Spec.Startup.HealthHTTPPort = 8080
	p := readinessProbe(s)
	if p.HTTPGet == nil || p.TCPSocket != nil {
		t.Fatalf("expected HTTPGet probe, got %+v", p.ProbeHandler)
	}
	if p.HTTPGet.Port.IntVal != 8080 {
		t.Errorf("HTTP probe port = %d, want 8080", p.HTTPGet.Port.IntVal)
	}
	if p.HTTPGet.Path != "/healthz" {
		t.Errorf("HTTP probe path = %q, want default /healthz", p.HTTPGet.Path)
	}
}

func TestReadinessProbeHTTPCustomPath(t *testing.T) {
	s := &v1alpha1.MinecraftServer{}
	s.Spec.Startup.HealthHTTPPort = 9000
	s.Spec.Startup.HealthHTTPPath = "/ready"
	p := readinessProbe(s)
	if p.HTTPGet == nil || p.HTTPGet.Path != "/ready" || p.HTTPGet.Port.IntVal != 9000 {
		t.Fatalf("custom HTTP probe wrong: %+v", p.HTTPGet)
	}
}

// A server with a health port also exposes it as a named container port so the
// kubelet can reach it.
func TestBuildStatefulSetAddsHealthPort(t *testing.T) {
	s := &v1alpha1.MinecraftServer{}
	s.Spec.Storage.Size = "1Gi"
	s.Spec.Startup.HealthHTTPPort = 8080
	sts, err := buildStatefulSet(s, 1)
	if err != nil {
		t.Fatalf("buildStatefulSet: %v", err)
	}
	found := false
	for _, port := range sts.Spec.Template.Spec.Containers[0].Ports {
		if port.Name == "health" && port.ContainerPort == 8080 {
			found = true
		}
	}
	if !found {
		t.Error("health container port 8080 not exposed")
	}
}

// The login system server (and ONLY it) receives the service token, sourced from a
// Secret via secretKeyRef — never a literal — so its felis-limbo plugin can
// authenticate to the felis-api internal face.
func TestBuildEnvInjectsServiceTokenForLogin(t *testing.T) {
	s := &v1alpha1.MinecraftServer{}
	s.Name = naming.SystemLoginServer
	tok := findEnv(buildEnv(s), envServiceToken)
	if tok == nil {
		t.Fatalf("%s not injected for the login server", envServiceToken)
	}
	if tok.Value != "" {
		t.Errorf("%s carries a literal value %q — it must be a secretKeyRef", envServiceToken, tok.Value)
	}
	if tok.ValueFrom == nil || tok.ValueFrom.SecretKeyRef == nil {
		t.Fatalf("%s must be sourced from a secretKeyRef", envServiceToken)
	}
	ref := tok.ValueFrom.SecretKeyRef
	if ref.Name != naming.ServiceTokenSecretName || ref.Key != naming.ServiceTokenSecretKey {
		t.Errorf("secretKeyRef = %s/%s, want %s/%s", ref.Name, ref.Key, naming.ServiceTokenSecretName, naming.ServiceTokenSecretKey)
	}
}

// A user server (any non-login name) must NOT receive the service token — the
// reserved-name gate is what stops the internal credential leaking into a player's
// pod. naming.ValidateServerName forbids users from ever claiming "login".
func TestBuildEnvWithholdsServiceTokenFromUserServers(t *testing.T) {
	for _, name := range []string{"survival", "creative", naming.SystemLobbyServer} {
		s := &v1alpha1.MinecraftServer{}
		s.Name = name
		if tok := findEnv(buildEnv(s), envServiceToken); tok != nil {
			t.Errorf("%s: service token leaked into a non-login server", name)
		}
	}
}