fix(servers): 主人可放弃服务器、管理员可删除服务器,由 reaper 归档世界后释放或移除

This commit is contained in:
Lemon-miaow committed 2026-09-27 03:49:49 +08:00
1 parent 4a26bf4ca0
commit 0e08fa7ced
55 files changed
+2774 -119

No files matched your search

+17 -13
View File
@@ -185,12 +185,16 @@ func OperatorRole(p Params) *rbacv1.Role {
})
}
// ReaperRole grants felis-reaper its two destructive, disjoint powers
// (internal/reaper.k8scluster): patch a MinecraftServer to Stop it and delete its
// world PVC. Candidate servers come from the Postgres store, not a cluster List,
// so minecraftservers need no list/watch; the reaper uses a direct client. It can
// read+patch minecraftservers but cannot create them, and holds no power over
// StatefulSets, Services, or Secrets — those belong to the operator and api.
// ReaperRole grants felis-reaper its destructive powers
// (internal/reaper.k8scluster): patch a MinecraftServer to Stop it, delete its
// world PVC, and delete the MinecraftServer of a server an admin deleted.
// Candidate servers come from the Postgres store, not a cluster List, so
// minecraftservers need no list/watch; the reaper uses a direct client. It can
// read, patch and delete minecraftservers but cannot create them, and holds no
// power over StatefulSets, Services, or Secrets — those belong to the operator
// and api (deleting a MinecraftServer lets garbage collection take the ones the
// operator made for it; the world PVC is retained by the StatefulSet and the
// reaper deletes it first, after archiving it).
//
// The same patch holds the world maintenance lock while a world is archived and
// reclaimed (internal/maintenance). Taking it needs the two reads felis-api makes
@@ -202,16 +206,16 @@ func OperatorRole(p Params) *rbacv1.Role {
// stock local-path directory name. get is strictly weaker than the delete the
// same rule already grants, so it widens nothing.
//
// Note no identity anywhere holds minecraftservers:delete. That is intentional, not
// a missing grant: reaping releases a server by flipping desiredState=Stopped and
// reclaiming the world PVC (k8scluster.go does "nothing else"), leaving the CR in
// place so a former owner can re-claim it within the retention window (spec §466).
// The MinecraftServer CR is the lifecycle source of truth and is retained, never
// hard-deleted, so the delete verb is deliberately absent from every Role.
// The reaper is the only identity with minecraftservers:delete. Reaping an idle
// world releases the server by flipping desiredState=Stopped and reclaiming the
// world PVC, leaving the CR in place so it can be claimed again; the CR goes only
// when an admin deletes the server (PUT /servers/{name}/retirement), and then only
// once the reaper has archived and deleted its world. felis-api records that
// request and never deletes a CR itself.
func ReaperRole(p Params) *rbacv1.Role {
p = p.withDefaults()
return role(p.MinecraftNamespace, "felis-reaper", ComponentReaper, []rbacv1.PolicyRule{
rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "patch"}),
rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "patch", "delete"}),
rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"get", "delete"}),
rule([]string{groupCore}, []string{"pods"}, []string{"list"}),
rule([]string{groupBatch}, []string{"jobs"}, []string{"list"}),
+22 -7
View File
@@ -331,17 +331,32 @@ func TestReaperRBAC_GatedOnRetention(t *testing.T) {
}
}
// TestNoIdentityDeletesMinecraftServers locks the lifecycle invariant: the
// MinecraftServer CR is the retained source of truth (released by Stop + PVC
// reclaim, never hard-deleted, so a former owner can re-claim within the retention
// window — spec §466). No control-plane identity may hold minecraftservers:delete;
// if a future change adds it, this fails loudly so the decision is deliberate.
func TestNoIdentityDeletesMinecraftServers(t *testing.T) {
// TestOnlyReaperDeletesMinecraftServers locks the lifecycle invariant: a
// MinecraftServer is retained when its world is reaped (released by Stop + PVC
// reclaim, so it can be claimed again), and removed only when an admin deletes the
// server, by the reaper, after it archived and deleted the world. No other
// control-plane identity may hold minecraftservers:delete, and without a reaper
// deployed none does.
func TestOnlyReaperDeletesMinecraftServers(t *testing.T) {
for _, role := range ControlPlaneRBAC(testParams()).Roles {
if hasRule(role, groupFelis, "minecraftservers", "delete") {
t.Errorf("%s grants minecraftservers:delete — the CR is retained, never hard-deleted", role.Name)
t.Errorf("%s grants minecraftservers:delete with no reaper deployed", role.Name)
}
}
reaper := 0
for _, role := range ControlPlaneRBAC(reaperParams()).Roles {
if !hasRule(role, groupFelis, "minecraftservers", "delete") {
continue
}
if role.Name != "felis-reaper" {
t.Errorf("%s grants minecraftservers:delete — only the reaper removes a server, after archiving its world", role.Name)
continue
}
reaper++
}
if reaper != 1 {
t.Error("the reaper must delete minecraftservers (an admin's deletion of a server)")
}
}
// TestNoClusterScopedRBAC enforces the §22 red line: nothing in the bundle is a