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.
This commit is contained in:
2 files changed
+177
-9
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user