Files
Felis/internal/reaper/retire_test.go

212 lines
8.8 KiB
Go

package reaper
import (
"errors"
"reflect"
"testing"
"time"
)
// retiring is a candidate whose owner gave it up an hour ago, a day after they
// last played: nowhere near idle, so only the request makes the reaper act.
func retiring(name, owner string, del bool) Candidate {
return Candidate{Name: name, OwnerID: owner, LastActiveAt: idleBy(Day),
RetireRequestedAt: idleBy(time.Hour), RetireDelete: del}
}
// A server its owner gave up goes on the next run however recently it was
// played: archived as a "released" backup under the owner, volume deleted, and
// released for someone else to claim, with the request done with.
func TestRetireReleaseArchivesThenReleases(t *testing.T) {
r, st, cl, _ := newReaper(DefaultConfig(), retiring("alpha", "user-7", false))
sum := mustRun(t, r)
want := []string{"hold", "archive", "insert", "deletePVC", "release", "audit:" + ActionReleaseWorld, "unhold"}
if !reflect.DeepEqual(st.rec.events, want) {
t.Fatalf("call order = %v, want %v", st.rec.events, want)
}
if len(st.backups) != 1 || st.backups[0].reason != ReasonReleased || st.backups[0].owner != "user-7" {
t.Fatalf("backups = %+v, want one released archive recorded against user-7", st.backups)
}
if want := testNow.Add(DefaultConfig().Retention); !st.backups[0].expires.Equal(want) {
t.Fatalf("archive expires %v, want %v", st.backups[0].expires, want)
}
if len(st.audits) != 1 || st.audits[0].FormerOwner != "user-7" {
t.Fatalf("audits = %+v", st.audits)
}
c := st.byName["alpha"]
if c.OwnerID != "" || !c.RetireRequestedAt.IsZero() {
t.Fatalf("after release: %+v, want no owner and no pending request", c)
}
if len(cl.deletedServers) != 0 || len(st.deleted) != 0 {
t.Fatalf("a release deleted the server: %v %v", cl.deletedServers, st.deleted)
}
if sum.WorldsReaped != 1 || sum.Released != 1 || sum.ServersDeleted != 0 || sum.Skipped != 0 {
t.Fatalf("summary = %+v", sum)
}
}
// An admin's deletion archives the world the same way, then removes the
// MinecraftServer that was inspected (by uid) and only after it the row.
func TestRetireDeleteRemovesServerThenRow(t *testing.T) {
r, st, cl, _ := newReaper(DefaultConfig(), retiring("beta", "user-2", true))
sum := mustRun(t, r)
want := []string{"hold", "archive", "insert", "deletePVC", "deleteServer", "deleteRow", "audit:" + ActionDeleteServer, "unhold"}
if !reflect.DeepEqual(st.rec.events, want) {
t.Fatalf("call order = %v, want %v", st.rec.events, want)
}
if !reflect.DeepEqual(cl.deletedServers, []string{"beta/uid-beta"}) {
t.Fatalf("deleted servers = %v, want [beta/uid-beta]", cl.deletedServers)
}
if !reflect.DeepEqual(st.deleted, []string{"beta"}) || len(st.released) != 0 {
t.Fatalf("rows deleted %v released %v", st.deleted, st.released)
}
if len(st.backups) != 1 || st.backups[0].reason != ReasonReleased || st.backups[0].owner != "user-2" {
t.Fatalf("backups = %+v", st.backups)
}
if sum.WorldsReaped != 1 || sum.ServersDeleted != 1 || sum.Released != 0 {
t.Fatalf("summary = %+v", sum)
}
}
// The archive a retirement leaves must be of the world as its owner left it: one
// taken before the request (a reap waiting for its off-site copy, say) is not
// reused, one taken after it is.
func TestRetireReusesOnlyAnArchiveTakenAfterTheRequest(t *testing.T) {
c := retiring("gamma", "user-3", false)
r, st, _, ar := newReaper(DefaultConfig(), c)
st.backups = append(st.backups, &fakeBackup{id: "old", server: "gamma", ref: "ref-old", reason: ReasonInactive,
status: "present", createdAt: c.RetireRequestedAt.Add(-1), sha: "sha-ref-old"})
mustRun(t, r)
if ar.archives != 1 {
t.Fatalf("archives = %d: an archive older than the request was reused", ar.archives)
}
r, st, _, ar = newReaper(DefaultConfig(), c)
st.backups = append(st.backups, &fakeBackup{id: "new", server: "gamma", ref: "ref-new", reason: ReasonReleased,
status: "present", createdAt: c.RetireRequestedAt.Add(1), sha: "sha-ref-new"})
mustRun(t, r)
if ar.archives != 0 {
t.Fatalf("archives = %d: the archive taken after the request was not reused", ar.archives)
}
}
// A retired server that never had a world has nothing to archive: it is
// released (an unowned one too, where an idle one only restarts its clock), or
// deleted.
func TestRetireWithNoWorld(t *testing.T) {
for _, tc := range []struct {
name string
c Candidate
want []string
check func(*testing.T, *fakeStore, *fakeCluster, Summary)
}{
{"owned release", retiring("a", "user-1", false),
[]string{"hold", "release", "audit:" + ActionReleaseWorld, "unhold"},
func(t *testing.T, st *fakeStore, _ *fakeCluster, sum Summary) {
if sum.Released != 1 || sum.WorldsReaped != 0 {
t.Errorf("summary = %+v", sum)
}
}},
{"unowned release", retiring("a", "", false),
[]string{"hold", "release", "audit:" + ActionReleaseWorld, "unhold"},
func(t *testing.T, st *fakeStore, _ *fakeCluster, _ Summary) {
if !st.byName["a"].RetireRequestedAt.IsZero() {
t.Errorf("request still pending: %+v", st.byName["a"])
}
}},
{"delete", retiring("a", "user-1", true),
[]string{"hold", "deleteServer", "deleteRow", "audit:" + ActionDeleteServer, "unhold"},
func(t *testing.T, _ *fakeStore, cl *fakeCluster, sum Summary) {
if sum.ServersDeleted != 1 || sum.WorldsReaped != 0 || len(cl.deletedServers) != 1 {
t.Errorf("summary = %+v, deleted %v", sum, cl.deletedServers)
}
}},
} {
t.Run(tc.name, func(t *testing.T) {
r, st, cl, ar := newReaper(DefaultConfig(), tc.c)
cl.noWorld = map[string]bool{"world-a-0": true}
sum := mustRun(t, r)
if !reflect.DeepEqual(st.rec.events, tc.want) {
t.Fatalf("call order = %v, want %v", st.rec.events, tc.want)
}
if ar.archives != 0 || cl.deletePVCCalls != 0 {
t.Fatalf("archived %d, deleted %d PVCs of a server with no world", ar.archives, cl.deletePVCCalls)
}
tc.check(t, st, cl, sum)
})
}
}
// A deletion interrupted after the MinecraftServer went (or one removed by hand)
// is finished from the row alone, unless a world volume is still there: that is
// never deleted unarchived, and the run reports it. A release request with no
// MinecraftServer is left alone as before.
func TestRetireDeleteWithTheServerGone(t *testing.T) {
r, st, cl, _ := newReaper(DefaultConfig(), retiring("gone", "user-4", true))
delete(cl.crds, "gone")
cl.noWorld = map[string]bool{"world-gone-0": true}
sum := mustRun(t, r)
if !reflect.DeepEqual(st.rec.events, []string{"deleteRow", "audit:" + ActionDeleteServer}) || sum.ServersDeleted != 1 {
t.Fatalf("events %v, summary %+v", st.rec.events, sum)
}
r, st, cl, _ = newReaper(DefaultConfig(), retiring("gone", "user-4", true))
delete(cl.crds, "gone")
sum = mustRun(t, r)
if len(st.deleted) != 0 || sum.Skipped != 1 || sum.ServersDeleted != 0 {
t.Fatalf("with its world volume left: deleted %v, summary %+v", st.deleted, sum)
}
r, st, cl, _ = newReaper(DefaultConfig(), retiring("gone", "user-4", false))
delete(cl.crds, "gone")
sum = mustRun(t, r)
if len(st.rec.events) != 0 || sum.Skipped != 0 {
t.Fatalf("release with no server: events %v, summary %+v", st.rec.events, sum)
}
}
// A failed MinecraftServer delete keeps the row asking for its deletion, and the
// next run finishes it from the archive the first one left.
func TestRetireDeleteServerFailureIsRetried(t *testing.T) {
r, st, cl, ar := newReaper(DefaultConfig(), retiring("delta", "user-5", true))
cl.deleteServerErr = errors.New("apiserver down")
sum := mustRun(t, r)
if sum.Skipped != 1 || len(st.deleted) != 0 || st.byName["delta"] == nil || !st.byName["delta"].RetireDelete {
t.Fatalf("after a failed delete: summary %+v, deleted %v, row %+v", sum, st.deleted, st.byName["delta"])
}
cl.deleteServerErr = nil
cl.noWorld = map[string]bool{"world-delta-0": true} // the first run deleted it
sum = mustRun(t, r)
if ar.archives != 1 || !reflect.DeepEqual(st.deleted, []string{"delta"}) || sum.ServersDeleted != 1 {
t.Fatalf("retry: archives %d, deleted %v, summary %+v", ar.archives, st.deleted, sum)
}
}
// A system server is never given up, whatever its row says.
func TestRetireExemptServerIgnored(t *testing.T) {
r, st, cl, ar := newReaper(DefaultConfig(), retiring("lobby", "", true))
cl.crds["lobby"] = ServerCRD{Exempt: true, PVC: "world-lobby-0", UID: "uid-lobby"}
sum := mustRun(t, r)
if len(st.rec.events) != 0 || ar.archives != 0 || sum.Skipped != 0 {
t.Fatalf("exempt server touched: events %v, summary %+v", st.rec.events, sum)
}
}
// With the off-site copy required, a retirement waits for it like an idle reap.
func TestRetireWaitsForTheOffsiteCopy(t *testing.T) {
cfg := DefaultConfig()
cfg.RequireOffsite = true
r, st, cl, _ := newReaper(cfg, retiring("eps", "user-6", true))
sum := mustRun(t, r)
if sum.AwaitingOffsite != 1 || cl.deletePVCCalls != 0 || len(cl.deletedServers) != 0 || len(st.deleted) != 0 {
t.Fatalf("summary %+v, deletePVC %d, servers %v", sum, cl.deletePVCCalls, cl.deletedServers)
}
}