fix(operator): StatefulSet 显式保留世界卷,同名旧世界卷仍在时建服回 409 world_volume_exists,文档写明孤儿卷查找与单节点约束
This commit is contained in:
11 files changed
+112
-22
No files matched your search
@@ -39,6 +39,13 @@ cloudflared is left as it is, see §4):
|
||||
32-bit hosts are not supported: there is no k3s, JRE or Go build the installer will fetch
|
||||
for them.
|
||||
|
||||
One node is the whole supported shape. A world volume is a ReadWriteOnce claim on the
|
||||
node's local-path storage, so a game server's pod is pinned to the node that first
|
||||
scheduled it and cannot move when that node fails; the operator and felis-api each run
|
||||
as a single replica without leader election, so an upgrade or a node restart pauses
|
||||
wakes and stops until their pod is back. Joining k3s agents to the cluster is untested
|
||||
and gains no failover.
|
||||
|
||||
## 2. Sizing
|
||||
|
||||
### What the platform itself uses
|
||||
|
||||
+20
-9
@@ -1157,17 +1157,28 @@ leaves it off; only servers it actually filled get a line.
|
||||
|
||||
## 13. World PVC survives after I deleted the MinecraftServer
|
||||
|
||||
This is expected. The world PVC is a StatefulSet `VolumeClaimTemplate`. There is
|
||||
**no `persistentVolumeClaimRetentionPolicy` and no finalizer** anywhere in the
|
||||
operator. Deleting the `MinecraftServer` garbage-collects the StatefulSet, but
|
||||
StatefulSet deletion does **not** cascade to its template PVCs, and nothing else
|
||||
cleans them up. So the world PVC **always survives** server deletion. The
|
||||
**only** code that deletes a world PVC is the reaper, and only after a verified
|
||||
backup (§10). To reclaim a world PVC manually:
|
||||
This is expected. The world PVC is a StatefulSet `VolumeClaimTemplate`, and the
|
||||
operator sets the StatefulSet's `persistentVolumeClaimRetentionPolicy` to
|
||||
`Retain` on delete and on scale, explicitly rather than by the API default.
|
||||
There is no finalizer. Deleting the `MinecraftServer` garbage-collects the
|
||||
StatefulSet and keeps the claim, so a `MinecraftServer` that comes back under
|
||||
the same name mounts the same world. The **only** code that deletes a world PVC
|
||||
is the reaper, and only after a verified backup (§10).
|
||||
|
||||
A kept claim holds the name: creating a new server with it answers
|
||||
`409 world_volume_exists`, since the new server would otherwise mount the old
|
||||
world and hand it to its new owner. List the world claims whose server is gone:
|
||||
|
||||
```
|
||||
kubectl get pvc -l app.kubernetes.io/name=<name>
|
||||
kubectl delete pvc <pvc> # irreversible — the world is gone
|
||||
comm -23 \
|
||||
<(kubectl -n minecraft get pvc -l felis.lolicon.best/server -o jsonpath='{range .items[*]}{.metadata.labels.felis\.lolicon\.best/server}{"\n"}{end}' | sort) \
|
||||
<(kubectl -n minecraft get minecraftservers -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' | sort)
|
||||
```
|
||||
|
||||
To reclaim one (take a backup first if the world may still matter):
|
||||
|
||||
```
|
||||
kubectl -n minecraft delete pvc world-<name>-0 # irreversible — the world is gone
|
||||
```
|
||||
|
||||
`spec.storage.retainOnDelete` sat in the CRD and reached no controller. Spec
|
||||
|
||||
+19
-13
@@ -1731,16 +1731,17 @@ func (f *fakeRestorer) Restore(_ context.Context, name, ref string) error {
|
||||
}
|
||||
|
||||
type fakeCluster struct {
|
||||
byName map[string]*ServerInfo
|
||||
bySub map[string]*ServerInfo
|
||||
list []ServerInfo
|
||||
listErr error
|
||||
desired map[string]v1alpha1.DesiredState
|
||||
created map[string]CreateServerInput // name -> the validated input it was created from
|
||||
patched map[string]ServerSpecPatch // name -> the validated spec patch it received
|
||||
noWorld map[string]bool // server names modeled WITHOUT a world volume (never started / reaped)
|
||||
createErr error
|
||||
pingErr error
|
||||
byName map[string]*ServerInfo
|
||||
bySub map[string]*ServerInfo
|
||||
list []ServerInfo
|
||||
listErr error
|
||||
desired map[string]v1alpha1.DesiredState
|
||||
created map[string]CreateServerInput // name -> the validated input it was created from
|
||||
patched map[string]ServerSpecPatch // name -> the validated spec patch it received
|
||||
noWorld map[string]bool // server names modeled WITHOUT a world volume (never started / reaped)
|
||||
orphanWorld map[string]bool // names with a world volume but no server (CR deleted by hand)
|
||||
createErr error
|
||||
pingErr error
|
||||
// maintErr / wakeErr: what AcquireMaintenance / SetDesiredState(Running)
|
||||
// return for a server (the world-volume lock, internal/maintenance).
|
||||
maintErr map[string]error
|
||||
@@ -1775,10 +1776,15 @@ func (c *fakeCluster) ListServers(_ context.Context) ([]ServerInfo, error) {
|
||||
}
|
||||
func (c *fakeCluster) Ping(_ context.Context) error { return c.pingErr }
|
||||
|
||||
// WorldVolumeExists models the world PVC: present unless the test named the
|
||||
// server in noWorld (never started / already reaped).
|
||||
// WorldVolumeExists models the world PVC: a known server has one unless the test
|
||||
// named it in noWorld (never started / already reaped); an unknown name has one
|
||||
// only when named in orphanWorld (its CR was deleted by hand).
|
||||
func (c *fakeCluster) WorldVolumeExists(_ context.Context, n string) (bool, error) {
|
||||
return !c.noWorld[n], nil
|
||||
if c.orphanWorld[n] {
|
||||
return true, nil
|
||||
}
|
||||
_, known := c.byName[n]
|
||||
return known && !c.noWorld[n], nil
|
||||
}
|
||||
|
||||
func (c *fakeCluster) SetDesiredState(_ context.Context, n string, s v1alpha1.DesiredState) error {
|
||||
|
||||
@@ -274,6 +274,21 @@ func TestCreateServerRejections(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
// A CR deleted by hand keeps its world volume; a new server of that
|
||||
// name would mount it and inherit the old world.
|
||||
name: "world volume left by a deleted server",
|
||||
body: validCreateBody,
|
||||
setup: func(_ *fakeRepo, cl *fakeCluster) {
|
||||
cl.orphanWorld = map[string]bool{"survival": true}
|
||||
},
|
||||
wantCode: http.StatusConflict, wantErr: "world_volume_exists",
|
||||
check: func(t *testing.T, repo *fakeRepo, _ *fakeCluster) {
|
||||
if repo.seeded["survival"] {
|
||||
t.Error("a create refused over a leftover volume must not seed a servers row")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
|
||||
@@ -475,6 +475,20 @@ func (a *API) handleCreateServer(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// A server deleted outside the reaper (kubectl delete) leaves its world
|
||||
// volume behind: the StatefulSet retains claims on delete. A new server of
|
||||
// the same name would mount that claim and hand the old world to its new
|
||||
// owner, so the name stays taken until an operator removes the volume.
|
||||
switch exists, err := a.Cluster.WorldVolumeExists(r.Context(), body.Name); {
|
||||
case err != nil:
|
||||
writeError(w, r, err)
|
||||
return
|
||||
case exists:
|
||||
writeError(w, r, newError(http.StatusConflict, "world_volume_exists",
|
||||
"the world volume of an earlier server named %q still exists; delete it or choose another name", body.Name))
|
||||
return
|
||||
}
|
||||
|
||||
// Seed the business rows FIRST (servers + alias). ClaimServer needs the row,
|
||||
// so a CRD-only server would be unclaimable. PG-first means a later CRD
|
||||
// failure leaves a claimable ghost row — acceptable, not transactional.
|
||||
|
||||
@@ -320,6 +320,16 @@ func buildStatefulSet(server *v1alpha1.MinecraftServer, replicas int32, felisIma
|
||||
},
|
||||
},
|
||||
VolumeClaimTemplates: []corev1.PersistentVolumeClaim{pvc},
|
||||
// The world outlives its StatefulSet: deleting the CR (and with it,
|
||||
// by owner reference, the StatefulSet) keeps the claim, so a CR that
|
||||
// comes back under the same name mounts the same world. The reaper is
|
||||
// the one path that deletes a world, after its final backup. Stated
|
||||
// here although it is the API default, so the semantics never ride on
|
||||
// a default.
|
||||
PersistentVolumeClaimRetentionPolicy: &appsv1.StatefulSetPersistentVolumeClaimRetentionPolicy{
|
||||
WhenDeleted: appsv1.RetainPersistentVolumeClaimRetentionPolicyType,
|
||||
WhenScaled: appsv1.RetainPersistentVolumeClaimRetentionPolicyType,
|
||||
},
|
||||
},
|
||||
}
|
||||
return sts, nil
|
||||
|
||||
@@ -54,3 +54,17 @@ func TestGameContainerProbes(t *testing.T) {
|
||||
t.Fatalf("login-gate startup = %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting a server's StatefulSet, or scaling it to zero, keeps the world claim.
|
||||
func TestWorldClaimOutlivesTheStatefulSet(t *testing.T) {
|
||||
srv := &v1alpha1.MinecraftServer{ObjectMeta: metav1.ObjectMeta{Name: "survival", Namespace: "minecraft"}}
|
||||
srv.Spec.Image = "itzg/minecraft-server:java21"
|
||||
sts, err := buildStatefulSet(srv, 1, "felis:test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := sts.Spec.PersistentVolumeClaimRetentionPolicy
|
||||
if p == nil || p.WhenDeleted != "Retain" || p.WhenScaled != "Retain" {
|
||||
t.Fatalf("retention policy = %+v, want Retain on delete and on scale", p)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"image_change_unconfirmed": "Changing the image opens the world with the new build's Minecraft version, and upgraded chunks can't be opened by the old one again. Back the world up, tick the confirmation, then save.",
|
||||
"subdomain_taken": "That subdomain is already in use.",
|
||||
"already_exists": "A server with that name already exists.",
|
||||
"world_volume_exists": "An earlier server with that name left its world volume behind. Choose another name, or ask the operator to delete the old volume.",
|
||||
"cooldown": "Wake is cooling down — try again shortly.",
|
||||
"not_running": "The server isn't running — wake it before managing access.",
|
||||
"console_unavailable": "Can't reach the server console right now — try again shortly.",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"image_change_unconfirmed": "更换镜像会让世界用新构建的 Minecraft 版本打开,区块升级后无法再用旧版本打开。请先备份世界,再勾选确认后保存。",
|
||||
"subdomain_taken": "该子域名已被占用。",
|
||||
"already_exists": "同名服务器已存在。",
|
||||
"world_volume_exists": "同名的旧服务器留下了世界卷。请换一个名称,或请运维删除旧的世界卷。",
|
||||
"cooldown": "启动冷却中——请稍后再试。",
|
||||
"not_running": "服务器未在运行——请先启动它再管理访问权限。",
|
||||
"console_unavailable": "暂时无法连接服务器控制台,请稍后重试。",
|
||||
|
||||
@@ -392,6 +392,13 @@ describe("api access-control wire shapes", () => {
|
||||
expect(humanizeError({ code: "console_unavailable" })).toMatch(/console/i);
|
||||
});
|
||||
|
||||
it("says a server name is held by a world volume left behind", async () => {
|
||||
const { humanizeError } = await import("./api");
|
||||
expect(humanizeError({ status: 409, code: "world_volume_exists" })).toBe(
|
||||
"An earlier server with that name left its world volume behind. Choose another name, or ask the operator to delete the old volume.",
|
||||
);
|
||||
});
|
||||
|
||||
it("says why a user change was refused for the caller's own or the owner account", async () => {
|
||||
const { humanizeError } = await import("./api");
|
||||
expect(humanizeError({ status: 403, code: "self_protected" })).toBe(
|
||||
|
||||
@@ -910,6 +910,10 @@ export function humanizeError(e: unknown): string {
|
||||
return t("subdomain_taken");
|
||||
case "already_exists":
|
||||
return t("already_exists");
|
||||
// A server deleted by hand left its world volume; the name stays taken so a
|
||||
// new server cannot mount the old world.
|
||||
case "world_volume_exists":
|
||||
return t("world_volume_exists");
|
||||
case "cooldown":
|
||||
return t("cooldown");
|
||||
// Access control (spec §7): the server must be Running for any RCON-backed
|
||||
|
||||
Reference in new issue
Block a user