Loading docs/operations.md +7 −0 Changes for docs/operations.md: 7 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading docs/troubleshooting.md +20 −9 Changes for docs/troubleshooting.md: 20 added lines, 9 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading internal/api/api_test.go +9 −3 Changes for internal/api/api_test.go: 9 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -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) Loading Loading @@ -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 { Loading internal/api/handlers_create_test.go +15 −0 Changes for internal/api/handlers_create_test.go: 15 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 { Loading internal/api/handlers_user.go +14 −0 Changes for internal/api/handlers_user.go: 14 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading
docs/operations.md +7 −0 Changes for docs/operations.md: 7 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading
docs/troubleshooting.md +20 −9 Changes for docs/troubleshooting.md: 20 added lines, 9 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading
internal/api/api_test.go +9 −3 Changes for internal/api/api_test.go: 9 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -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) Loading Loading @@ -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 { Loading
internal/api/handlers_create_test.go +15 −0 Changes for internal/api/handlers_create_test.go: 15 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 { Loading
internal/api/handlers_user.go +14 −0 Changes for internal/api/handlers_user.go: 14 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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