fix(api): refuse backup/restore before a missing world volume

A server whose world PVC does not exist yet (never started) or no longer exists
(the world was already reaped) accepted the backup/restore POST, answered 202,
and the Job sat Pending on the missing claim until its deadline with nothing
recorded anywhere — a silent no-op from the operator's seat. The live drill on
the reaped `resolvecheck` world reproduced exactly that.

Both handlers now read the world PVC (Cluster.WorldVolumeExists, over the same
naming.WorldPVCName the Jobs mount) and answer a specific 409 no_world_volume
with "start it once to create it, then retry". The felis-api Role gains the
matching get-only PVC grant — the first live run surfaced the missing RBAC as a
403 behind a 500, so the fix ships with it.

Live (auditfix38): resolvecheck -> 409 no_world_volume on both faces; test-one
(which has a world) still backs up through the new gate end to end.
This commit is contained in:
Lemon-miaow committed 2026-09-23 07:49:00 +08:00
1 parent 55d515d41f
commit 508a1c02da
8 files changed
+127 -3

No files matched your search

+8 -2
View File
@@ -84,8 +84,11 @@ func ControlPlaneRBAC(p Params) RBAC {
// FINISHED Job whose name still blocks a retry can be replaced), and stream the
// live console for the read side (internal/api.logstream — pods:list to find
// the server's running pod, then pods/log:get to follow it; spec §8 读=pods/log
// follow). felis-api uses a DIRECT client, so it needs no list/watch beyond the
// explicit List calls.
// follow). It also Gets the world PVC before backup/restore
// (internal/api.k8scluster.WorldVolumeExists) so a never-started or reaped
// world is refused up front instead of leaving a Job Pending on a missing
// claim. felis-api uses a DIRECT client, so it needs no list/watch beyond the
// explicit List calls — and the PVC grant is get-only, mirroring that.
//
// The read-side grant is deliberately minimal: pods:list + pods/log:get, NOT
// pods:get — the streamer lists pods by the server label then reads the chosen
@@ -97,6 +100,9 @@ func APIMinecraftRole(p Params) *rbacv1.Role {
return role(p.MinecraftNamespace, "felis-api", ComponentAPI, []rbacv1.PolicyRule{
rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "list", "create", "patch"}),
rule([]string{groupCore}, []string{"secrets"}, []string{"get"}),
// get-only: WorldVolumeExists does a single direct Get of the world PVC;
// nothing in felis-api lists or deletes PVCs.
rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"get"}),
// list backs GET /servers/{name}/jobs — the async status outlet reads the
// backup/restore Jobs back by the server label (read-only).
rule([]string{groupBatch}, []string{"jobs"}, []string{"create", "get", "delete", "list"}),
+8
View File
@@ -121,6 +121,14 @@ func TestAPIRole_MinecraftPowersExact(t *testing.T) {
if !hasRule(mc, groupCore, "secrets", "get") {
t.Error("felis-api must read RCON secrets (secrets:get) for console writes")
}
// WorldVolumeExists (backup/restore pre-gate) does a single direct PVC Get;
// nothing in felis-api lists or deletes claims.
if !hasRule(mc, groupCore, "persistentvolumeclaims", "get") {
t.Error("felis-api must get the world PVC (persistentvolumeclaims:get) for the backup/restore world-volume gate")
}
if hasRule(mc, groupCore, "persistentvolumeclaims", "list") || hasRule(mc, groupCore, "persistentvolumeclaims", "delete") {
t.Error("felis-api must NOT list or delete PVCs (the gate is a single direct Get)")
}
// Read-side console (spec §8 读=pods/log follow): list pods to find the
// running pod, then read its log subresource — and nothing wider.
if !hasRule(mc, groupCore, "pods", "list") {