feat(operator): 平台升级不再重启运行中的游戏服,registry 清理保留游戏 pod 在用的镜像

This commit is contained in:
Lemon-miaow committed 2026-09-24 23:52:30 +08:00
1 parent 07eb682358
commit 80fbc779d9
5 files changed
+188 -10

No files matched your search

+50 -2
View File
@@ -10,6 +10,7 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"time"
@@ -61,6 +62,35 @@ func rconStamp(password []byte) string {
return hex.EncodeToString(sum[:8])
}
// PodTemplateAnnotation stamps the StatefulSet with a fingerprint of the pod
// template the operator last wrote, taken with the felis image left out of the
// init containers. The installer tags that image by release, so every platform
// upgrade hands the operator a new one; rolling every running server onto it
// would restart each world under its players for an init step that has already
// run. While a server runs and the fingerprint still matches, its template is
// left as it is and the new image arrives with its next start (stop scales to
// zero, and a start writes the whole template). Any other change — a spec edit,
// a new RCON password, a builder change in a new release — moves the
// fingerprint and rolls the pod as before.
const PodTemplateAnnotation = "felis.lolicon.best/pod-template"
// podTemplateStamp fingerprints tmpl for PodTemplateAnnotation. encoding/json
// writes map keys sorted, so the same template always hashes the same.
func podTemplateStamp(tmpl *corev1.PodTemplateSpec, felisImage string) (string, error) {
t := tmpl.DeepCopy()
for i := range t.Spec.InitContainers {
if felisImage != "" && t.Spec.InitContainers[i].Image == felisImage {
t.Spec.InitContainers[i].Image = ""
}
}
b, err := json.Marshal(t)
if err != nil {
return "", fmt.Errorf("fingerprint pod template: %w", err)
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:8]), nil
}
// Reconciler reconciles a MinecraftServer with its managed children.
type Reconciler struct {
client.Client
@@ -536,19 +566,37 @@ func (r *Reconciler) rconPassword(ctx context.Context, server *v1alpha1.Minecraf
// 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).
// immutable and must not be re-sent). A running server whose template changed
// only in the felis image keeps its template (PodTemplateAnnotation).
func (r *Reconciler) applyStatefulSet(ctx context.Context, desired *appsv1.StatefulSet) error {
stamp, err := podTemplateStamp(&desired.Spec.Template, r.FelisImage)
if err != nil {
return err
}
if desired.Annotations == nil {
desired.Annotations = map[string]string{}
}
desired.Annotations[PodTemplateAnnotation] = stamp
var existing appsv1.StatefulSet
err := r.Get(ctx, client.ObjectKeyFromObject(desired), &existing)
err = r.Get(ctx, client.ObjectKeyFromObject(desired), &existing)
if apierrors.IsNotFound(err) {
return r.Create(ctx, desired)
}
if err != nil {
return err
}
running := existing.Spec.Replicas != nil && *existing.Spec.Replicas > 0
existing.Labels = desired.Labels
existing.Spec.Replicas = desired.Spec.Replicas
if running && existing.Annotations[PodTemplateAnnotation] == stamp {
return r.Update(ctx, &existing)
}
existing.Spec.Template = desired.Spec.Template
if existing.Annotations == nil {
existing.Annotations = map[string]string{}
}
existing.Annotations[PodTemplateAnnotation] = stamp
return r.Update(ctx, &existing)
}
+83
View File
@@ -1083,3 +1083,86 @@ func TestIdleAutoStopIsInertWithoutRcon(t *testing.T) {
got, v1alpha1.DesiredRunning)
}
}
const (
felisV1 = "registry.felis.svc:5000/felis/felis:v1.0.0"
felisV2 = "registry.felis.svc:5000/felis/felis:v1.1.0"
)
func initImages(t *testing.T, c client.Client, name string) []string {
t.Helper()
var images []string
for _, ic := range getSTS(t, c, name).Spec.Template.Spec.InitContainers {
images = append(images, ic.Image)
}
if len(images) == 0 {
t.Fatal("the StatefulSet runs no felis init container")
}
return images
}
func expectInitImages(t *testing.T, c client.Client, want string) {
t.Helper()
for _, got := range initImages(t, c, "survival") {
if got != want {
t.Fatalf("init container image = %q, want %q", got, want)
}
}
}
// TestFelisUpgradeLeavesARunningServerAlone: the installer moves the felis image
// on every release. A running server keeps the template it started with rather
// than restarting under its players; any other change still rolls it, and the
// roll carries the new image along.
func TestFelisUpgradeLeavesARunningServerAlone(t *testing.T) {
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 1, Max: 20, Known: true}}, runningServer(), rconSecret())
r.FelisImage = felisV1
reconcile(t, r, "survival")
markPodReady(t, c, "survival")
reconcile(t, r, "survival")
expectInitImages(t, c, felisV1)
stamp := getSTS(t, c, "survival").Annotations[operator.PodTemplateAnnotation]
if stamp == "" {
t.Fatal("the StatefulSet must carry the pod-template fingerprint")
}
r.FelisImage = felisV2
reconcile(t, r, "survival")
expectInitImages(t, c, felisV1)
if got := getSTS(t, c, "survival").Annotations[operator.PodTemplateAnnotation]; got != stamp {
t.Fatalf("fingerprint moved from %q to %q on an image-only change", stamp, got)
}
server := getServer(t, c, "survival")
server.Spec.JavaMemory = "6G"
if err := c.Update(context.Background(), server); err != nil {
t.Fatalf("edit spec: %v", err)
}
reconcile(t, r, "survival")
expectInitImages(t, c, felisV2)
if got := getSTS(t, c, "survival").Annotations[operator.PodTemplateAnnotation]; got == stamp {
t.Fatal("a spec edit must move the fingerprint")
}
}
// TestFelisUpgradeReachesAServerOnItsNextStart: a stopped server's next start
// writes the whole template, the new felis image included.
func TestFelisUpgradeReachesAServerOnItsNextStart(t *testing.T) {
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 1, Max: 20, Known: true}}, runningServer(), rconSecret())
r.FelisImage = felisV1
stopRunningServer(t, r, c)
markPodTerminated(t, c, "survival")
reconcile(t, r, "survival")
r.FelisImage = felisV2
server := getServer(t, c, "survival")
server.Spec.DesiredState = v1alpha1.DesiredRunning
if err := c.Update(context.Background(), server); err != nil {
t.Fatalf("flip desiredState: %v", err)
}
reconcile(t, r, "survival")
if sts := getSTS(t, c, "survival"); sts.Spec.Replicas == nil || *sts.Spec.Replicas != 1 {
t.Fatalf("replicas = %v, want 1 after start", sts.Spec.Replicas)
}
expectInitImages(t, c, felisV2)
}