Unverified Commit 925cfcf8 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(operator): StatefulSet 显式保留世界卷,同名旧世界卷仍在时建服回 409 world_volume_exists,文档写明孤儿卷查找与单节点约束

parent b7d4275c
Loading
Loading
Loading
Loading
+7 −0
Changes for docs/operations.md: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for docs/troubleshooting.md: 20 added lines, 9 removed lines.
Original line number Diff line number Diff line
@@ -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:

```
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 get pvc -l app.kubernetes.io/name=<name>
kubectl delete pvc <pvc>      # irreversible — the world is gone
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
+9 −3
Changes for internal/api/api_test.go: 9 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -1739,6 +1739,7 @@ type fakeCluster struct {
	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)
@@ -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 {
+15 −0
Changes for internal/api/handlers_create_test.go: 15 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 {
+14 −0
Changes for internal/api/handlers_user.go: 14 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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.
Loading