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

+2 -2
View File
@@ -144,8 +144,8 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int {
// FelisWorldJobFailed rule) reach the operator: a world that cannot be archived // FelisWorldJobFailed rule) reach the operator: a world that cannot be archived
// is kept, and without this nobody would learn that it is never reaped. // is kept, and without this nobody would learn that it is never reaped.
func reportReaperRun(sum reaper.Summary, stdout, stderr io.Writer) int { func reportReaperRun(sum reaper.Summary, stdout, stderr io.Writer) int {
fmt.Fprintf(stdout, "felis reaper: evaluated=%d reaped=%d awaiting_offsite=%d awaiting_stop=%d warned=%d skipped=%d store_full=%d evicted=%d expired=%d expire_failed=%d verified=%d corrupt=%d verify_failed=%d swept=%d orphan_archives=%d\n", fmt.Fprintf(stdout, "felis reaper: evaluated=%d reaped=%d released=%d deleted=%d awaiting_offsite=%d awaiting_stop=%d warned=%d skipped=%d store_full=%d evicted=%d expired=%d expire_failed=%d verified=%d corrupt=%d verify_failed=%d swept=%d orphan_archives=%d\n",
sum.Evaluated, sum.WorldsReaped, sum.AwaitingOffsite, sum.AwaitingStop, sum.Warned, sum.Skipped, sum.StoreFull, sum.Evaluated, sum.WorldsReaped, sum.Released, sum.ServersDeleted, sum.AwaitingOffsite, sum.AwaitingStop, sum.Warned, sum.Skipped, sum.StoreFull,
sum.EvictedEarly, sum.BackupsExpired, sum.ExpireFailed, sum.EvictedEarly, sum.BackupsExpired, sum.ExpireFailed,
sum.Verified, sum.Corrupt, sum.VerifyFailed, sum.Swept, sum.OrphanArchives) sum.Verified, sum.Corrupt, sum.VerifyFailed, sum.Swept, sum.OrphanArchives)
if !sum.Failed() { if !sum.Failed() {
+4 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -27,6 +28,7 @@ func TestReportReaperRunFailsTheJob(t *testing.T) {
want int want int
}{ }{
{"clean", reaper.Summary{Evaluated: 3, WorldsReaped: 1, AwaitingOffsite: 1}, 0}, {"clean", reaper.Summary{Evaluated: 3, WorldsReaped: 1, AwaitingOffsite: 1}, 0},
{"retirements", reaper.Summary{Evaluated: 5, WorldsReaped: 3, Released: 2, ServersDeleted: 1}, 0},
{"waiting for a stop", reaper.Summary{Evaluated: 3, AwaitingStop: 1}, 0}, {"waiting for a stop", reaper.Summary{Evaluated: 3, AwaitingStop: 1}, 0},
{"server failed", reaper.Summary{Evaluated: 3, Skipped: 1}, 1}, {"server failed", reaper.Summary{Evaluated: 3, Skipped: 1}, 1},
{"store full", reaper.Summary{Evaluated: 3, Skipped: 1, StoreFull: 1}, 1}, {"store full", reaper.Summary{Evaluated: 3, Skipped: 1, StoreFull: 1}, 1},
@@ -40,7 +42,8 @@ func TestReportReaperRunFailsTheJob(t *testing.T) {
if got := reportReaperRun(tc.sum, &out, &errb); got != tc.want { if got := reportReaperRun(tc.sum, &out, &errb); got != tc.want {
t.Errorf("%s: exit %d, want %d", tc.name, got, tc.want) t.Errorf("%s: exit %d, want %d", tc.name, got, tc.want)
} }
if !strings.Contains(out.String(), "skipped=") || !strings.Contains(out.String(), "expire_failed=") { if !strings.Contains(out.String(), "skipped=") || !strings.Contains(out.String(), "expire_failed=") ||
!strings.Contains(out.String(), fmt.Sprintf(" released=%d deleted=%d ", tc.sum.Released, tc.sum.ServersDeleted)) {
t.Errorf("%s: summary line = %q", tc.name, out.String()) t.Errorf("%s: summary line = %q", tc.name, out.String())
} }
if (tc.want == 1) != (errb.Len() > 0) { if (tc.want == 1) != (errb.Len() > 0) {
+128 -5
View File
@@ -503,6 +503,14 @@ components:
Present and true for a Failed server no automatic retry will bring up: its Present and true for a Failed server no automatic retry will bring up: its
start timed out with the retries spent, or its spec is invalid. A Failed start timed out with the retries spent, or its spec is invalid. A Failed
server without it is still in its restart backoff and may come up on its own. server without it is still in its restart backoff and may come up on its own.
reaperExempt:
type: boolean
description: Present and true for a system server the reaper never touches; it cannot be given up or deleted.
retiring:
allOf: [{ $ref: '#/components/schemas/RetireState' }]
description: >-
Owner and staff only. Present while the owner has given the server up or an
admin is deleting it; the reaper carries that out on its next run.
FleetServer: FleetServer:
description: One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView). description: One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView).
@@ -525,8 +533,9 @@ components:
claimable: claimable:
type: boolean type: boolean
description: >- description: >-
True for a live, unclaimed, non-system server, the same rule the claim True for a live, unclaimed, non-system server with no pending deletion,
route enforces. False whenever ownership is unknown. the same rule the claim route enforces. False whenever ownership is
unknown.
ownerUnknown: ownerUnknown:
type: boolean type: boolean
description: >- description: >-
@@ -540,6 +549,19 @@ components:
so the cockpit renders them read-only instead of offering actions so the cockpit renders them read-only instead of offering actions
that would 400. that would 400.
RetireState:
type: object
description: >-
A pending retirement (internal/api/repo.go RetireState): the owner gave the
server up, or with delete an admin is deleting it. The reaper carries it out
on its next daily run: it archives the world as a released backup, deletes
the world volume and releases the server, and for a deletion also removes
it. Until then the server stays stopped and cannot be woken or claimed.
required: [requested_at, delete]
properties:
requested_at: { type: string, format: date-time }
delete: { type: boolean }
AllowlistEntry: AllowlistEntry:
type: object type: object
description: >- description: >-
@@ -596,6 +618,9 @@ components:
startGaveUp: startGaveUp:
type: boolean type: boolean
description: Owned rows only. Present and true for a Failed server no automatic retry will bring up; waking it from the panel starts it over. description: Owned rows only. Present and true for a Failed server no automatic retry will bring up; waking it from the panel starts it over.
retiring:
allOf: [{ $ref: '#/components/schemas/RetireState' }]
description: Owned rows only. Present while the server is given up or being deleted.
BackupView: BackupView:
type: object type: object
@@ -610,7 +635,7 @@ components:
size_bytes: { type: integer, format: int64 } size_bytes: { type: integer, format: int64 }
reason: reason:
type: string type: string
description: inactive_15d (idle reclaim), manual (on demand), pre_restore (the safety snapshot in front of a restore) or scheduled (the daily restore point felis-api takes of a world played since its last one, once the server stops). description: inactive_15d (idle reclaim), released (the world of a server its owner gave up or an admin deleted), manual (on demand), pre_restore (the safety snapshot in front of a restore) or scheduled (the daily restore point felis-api takes of a world played since its last one, once the server stops).
status: { type: string } status: { type: string }
created_at: { type: string, format: date-time } created_at: { type: string, format: date-time }
expires_at: { type: string, format: date-time } expires_at: { type: string, format: date-time }
@@ -1240,6 +1265,8 @@ paths:
file write holds the server's world volume. start_failed: the last file write holds the server's world volume. start_failed: the last
start failed and its automatic retries are spent (ServerInfo.startGaveUp); start failed and its automatic retries are spent (ServerInfo.startGaveUp);
the server stays down until a person starts it from the panel. the server stays down until a person starts it from the panel.
server_retiring: the owner gave the server up or an admin is deleting it;
it stays down until the reaper archives it.
content: content:
application/json: application/json:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
@@ -1830,7 +1857,10 @@ paths:
'404': '404':
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
'409': '409':
description: A restore, backup or file write holds the server's world volume (maintenance_in_progress); nothing was started. description: >-
Nothing was started. maintenance_in_progress: a restore, backup or file
write holds the server's world volume. server_retiring: the server is
given up or being deleted (ServerInfo.retiring).
content: content:
application/json: application/json:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
@@ -1904,7 +1934,9 @@ paths:
'404': '404':
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
'409': '409':
description: Already claimed. description: >-
already_claimed: someone else owns it. server_retiring: the server is
being deleted.
content: content:
application/json: application/json:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
@@ -2498,6 +2530,97 @@ paths:
application/json: application/json:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
/api/v1/servers/{name}/retirement:
put:
tags: [servers]
operationId: retireServer
summary: Give the server up (owner) or delete it (admin). The reaper carries it out.
description: >-
The server is stopped and the request recorded; the reaper, the one component
that deletes a world, carries it out on its next daily run. It archives the
world as a released backup (kept for the reaper's retention, recorded against
the owner), deletes the world volume and releases the server for anyone to
claim; with delete it also removes the server, which frees its name and
subdomain. Until then the server cannot be woken or claimed and still counts
against the owner's quota, and the request can be cancelled. Repeating it
keeps the first request time, and a deletion stays a deletion. confirm must
repeat the server's name. Audited as server.release / server.delete.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [confirm]
properties:
confirm: { type: string, description: The server's name, typed back. }
delete: { type: boolean, description: Delete the server (admin only). Default false gives it up. }
responses:
'202':
description: Recorded; the server is stopped.
content:
application/json:
schema:
type: object
required: [name, retiring]
properties:
name: { type: string }
retiring: { $ref: '#/components/schemas/RetireState' }
'400':
description: A malformed body or name, or confirm does not match the name (confirm_mismatch).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: Neither the owner nor an admin, or an owner asking to delete.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'404':
$ref: '#/components/responses/NotFound'
'409':
description: >-
system_server: a system server is never given up or deleted.
world_volume_orphaned: the server is gone from the cluster but its world
volume remains, which an operator archives and removes by hand.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
delete:
tags: [servers]
operationId: cancelRetire
summary: Cancel a pending retirement. Only an admin cancels a deletion.
description: >-
The server stays stopped; its owner starts it again when they want it.
Cancelling when nothing is pending changes nothing. Audited as
server.retire_cancel.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
responses:
'204':
description: Nothing is pending any more.
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: Neither the owner nor an admin, or an owner cancelling an admin's deletion.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'404':
$ref: '#/components/responses/NotFound'
/api/v1/servers/{name}/status: /api/v1/servers/{name}/status:
get: get:
tags: [servers] tags: [servers]
+7 -4
View File
@@ -963,12 +963,15 @@ failing: `sudo felis offsite status` (§16).
Each run ends with one line: Each run ends with one line:
``` ```
felis reaper: evaluated=12 reaped=1 awaiting_offsite=0 awaiting_stop=0 warned=2 skipped=0 store_full=0 evicted=0 expired=3 expire_failed=0 verified=6 corrupt=0 verify_failed=0 swept=0 orphan_archives=0 felis reaper: evaluated=12 reaped=1 released=0 deleted=0 awaiting_offsite=0 awaiting_stop=0 warned=2 skipped=0 store_full=0 evicted=0 expired=3 expire_failed=0 verified=6 corrupt=0 verify_failed=0 swept=0 orphan_archives=0
``` ```
`skipped` counts servers the run failed on (steps 1–3 above, or the cluster or `released` and `deleted` count retirements carried out: servers their owner gave
the database answering with an error; exempt servers and rows whose CRD is gone up (or an admin released) and servers an admin deleted, each world archived
are not counted), `store_full` the subset kept because the backup store is full, first as a `released` backup. `skipped` counts servers the run failed on (steps
1–3 above, or the cluster or the database answering with an error; exempt
servers and rows whose CRD is gone are not counted, except a deletion whose
MinecraftServer is gone while its world volume remains), `store_full` the subset kept because the backup store is full,
and `expire_failed` expired backups it could not remove. `awaiting_stop` counts and `expire_failed` expired backups it could not remove. `awaiting_stop` counts
idle servers left for the next run because they were not yet down (step 0); it idle servers left for the next run because they were not yet down (step 0); it
does not fail the run, but a server that stays there for days is being started does not fail the run, but a server that stays there for days is being started
+5
View File
@@ -516,6 +516,11 @@ func (a *API) externalAPIRoutes() []apiRoute {
// access routes above (handlers_allowlist.go). // access routes above (handlers_allowlist.go).
{Method: "GET", Pattern: "/api/v1/servers/{name}/allowlist", h: a.handleAllowlistList}, {Method: "GET", Pattern: "/api/v1/servers/{name}/allowlist", h: a.handleAllowlistList},
{Method: "PUT", Pattern: "/api/v1/servers/{name}/allowlist/{uuid}", h: a.handleAllowlistSetWake}, {Method: "PUT", Pattern: "/api/v1/servers/{name}/allowlist/{uuid}", h: a.handleAllowlistSetWake},
// Retirement: the owner gives the server up, or an admin deletes it. The
// request is recorded and the reaper carries it out on its next run
// (handlers_retire.go); owner/admin-gated inside the handlers.
{Method: "PUT", Pattern: "/api/v1/servers/{name}/retirement", h: a.handleRetire},
{Method: "DELETE", Pattern: "/api/v1/servers/{name}/retirement", h: a.handleCancelRetire},
{Method: "GET", Pattern: "/api/v1/servers/{name}/status", h: a.handleStatus}, {Method: "GET", Pattern: "/api/v1/servers/{name}/status", h: a.handleStatus},
// Identity self-read (spec §14 tiering): the panel reads this once at boot to // Identity self-read (spec §14 tiering): the panel reads this once at boot to
// learn its own tier and decide which navigation surfaces to render. App-tier — // learn its own tier and decide which navigation surfaces to render. App-tier —
+24
View File
@@ -824,6 +824,30 @@ func (f *fakeRepo) SetAllowlistWake(_ context.Context, n, uuid string, canWake b
} }
return ErrNotFound return ErrNotFound
} }
// RequestRetire and CancelRetire mirror PGRepo's: the first request time is
// kept, a deletion stays a deletion, and only an admin cancels a deletion.
func (f *fakeRepo) RequestRetire(_ context.Context, n string, del bool) (RetireState, error) {
rec, ok := f.byName[n]
if !ok {
return RetireState{}, ErrNotFound
}
if rec.Retire == nil {
rec.Retire = &RetireState{RequestedAt: time.Date(2026, 9, 27, 12, 0, 0, 0, time.UTC)}
}
rec.Retire.Delete = rec.Retire.Delete || del
return *rec.Retire, nil
}
func (f *fakeRepo) CancelRetire(_ context.Context, n string, mayCancelDelete bool) error {
rec, ok := f.byName[n]
if !ok {
return ErrNotFound
}
if rec.Retire != nil && rec.Retire.Delete && !mayCancelDelete {
return ErrConflict
}
rec.Retire = nil
return nil
}
func (f *fakeRepo) UserByMCUUID(_ context.Context, uuid string) (string, error) { func (f *fakeRepo) UserByMCUUID(_ context.Context, uuid string) (string, error) {
if u, ok := f.links[uuid]; ok && !f.seededDead(u) { if u, ok := f.links[uuid]; ok && !f.seededDead(u) {
return u, nil return u, nil
+7
View File
@@ -46,6 +46,13 @@ type ServerInfo struct {
// restart backoff and may yet come up on its own. // restart backoff and may yet come up on its own.
AutoRestarts int32 `json:"autoRestarts,omitempty"` AutoRestarts int32 `json:"autoRestarts,omitempty"`
StartGaveUp bool `json:"startGaveUp,omitempty"` StartGaveUp bool `json:"startGaveUp,omitempty"`
// ReaperExempt marks a system server (spec.reaperExempt, the lobby): the
// reaper never touches it, so it cannot be given up or deleted either.
ReaperExempt bool `json:"reaperExempt,omitempty"`
// Retiring is the pending request to give the server up or delete it. It is
// business state from Postgres, joined onto the owner's and staff's view by
// the status route; the cluster never sets it.
Retiring *RetireState `json:"retiring,omitempty"`
// Resources is the spec's pod resource block. It stays off the wire; a spec // Resources is the spec's pod resource block. It stays off the wire; a spec
// patch reads it so the fields the admin left out keep their values. // patch reads it so the fields the admin left out keep their values.
Resources corev1.ResourceRequirements `json:"-"` Resources corev1.ResourceRequirements `json:"-"`
+9 -2
View File
@@ -152,6 +152,12 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err) writeError(w, r, err)
return return
} }
// Given up or being deleted: it stays down until the reaper archives it, and
// velocity tells the player so instead of queueing them.
if rec != nil && rec.Retire != nil {
writeError(w, r, errServerRetiring)
return
}
// A start whose automatic restarts are spent (or that can never succeed as // A start whose automatic restarts are spent (or that can never succeed as
// configured) stays down until a person looks at it. The 202 this used to // configured) stays down until a person looks at it. The 202 this used to
// return queued the player for a server nothing was starting. The join leaves // return queued the player for a server nothing was starting. The join leaves
@@ -305,7 +311,8 @@ func (a *API) handleInternalClaim(w http.ResponseWriter, r *http.Request) {
// the claim call's, not the menu's). The lobby uses it purely to choose between // the claim call's, not the menu's). The lobby uses it purely to choose between
// rendering `Claim & Start` (ownerless) and `Join`/`Wake` (owned). A server known // rendering `Claim & Start` (ownerless) and `Join`/`Wake` (owned). A server known
// to the cluster but missing its servers-row is treated as ownerless, so it still // to the cluster but missing its servers-row is treated as ownerless, so it still
// renders a sane tile rather than erroring. // renders a sane tile rather than erroring. A server being deleted is not claimable
// even while it has no owner.
func (a *API) handleInternalMenuStatus(w http.ResponseWriter, r *http.Request) { func (a *API) handleInternalMenuStatus(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name") name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil { if err := naming.ValidateServerName(name); err != nil {
@@ -323,7 +330,7 @@ func (a *API) handleInternalMenuStatus(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err) writeError(w, r, err)
return return
} }
if rec != nil && rec.OwnerID != "" { if rec != nil && (rec.OwnerID != "" || rec.Retire != nil) {
claimable = false claimable = false
} }
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
+158
View File
@@ -0,0 +1,158 @@
package api
import (
"errors"
"net/http"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/naming"
)
// A server leaves its owner, or the platform, through a retirement: the owner
// gives it up, or an admin asks for it to be deleted. felis-api only records the
// request and stops the server; the reaper, the one component that deletes a
// world, carries it out on its next daily run. It archives the world (a
// "released" backup kept for the reaper's retention, recorded against the owner),
// deletes the world volume and releases the server for someone else to claim, and
// for a deletion also removes the MinecraftServer and marks the servers row
// deleted, which frees the name and subdomain. Until the reaper gets to it the
// owner or an admin can cancel it; the server cannot be woken or claimed in the
// meantime, so the world the reaper archives is the one the owner left.
// retireRequest is the PUT /servers/{name}/retirement body. Confirm must repeat
// the server's name, the same typed confirmation the panel asks for, so a stray
// or replayed call cannot give a world away.
type retireRequest struct {
Confirm string `json:"confirm"`
Delete bool `json:"delete"`
}
// errServerRetiring refuses a wake or claim of a server with a pending
// retirement.
var errServerRetiring = newError(http.StatusConflict, "server_retiring",
"this server is being given up or deleted; cancel that first")
// retireServer resolves {name} for the retirement routes and applies their gate:
// 400 for a malformed name, 404 for a server that does not exist, 403 for a caller
// who neither owns it nor is an admin.
func (a *API) retireServer(w http.ResponseWriter, r *http.Request) (*ServerRecord, bool) {
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return nil, false
}
rec, err := a.Repo.ServerByName(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return nil, false
}
if !a.isOwnerOrAdmin(principalFromContext(r.Context()), rec) {
writeError(w, r, errForbidden)
return nil, false
}
return rec, true
}
// handleRetire records a retirement and stops the server. The owner may give the
// server up; deleting it is an admin's call. A system server (reaperExempt, the
// lobby) is never retired: the reaper would not touch it and the request would
// sit forever. A deletion of a server whose MinecraftServer is already gone (one
// removed with kubectl) is accepted, and the reaper then only marks its row.
func (a *API) handleRetire(w http.ResponseWriter, r *http.Request) {
rec, ok := a.retireServer(w, r)
if !ok {
return
}
p := principalFromContext(r.Context())
var req retireRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
if req.Delete && !p.IsAdmin() {
writeError(w, r, newError(http.StatusForbidden, "forbidden", "only an administrator can delete a server"))
return
}
if req.Confirm != rec.Name {
writeError(w, r, newError(http.StatusBadRequest, "confirm_mismatch",
"type the server's name to confirm"))
return
}
info, err := a.Cluster.GetServer(r.Context(), rec.Name)
switch {
case errors.Is(err, ErrNotFound) && req.Delete:
// The StatefulSet retains its claim, so a MinecraftServer removed by hand
// can leave the world volume behind, and the reaper deletes no world it
// cannot hold still to archive. That one is an operator's to deal with.
switch exists, err := a.Cluster.WorldVolumeExists(r.Context(), rec.Name); {
case err != nil:
writeError(w, r, err)
return
case exists:
writeError(w, r, newError(http.StatusConflict, "world_volume_orphaned",
"this server's MinecraftServer is gone but its world volume remains; archive and remove the volume by hand first"))
return
}
info = nil
case err != nil:
a.writeLookupError(w, r, err)
return
case info.ReaperExempt:
writeError(w, r, newError(http.StatusConflict, "system_server",
"a system server cannot be given up or deleted"))
return
}
// Stop first: a failed stop leaves nothing recorded, and a wake that lands
// after the request is refused. The reaper stops the server again before it
// touches the world, so one that slips in between costs only time.
if info != nil && info.DesiredState != string(v1alpha1.DesiredStopped) {
if err := a.Cluster.SetDesiredState(r.Context(), rec.Name, v1alpha1.DesiredStopped); err != nil {
a.writeLookupError(w, r, err)
return
}
}
st, err := a.Repo.RequestRetire(r.Context(), rec.Name, req.Delete)
if err != nil {
a.writeLookupError(w, r, err)
return
}
e := AuditEntry{Actor: auditActor(p), Action: "server.release", ServerName: rec.Name}
if st.Delete {
e.Action = "server.delete"
}
if p != nil {
e.ActorUserID = p.UserID
}
if rec.OwnerID != "" {
e.Payload = auditPayload(map[string]any{"owner_id": rec.OwnerID})
}
a.auditEntry(r, e)
writeJSON(w, http.StatusAccepted, map[string]any{"name": rec.Name, "retiring": st})
}
// handleCancelRetire drops a pending retirement. The owner may take back giving
// the server up, but a deletion an admin asked for is the admin's to cancel. The
// server stays stopped; its owner starts it again when they want it.
func (a *API) handleCancelRetire(w http.ResponseWriter, r *http.Request) {
rec, ok := a.retireServer(w, r)
if !ok {
return
}
p := principalFromContext(r.Context())
pending := rec.Retire != nil
if err := a.Repo.CancelRetire(r.Context(), rec.Name, p.IsAdmin()); err != nil {
if errors.Is(err, ErrConflict) {
writeError(w, r, newError(http.StatusForbidden, "forbidden",
"only an administrator can cancel the deletion of a server"))
return
}
a.writeLookupError(w, r, err)
return
}
if pending {
a.audit(r, "server.retire_cancel", rec.Name)
}
w.WriteHeader(http.StatusNoContent)
}
+299
View File
@@ -0,0 +1,299 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
)
var (
retireOwner = &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
retireAdmin = &Principal{UserID: "admin1", Role: "admin", ViaAdminAccess: true}
retireStranger = &Principal{UserID: "other", Email: "[email protected]", Role: "user"}
)
// retireAPI serves survival, owned by owner1 and running, to p.
func retireAPI(p *Principal) (*API, *fakeRepo, *fakeCluster) {
repo := newFakeRepo()
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
cl := newFakeCluster()
cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Running", Ready: true,
DesiredState: string(v1alpha1.DesiredRunning), AutostartPolicy: "public"}
a := newTestAPI(repo, cl)
a.External = staticExternal{p: p}
return a, repo, cl
}
func putRetire(a *API, body string) *httpResult {
return result(do(a.ExternalHandler(), "PUT", "/api/v1/servers/survival/retirement", body, jsonHeader))
}
func cancelRetire(a *API) *httpResult {
return result(do(a.ExternalHandler(), "DELETE", "/api/v1/servers/survival/retirement", "", nil))
}
// TestRetireRequest covers PUT /servers/{name}/retirement: the owner may give the
// server up and an admin may delete it, both only with the name typed back; the
// server is stopped and the request recorded for the reaper, and audited. Every
// refusal leaves the server running and nothing recorded.
func TestRetireRequest(t *testing.T) {
t.Run("owner gives the server up", func(t *testing.T) {
a, repo, cl := retireAPI(retireOwner)
res := putRetire(a, `{"confirm":"survival"}`)
if res.code != http.StatusAccepted {
t.Fatalf("code = %d (%s)", res.code, res.body)
}
var got struct {
Name string `json:"name"`
Retiring RetireState `json:"retiring"`
}
if err := json.Unmarshal([]byte(res.body), &got); err != nil || got.Name != "survival" ||
got.Retiring.Delete || got.Retiring.RequestedAt.IsZero() {
t.Fatalf("body = %s (%v)", res.body, err)
}
if cl.desired["survival"] != v1alpha1.DesiredStopped {
t.Fatalf("desiredState = %q, want Stopped", cl.desired["survival"])
}
if r := repo.byName["survival"].Retire; r == nil || r.Delete {
t.Fatalf("recorded = %+v, want a release", r)
}
if len(repo.audits) != 1 || repo.audits[0].Action != "server.release" || repo.audits[0].ActorUserID != "owner1" ||
!strings.Contains(string(repo.audits[0].Payload), `"owner_id":"owner1"`) {
t.Fatalf("audits = %+v", repo.audits)
}
})
t.Run("admin deletes the server", func(t *testing.T) {
a, repo, cl := retireAPI(retireAdmin)
res := putRetire(a, `{"confirm":"survival","delete":true}`)
if res.code != http.StatusAccepted || !strings.Contains(res.body, `"delete":true`) {
t.Fatalf("code = %d (%s)", res.code, res.body)
}
if cl.desired["survival"] != v1alpha1.DesiredStopped {
t.Fatalf("desiredState = %q, want Stopped", cl.desired["survival"])
}
if r := repo.byName["survival"].Retire; r == nil || !r.Delete {
t.Fatalf("recorded = %+v, want a deletion", r)
}
if len(repo.audits) != 1 || repo.audits[0].Action != "server.delete" || repo.audits[0].ActorUserID != "admin1" {
t.Fatalf("audits = %+v", repo.audits)
}
})
t.Run("a server whose MinecraftServer is gone can still be deleted", func(t *testing.T) {
a, repo, cl := retireAPI(retireAdmin)
delete(cl.byName, "survival")
if res := putRetire(a, `{"confirm":"survival","delete":true}`); res.code != http.StatusAccepted {
t.Fatalf("code = %d (%s)", res.code, res.body)
}
if _, set := cl.desired["survival"]; set || repo.byName["survival"].Retire == nil {
t.Fatalf("desired %v, recorded %+v", cl.desired, repo.byName["survival"].Retire)
}
})
for _, tc := range []struct {
name string
p *Principal
body string
setup func(*fakeRepo, *fakeCluster)
code int
err string
}{
{"owner may not delete", retireOwner, `{"confirm":"survival","delete":true}`, nil, http.StatusForbidden, "forbidden"},
{"stranger", retireStranger, `{"confirm":"survival"}`, nil, http.StatusForbidden, "forbidden"},
{"name not typed back", retireOwner, `{"confirm":"Survival"}`, nil, http.StatusBadRequest, "confirm_mismatch"},
{"no confirmation", retireAdmin, `{"delete":true}`, nil, http.StatusBadRequest, "confirm_mismatch"},
{"system server", retireAdmin, `{"confirm":"survival","delete":true}`,
func(_ *fakeRepo, cl *fakeCluster) { cl.byName["survival"].ReaperExempt = true },
http.StatusConflict, "system_server"},
{"unknown server", retireAdmin, `{"confirm":"survival","delete":true}`,
func(repo *fakeRepo, _ *fakeCluster) { delete(repo.byName, "survival") },
http.StatusNotFound, "not_found"},
{"release of a server with no MinecraftServer", retireAdmin, `{"confirm":"survival"}`,
func(_ *fakeRepo, cl *fakeCluster) { delete(cl.byName, "survival") },
http.StatusNotFound, "not_found"},
{"world volume left without its server", retireAdmin, `{"confirm":"survival","delete":true}`,
func(_ *fakeRepo, cl *fakeCluster) {
delete(cl.byName, "survival")
cl.orphanWorld = map[string]bool{"survival": true}
},
http.StatusConflict, "world_volume_orphaned"},
} {
t.Run(tc.name, func(t *testing.T) {
a, repo, cl := retireAPI(tc.p)
if tc.setup != nil {
tc.setup(repo, cl)
}
res := putRetire(a, tc.body)
if res.code != tc.code || res.errCode() != tc.err {
t.Fatalf("code = %d %q, want %d %q (%s)", res.code, res.errCode(), tc.code, tc.err, res.body)
}
if _, set := cl.desired["survival"]; set {
t.Fatal("a refused request stopped the server")
}
if rec := repo.byName["survival"]; rec != nil && rec.Retire != nil {
t.Fatalf("a refused request was recorded: %+v", rec.Retire)
}
if len(repo.audits) != 0 {
t.Fatalf("a refused request was audited: %+v", repo.audits)
}
})
}
}
// TestRetireCancel covers DELETE /servers/{name}/retirement: the owner takes back
// giving the server up, but only an admin cancels a deletion.
func TestRetireCancel(t *testing.T) {
pending := func(repo *fakeRepo, del bool) {
repo.byName["survival"].Retire = &RetireState{RequestedAt: time.Now(), Delete: del}
}
for _, tc := range []struct {
name string
p *Principal
del bool
code int
cleared bool
}{
{"owner cancels giving it up", retireOwner, false, http.StatusNoContent, true},
{"owner may not cancel a deletion", retireOwner, true, http.StatusForbidden, false},
{"admin cancels a deletion", retireAdmin, true, http.StatusNoContent, true},
{"stranger", retireStranger, false, http.StatusForbidden, false},
} {
t.Run(tc.name, func(t *testing.T) {
a, repo, _ := retireAPI(tc.p)
pending(repo, tc.del)
res := cancelRetire(a)
if res.code != tc.code {
t.Fatalf("code = %d, want %d (%s)", res.code, tc.code, res.body)
}
if cleared := repo.byName["survival"].Retire == nil; cleared != tc.cleared {
t.Fatalf("cleared = %v, want %v", cleared, tc.cleared)
}
audited := len(repo.audits) == 1 && repo.audits[0].Action == "server.retire_cancel"
if audited != tc.cleared || (!tc.cleared && len(repo.audits) != 0) {
t.Fatalf("audits = %+v", repo.audits)
}
})
}
t.Run("nothing pending is a quiet no-op", func(t *testing.T) {
a, repo, _ := retireAPI(retireOwner)
if res := cancelRetire(a); res.code != http.StatusNoContent || len(repo.audits) != 0 {
t.Fatalf("code = %d, audits %+v", res.code, repo.audits)
}
})
}
// A server with a pending retirement stays as its owner left it until the reaper
// archives it: no face wakes or claims it, the lobby does not offer it, and the
// owner's and admin's views say it is going.
func TestRetiringServerIsFrozen(t *testing.T) {
t.Run("external wake", func(t *testing.T) {
a, repo, cl := retireAPI(retireOwner)
cl.byName["survival"].DesiredState = string(v1alpha1.DesiredStopped)
cl.byName["survival"].Phase, cl.byName["survival"].Ready = "Stopped", false
repo.byName["survival"].Retire = &RetireState{RequestedAt: time.Now()}
res := result(do(a.ExternalHandler(), "POST", "/api/v1/servers/survival/wake", "", nil))
if res.code != http.StatusConflict || res.errCode() != "server_retiring" {
t.Fatalf("code = %d %q (%s)", res.code, res.errCode(), res.body)
}
if _, set := cl.desired["survival"]; set {
t.Fatal("the wake went through")
}
})
t.Run("internal wake", func(t *testing.T) {
a, repo, cl := retireAPI(nil)
cl.byName["survival"].DesiredState = string(v1alpha1.DesiredStopped)
cl.byName["survival"].Phase, cl.byName["survival"].Ready = "Stopped", false
repo.byName["survival"].Retire = &RetireState{RequestedAt: time.Now()}
res := result(internalWake(a, `{"mc_uuid":"`+wakeUUID+`"}`))
if res.code != http.StatusConflict || res.errCode() != "server_retiring" {
t.Fatalf("code = %d %q (%s)", res.code, res.errCode(), res.body)
}
if _, set := cl.desired["survival"]; set {
t.Fatal("the wake went through")
}
})
t.Run("claim of an unowned server being deleted", func(t *testing.T) {
a, repo, _ := retireAPI(retireStranger)
repo.byName["survival"] = &ServerRecord{Name: "survival", Retire: &RetireState{RequestedAt: time.Now(), Delete: true}}
repo.linked["other"], repo.quota["other"], repo.claimOK["survival"] = true, true, true
res := result(do(a.ExternalHandler(), "POST", "/api/v1/servers/survival/claim", "", nil))
if res.code != http.StatusConflict || res.errCode() != "server_retiring" {
t.Fatalf("code = %d %q (%s)", res.code, res.errCode(), res.body)
}
if len(repo.audits) != 0 {
t.Fatalf("audits = %+v", repo.audits)
}
})
t.Run("lobby menu", func(t *testing.T) {
a, repo, _ := retireAPI(nil)
repo.byName["survival"] = &ServerRecord{Name: "survival", Retire: &RetireState{RequestedAt: time.Now(), Delete: true}}
res := result(internalMenu(a))
if res.code != http.StatusOK || !strings.Contains(res.body, `"claimable":false`) {
t.Fatalf("code = %d (%s)", res.code, res.body)
}
})
t.Run("status shows the request to the owner only", func(t *testing.T) {
for _, tc := range []struct {
p *Principal
sees bool
}{{retireOwner, true}, {retireAdmin, true}, {retireStranger, false}} {
a, repo, _ := retireAPI(tc.p)
repo.byName["survival"].Retire = &RetireState{RequestedAt: time.Now()}
res := result(do(a.ExternalHandler(), "GET", "/api/v1/servers/survival/status", "", nil))
if res.code != http.StatusOK || strings.Contains(res.body, `"retiring"`) != tc.sees {
t.Fatalf("%s: code = %d (%s)", tc.p.UserID, res.code, res.body)
}
}
})
t.Run("fleet", func(t *testing.T) {
a, repo, cl := retireAPI(retireAdmin)
cl.list = []ServerInfo{{Name: "survival", Phase: "Stopped"}, {Name: "creative", Phase: "Stopped"}}
repo.owners["survival"] = ServerOwnership{Retire: &RetireState{RequestedAt: time.Now(), Delete: true}}
repo.owners["creative"] = ServerOwnership{}
res := result(do(a.ExternalHandler(), "GET", "/api/v1/fleet", "", nil))
var got struct {
Servers []fleetServerView `json:"servers"`
}
if res.code != http.StatusOK || json.Unmarshal([]byte(res.body), &got) != nil || len(got.Servers) != 2 {
t.Fatalf("code = %d (%s)", res.code, res.body)
}
if s := got.Servers[0]; s.Claimable || s.Retiring == nil || !s.Retiring.Delete {
t.Fatalf("survival = %+v, want retiring and not claimable", s)
}
if s := got.Servers[1]; !s.Claimable || s.Retiring != nil {
t.Fatalf("creative = %+v, want claimable", s)
}
})
}
type httpResult struct {
code int
body string
}
func result(w *httptest.ResponseRecorder) *httpResult {
return &httpResult{code: w.Code, body: w.Body.String()}
}
// errCode is the error envelope's code, "" for a body that is not one.
func (r *httpResult) errCode() string {
var env struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
_ = json.Unmarshal([]byte(r.body), &env)
return env.Error.Code
}
+21 -3
View File
@@ -39,6 +39,12 @@ func (a *API) handleWake(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err) writeError(w, r, err)
return return
} }
// A server its owner gave up, or an admin is deleting, stays stopped until the
// reaper archives it: a start would change the world it archives.
if rec != nil && rec.Retire != nil {
writeError(w, r, errServerRetiring)
return
}
if !a.limiter().allowed(name, a.WakeCooldown) { if !a.limiter().allowed(name, a.WakeCooldown) {
writeError(w, r, newError(http.StatusTooManyRequests, "cooldown", "wake is cooling down, retry shortly")) writeError(w, r, newError(http.StatusTooManyRequests, "cooldown", "wake is cooling down, retry shortly"))
return return
@@ -135,6 +141,12 @@ func (a *API) handleClaim(w http.ResponseWriter, r *http.Request) {
"link your Minecraft account before claiming (see /api/v1/account/link/start)")) "link your Minecraft account before claiming (see /api/v1/account/link/start)"))
return return
} }
// A server with a pending deletion is not claimable (ClaimServer refuses it
// too); saying why beats the 409 already_claimed that would otherwise explain it.
if rec, err := a.Repo.ServerByName(r.Context(), name); err == nil && rec.Retire != nil {
writeError(w, r, errServerRetiring)
return
}
// ② quota gate, evaluated before the ownership write. All four dimensions // ② quota gate, evaluated before the ownership write. All four dimensions
// (servers, CPU, memory, storage) are checked against the user's quota caps // (servers, CPU, memory, storage) are checked against the user's quota caps
@@ -224,6 +236,11 @@ func (a *API) handleStatus(w http.ResponseWriter, r *http.Request) {
} }
if !a.isOwnerOrAdmin(p, rec) { if !a.isOwnerOrAdmin(p, rec) {
info = publicServerInfo(info) info = publicServerInfo(info)
} else if rec != nil && rec.Retire != nil {
// A pending retirement is Postgres state the cluster does not hold.
withRetire := *info
withRetire.Retiring = rec.Retire
info = &withRetire
} }
} }
writeJSON(w, http.StatusOK, info) writeJSON(w, http.StatusOK, info)
@@ -348,9 +365,10 @@ func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) {
for i, s := range servers { for i, s := range servers {
o, known := owners[s.Name] o, known := owners[s.Name]
system := naming.IsSystemServer(s.Name) system := naming.IsSystemServer(s.Name)
s.Retiring = o.Retire
views[i] = fleetServerView{ServerInfo: s, Owner: o.Owner, System: system, views[i] = fleetServerView{ServerInfo: s, Owner: o.Owner, System: system,
Owned: o.OwnerID != "" && o.OwnerID == p.UserID, Owned: o.OwnerID != "" && o.OwnerID == p.UserID,
Claimable: known && o.OwnerID == "" && !system, Claimable: known && o.OwnerID == "" && o.Retire == nil && !system,
OwnerUnknown: unknown} OwnerUnknown: unknown}
} }
writeJSON(w, http.StatusOK, map[string]any{"servers": views}) writeJSON(w, http.StatusOK, map[string]any{"servers": views})
@@ -369,8 +387,8 @@ type fleetServerView struct {
// Owned is true when the caller claimed this server, decided by account id so // Owned is true when the caller claimed this server, decided by account id so
// an owner without an email is still recognized. // an owner without an email is still recognized.
Owned bool `json:"owned"` Owned bool `json:"owned"`
// Claimable is true for a live, unclaimed, non-system server: the same rule // Claimable is true for a live, unclaimed, non-system server with no pending
// ClaimServer enforces. It is false whenever ownership is unknown. // deletion: the same rule ClaimServer enforces. It is false whenever ownership is unknown.
Claimable bool `json:"claimable"` Claimable bool `json:"claimable"`
// OwnerUnknown is true when the best-effort owner lookup failed, so an empty // OwnerUnknown is true when the best-effort owner lookup failed, so an empty
// Owner says nothing about whether the server is claimed. // Owner says nothing about whether the server is claimed.
+1
View File
@@ -468,6 +468,7 @@ func serverInfo(ms *v1alpha1.MinecraftServer) *ServerInfo {
LegacyForwarding: ms.Labels[v1alpha1.LabelForwarding] == v1alpha1.ForwardingLegacy, LegacyForwarding: ms.Labels[v1alpha1.LabelForwarding] == v1alpha1.ForwardingLegacy,
AutoRestarts: ms.Status.AutoRestarts, AutoRestarts: ms.Status.AutoRestarts,
StartGaveUp: v1alpha1.StartGaveUp(&ms.Status), StartGaveUp: v1alpha1.StartGaveUp(&ms.Status),
ReaperExempt: ms.Spec.ReaperExempt,
Resources: *ms.Spec.Resources.DeepCopy(), Resources: *ms.Spec.Resources.DeepCopy(),
} }
} }
+21
View File
@@ -323,6 +323,27 @@ func TestServerInfoCarriesStartGaveUp(t *testing.T) {
} }
} }
// A system server reaches the panel marked, so the console offers no give-up or
// delete the API would refuse; every other server leaves the field out.
func TestServerInfoCarriesReaperExempt(t *testing.T) {
lobby := testServer("lobby", "lobby")
lobby.Spec.ReaperExempt = true
for _, tc := range []struct {
ms *v1alpha1.MinecraftServer
want bool
}{{lobby, true}, {testServer("plain", "plain"), false}} {
info := serverInfo(tc.ms)
b, err := json.Marshal(info)
if err != nil {
t.Fatal(err)
}
if info.ReaperExempt != tc.want || strings.Contains(string(b), `"reaperExempt":true`) != tc.want ||
(!tc.want && strings.Contains(string(b), "reaperExempt")) {
t.Errorf("%s: reaperExempt = %v, JSON %s; want %v", tc.ms.Name, info.ReaperExempt, b, tc.want)
}
}
}
// An admin edits one resource at a time. The view hands the handler the whole // An admin edits one resource at a time. The view hands the handler the whole
// pod block, the handler lays the change over it, and the merge patch keeps // pod block, the handler lays the change over it, and the merge patch keeps
// everything the admin left alone: memory-only keeps the CPU limit, CPU-only // everything the admin left alone: memory-only keeps the CPU limit, CPU-only
+104 -14
View File
@@ -21,33 +21,49 @@ func NewPGRepo(db *sql.DB) *PGRepo { return &PGRepo{db: db} }
func (p *PGRepo) Ping(ctx context.Context) error { return p.db.PingContext(ctx) } func (p *PGRepo) Ping(ctx context.Context) error { return p.db.PingContext(ctx) }
func (p *PGRepo) ServerBySubdomain(ctx context.Context, subdomain string) (*ServerRecord, error) { func (p *PGRepo) ServerBySubdomain(ctx context.Context, subdomain string) (*ServerRecord, error) {
const q = `SELECT s.name, sa.subdomain, COALESCE(s.owner_id, ''), COALESCE(s.cached_phase, '') const q = `SELECT s.name, sa.subdomain, COALESCE(s.owner_id, ''), COALESCE(s.cached_phase, ''),
s.retire_requested_at, s.retire_delete
FROM server_aliases sa JOIN servers s ON s.name = sa.server_name FROM server_aliases sa JOIN servers s ON s.name = sa.server_name
WHERE sa.subdomain = $1 AND s.deleted_at IS NULL` WHERE sa.subdomain = $1 AND s.deleted_at IS NULL`
var r ServerRecord var r ServerRecord
switch err := p.db.QueryRowContext(ctx, q, subdomain).Scan(&r.Name, &r.Subdomain, &r.OwnerID, &r.CachedPhase); { var retireAt sql.NullTime
var retireDelete bool
switch err := p.db.QueryRowContext(ctx, q, subdomain).Scan(&r.Name, &r.Subdomain, &r.OwnerID, &r.CachedPhase, &retireAt, &retireDelete); {
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound return nil, ErrNotFound
case err != nil: case err != nil:
return nil, err return nil, err
} }
r.Retire = retireState(retireAt, retireDelete)
return &r, nil return &r, nil
} }
func (p *PGRepo) ServerByName(ctx context.Context, name string) (*ServerRecord, error) { func (p *PGRepo) ServerByName(ctx context.Context, name string) (*ServerRecord, error) {
const q = `SELECT s.name, COALESCE(sa.subdomain, ''), COALESCE(s.owner_id, ''), COALESCE(s.cached_phase, '') const q = `SELECT s.name, COALESCE(sa.subdomain, ''), COALESCE(s.owner_id, ''), COALESCE(s.cached_phase, ''),
s.retire_requested_at, s.retire_delete
FROM servers s LEFT JOIN server_aliases sa ON sa.server_name = s.name FROM servers s LEFT JOIN server_aliases sa ON sa.server_name = s.name
WHERE s.name = $1 AND s.deleted_at IS NULL` WHERE s.name = $1 AND s.deleted_at IS NULL`
var r ServerRecord var r ServerRecord
switch err := p.db.QueryRowContext(ctx, q, name).Scan(&r.Name, &r.Subdomain, &r.OwnerID, &r.CachedPhase); { var retireAt sql.NullTime
var retireDelete bool
switch err := p.db.QueryRowContext(ctx, q, name).Scan(&r.Name, &r.Subdomain, &r.OwnerID, &r.CachedPhase, &retireAt, &retireDelete); {
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound return nil, ErrNotFound
case err != nil: case err != nil:
return nil, err return nil, err
} }
r.Retire = retireState(retireAt, retireDelete)
return &r, nil return &r, nil
} }
// retireState is a servers row's pending retirement, nil when there is none.
func retireState(at sql.NullTime, deleteServer bool) *RetireState {
if !at.Valid {
return nil
}
return &RetireState{RequestedAt: at.Time, Delete: deleteServer}
}
func (p *PGRepo) IsLinked(ctx context.Context, userID string) (bool, error) { func (p *PGRepo) IsLinked(ctx context.Context, userID string) (bool, error) {
var ok bool var ok bool
err := p.db.QueryRowContext(ctx, err := p.db.QueryRowContext(ctx,
@@ -443,11 +459,12 @@ func (p *PGRepo) ClaimServer(ctx context.Context, name, userID string) (bool, er
} }
var owned sql.NullString var owned sql.NullString
var retiring bool
var cpu, mem, stor int var cpu, mem, stor int
switch err := tx.QueryRowContext(ctx, switch err := tx.QueryRowContext(ctx,
`SELECT owner_id, cached_cpu_milli, cached_memory_mb, cached_storage_mb `SELECT owner_id, retire_requested_at IS NOT NULL, cached_cpu_milli, cached_memory_mb, cached_storage_mb
FROM servers WHERE name = $1 AND deleted_at IS NULL FOR UPDATE`, FROM servers WHERE name = $1 AND deleted_at IS NULL FOR UPDATE`,
name).Scan(&owned, &cpu, &mem, &stor); { name).Scan(&owned, &retiring, &cpu, &mem, &stor); {
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
return false, ErrNotFound return false, ErrNotFound
case err != nil: case err != nil:
@@ -456,6 +473,12 @@ func (p *PGRepo) ClaimServer(ctx context.Context, name, userID string) (bool, er
if owned.Valid { if owned.Valid {
return false, nil // already claimed → 409 at the handler return false, nil // already claimed → 409 at the handler
} }
// An unowned server an admin is releasing or deleting: its world is about to
// be archived and deleted, or the server itself removed. The handler refuses
// it up front; this holds against a request that lands in between.
if retiring {
return false, nil
}
// The four-dimension gate, re-run inside the transaction. A missing quota // The four-dimension gate, re-run inside the transaction. A missing quota
// row leaves every NullInt64 invalid → quotaAllows treats each dimension as // row leaves every NullInt64 invalid → quotaAllows treats each dimension as
@@ -481,7 +504,7 @@ func (p *PGRepo) ClaimServer(ctx context.Context, name, userID string) (bool, er
res, err := tx.ExecContext(ctx, res, err := tx.ExecContext(ctx,
`UPDATE servers SET owner_id = $2, claimed_at = now(), last_active_at = now(), warned_3d_at = NULL, warned_1d_at = NULL `UPDATE servers SET owner_id = $2, claimed_at = now(), last_active_at = now(), warned_3d_at = NULL, warned_1d_at = NULL
WHERE name = $1 AND owner_id IS NULL AND deleted_at IS NULL`, WHERE name = $1 AND owner_id IS NULL AND deleted_at IS NULL AND retire_requested_at IS NULL`,
name, userID) name, userID)
if err != nil { if err != nil {
return false, err return false, err
@@ -597,6 +620,54 @@ func (p *PGRepo) SetAllowlistWake(ctx context.Context, name, mcUUID string, canW
return nil return nil
} }
// RequestRetire records the server's retirement for the reaper to carry out.
// Asking again keeps the first request time, so the panel's "since" stays true,
// and a deletion once asked for is not turned back into a release by the owner
// asking too.
func (p *PGRepo) RequestRetire(ctx context.Context, name string, deleteServer bool) (RetireState, error) {
var st RetireState
switch err := p.db.QueryRowContext(ctx,
`UPDATE servers
SET retire_requested_at = COALESCE(retire_requested_at, now()),
retire_delete = retire_delete OR $2
WHERE name = $1 AND deleted_at IS NULL
RETURNING retire_requested_at, retire_delete`, name, deleteServer).Scan(&st.RequestedAt, &st.Delete); {
case errors.Is(err, sql.ErrNoRows):
return RetireState{}, ErrNotFound
case err != nil:
return RetireState{}, err
}
return st, nil
}
// CancelRetire drops the server's pending retirement. The row is locked while
// the deletion flag is read, so an owner cannot cancel a deletion an admin asked
// for in between.
func (p *PGRepo) CancelRetire(ctx context.Context, name string, mayCancelDelete bool) error {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck // no-op after commit
var deleteServer bool
switch err := tx.QueryRowContext(ctx,
`SELECT retire_delete FROM servers WHERE name = $1 AND deleted_at IS NULL FOR UPDATE`,
name).Scan(&deleteServer); {
case errors.Is(err, sql.ErrNoRows):
return ErrNotFound
case err != nil:
return err
}
if deleteServer && !mayCancelDelete {
return ErrConflict
}
if _, err := tx.ExecContext(ctx,
`UPDATE servers SET retire_requested_at = NULL, retire_delete = false WHERE name = $1`, name); err != nil {
return err
}
return tx.Commit()
}
// UserByMCUUID resolves a verified in-game UUID to its linked user_id (spec §10 // UserByMCUUID resolves a verified in-game UUID to its linked user_id (spec §10
// account_links), or ErrNotFound when the UUID is not linked to any account. A // account_links), or ErrNotFound when the UUID is not linked to any account. A
// link whose account is dead reads the same as no link at all (audit #33), so the // link whose account is dead reads the same as no link at all (audit #33), so the
@@ -647,9 +718,13 @@ func (p *PGRepo) RecordJoin(ctx context.Context, name, mcUUID string) error {
return tx.Commit() return tx.Commit()
} }
// MyServers lists the servers the user owns, each with its pending retirement,
// and the unowned ones they may claim. An unowned server an admin is releasing or
// deleting is listed but not claimable.
func (p *PGRepo) MyServers(ctx context.Context, userID string) ([]MyServerView, error) { func (p *PGRepo) MyServers(ctx context.Context, userID string) ([]MyServerView, error) {
const q = `SELECT s.name, COALESCE(sa.subdomain, ''), const q = `SELECT s.name, COALESCE(sa.subdomain, ''),
COALESCE(s.owner_id = $1, false) AS owned, (s.owner_id IS NULL) AS claimable, COALESCE(s.cached_phase, '') COALESCE(s.owner_id = $1, false) AS owned, (s.owner_id IS NULL AND s.retire_requested_at IS NULL) AS claimable,
COALESCE(s.cached_phase, ''), s.retire_requested_at, s.retire_delete
FROM servers s LEFT JOIN server_aliases sa ON sa.server_name = s.name FROM servers s LEFT JOIN server_aliases sa ON sa.server_name = s.name
WHERE s.deleted_at IS NULL AND (s.owner_id = $1 OR s.owner_id IS NULL) WHERE s.deleted_at IS NULL AND (s.owner_id = $1 OR s.owner_id IS NULL)
ORDER BY s.name` ORDER BY s.name`
@@ -661,9 +736,14 @@ func (p *PGRepo) MyServers(ctx context.Context, userID string) ([]MyServerView,
var out []MyServerView var out []MyServerView
for rows.Next() { for rows.Next() {
var v MyServerView var v MyServerView
if err := rows.Scan(&v.Name, &v.Subdomain, &v.Owned, &v.Claimable, &v.Phase); err != nil { var retireAt sql.NullTime
var retireDelete bool
if err := rows.Scan(&v.Name, &v.Subdomain, &v.Owned, &v.Claimable, &v.Phase, &retireAt, &retireDelete); err != nil {
return nil, err return nil, err
} }
if v.Owned {
v.Retiring = retireState(retireAt, retireDelete)
}
out = append(out, v) out = append(out, v)
} }
return out, rows.Err() return out, rows.Err()
@@ -676,7 +756,8 @@ func (p *PGRepo) MyServers(ctx context.Context, userID string) ([]MyServerView,
// log records as the human actor, §6) and falls back to the never-NULL username // log records as the human actor, §6) and falls back to the never-NULL username
// when the address is absent. // when the address is absent.
func (p *PGRepo) ServerOwners(ctx context.Context) (map[string]ServerOwnership, error) { func (p *PGRepo) ServerOwners(ctx context.Context) (map[string]ServerOwnership, error) {
const q = `SELECT s.name, COALESCE(s.owner_id, ''), COALESCE(NULLIF(u.email, ''), u.username, '') const q = `SELECT s.name, COALESCE(s.owner_id, ''), COALESCE(NULLIF(u.email, ''), u.username, ''),
s.retire_requested_at, s.retire_delete
FROM servers s LEFT JOIN users u ON u.id = s.owner_id FROM servers s LEFT JOIN users u ON u.id = s.owner_id
WHERE s.deleted_at IS NULL` WHERE s.deleted_at IS NULL`
rows, err := p.db.QueryContext(ctx, q) rows, err := p.db.QueryContext(ctx, q)
@@ -688,9 +769,12 @@ func (p *PGRepo) ServerOwners(ctx context.Context) (map[string]ServerOwnership,
for rows.Next() { for rows.Next() {
var name string var name string
var o ServerOwnership var o ServerOwnership
if err := rows.Scan(&name, &o.OwnerID, &o.Owner); err != nil { var retireAt sql.NullTime
var retireDelete bool
if err := rows.Scan(&name, &o.OwnerID, &o.Owner, &retireAt, &retireDelete); err != nil {
return nil, err return nil, err
} }
o.Retire = retireState(retireAt, retireDelete)
out[name] = o out[name] = o
} }
return out, rows.Err() return out, rows.Err()
@@ -701,9 +785,11 @@ func (p *PGRepo) ServerOwners(ctx context.Context) (map[string]ServerOwnership,
// claimed later, spec §9.3) and its subdomain alias. The create handler has // claimed later, spec §9.3) and its subdomain alias. The create handler has
// already found no server and no world volume of this name, so a row that is // already found no server and no world volume of this name, so a row that is
// here belongs to an earlier server of the same name: one removed with kubectl, // here belongs to an earlier server of the same name: one removed with kubectl,
// or a create whose CRD write failed. That row starts over, and the earlier // a create whose CRD write failed, or one the reaper deleted between removing
// its MinecraftServer and marking the row. That row starts over, and the earlier
// server's other aliases and allowlist go with it; nothing of its owner, claim, // server's other aliases and allowlist go with it; nothing of its owner, claim,
// activity clock or reaper warnings reaches the new server. A retried create // activity clock, reaper warnings or pending retirement reaches the new server,
// so the reaper's unfinished deletion no longer applies to it. A retried create
// lands on the same fresh state. The alias subdomain is a PRIMARY KEY: bound to // lands on the same fresh state. The alias subdomain is a PRIMARY KEY: bound to
// another server, it rolls the whole seed back and returns ErrConflict, letting // another server, it rolls the whole seed back and returns ErrConflict, letting
// the create handler answer 409 before it touches the CRD. // the create handler answer 409 before it touches the CRD.
@@ -722,6 +808,7 @@ func (p *PGRepo) SeedServer(ctx context.Context, name, subdomain string, cpuMill
`INSERT INTO servers (name, cached_cpu_milli, cached_memory_mb, cached_storage_mb) VALUES ($1, $2, $3, $4) `INSERT INTO servers (name, cached_cpu_milli, cached_memory_mb, cached_storage_mb) VALUES ($1, $2, $3, $4)
ON CONFLICT (name) DO UPDATE SET owner_id = NULL, claimed_at = NULL, last_active_at = now(), ON CONFLICT (name) DO UPDATE SET owner_id = NULL, claimed_at = NULL, last_active_at = now(),
warned_3d_at = NULL, warned_1d_at = NULL, cached_phase = NULL, created_at = now(), deleted_at = NULL, warned_3d_at = NULL, warned_1d_at = NULL, cached_phase = NULL, created_at = now(), deleted_at = NULL,
retire_requested_at = NULL, retire_delete = false,
cached_cpu_milli = EXCLUDED.cached_cpu_milli, cached_memory_mb = EXCLUDED.cached_memory_mb, cached_cpu_milli = EXCLUDED.cached_cpu_milli, cached_memory_mb = EXCLUDED.cached_memory_mb,
cached_storage_mb = EXCLUDED.cached_storage_mb`, cached_storage_mb = EXCLUDED.cached_storage_mb`,
name, cpuMilli, memoryMB, storageMB); err != nil { name, cpuMilli, memoryMB, storageMB); err != nil {
@@ -865,7 +952,9 @@ func (p *PGRepo) BackupStoreBytes(ctx context.Context) (int64, error) {
// world's point is its current owner's newest intact scheduled backup taken // world's point is its current owner's newest intact scheduled backup taken
// since they claimed it: a previous owner's backups say nothing about the world // since they claimed it: a previous owner's backups say nothing about the world
// the new owner has built, and a corrupt one restores nothing. last_active_at // the new owner has built, and a corrupt one restores nothing. last_active_at
// moves on every join, so a world nobody joined since its point is skipped. // moves on every join, so a world nobody joined since its point is skipped. A
// world being given up is skipped too: the reaper archives it anyway, and a
// backup Job holding it when the reaper runs would put the release off a day.
func (p *PGRepo) ScheduledBackupCandidates(ctx context.Context, before time.Time) ([]ScheduledCandidate, error) { func (p *PGRepo) ScheduledBackupCandidates(ctx context.Context, before time.Time) ([]ScheduledCandidate, error) {
rows, err := p.db.QueryContext(ctx, rows, err := p.db.QueryContext(ctx,
`SELECT s.name, s.owner_id FROM servers s `SELECT s.name, s.owner_id FROM servers s
@@ -876,6 +965,7 @@ func (p *PGRepo) ScheduledBackupCandidates(ctx context.Context, before time.Time
AND b.created_at >= COALESCE(s.claimed_at, '-infinity') AND b.created_at >= COALESCE(s.claimed_at, '-infinity')
) pt ON true ) pt ON true
WHERE s.deleted_at IS NULL AND s.owner_id IS NOT NULL WHERE s.deleted_at IS NULL AND s.owner_id IS NOT NULL
AND s.retire_requested_at IS NULL
AND s.last_active_at > COALESCE(pt.at, '-infinity') AND s.last_active_at > COALESCE(pt.at, '-infinity')
AND COALESCE(pt.at, '-infinity') < $1 AND COALESCE(pt.at, '-infinity') < $1
ORDER BY pt.at NULLS FIRST, s.name`, before) ORDER BY pt.at NULLS FIRST, s.name`, before)
+28
View File
@@ -15,6 +15,19 @@ type ServerRecord struct {
OwnerID string OwnerID string
// CachedPhase is the non-authoritative phase projection used for fast lists. // CachedPhase is the non-authoritative phase projection used for fast lists.
CachedPhase string CachedPhase string
// Retire is the pending request to give the server up or delete it, nil when
// there is none (PUT /servers/{name}/retirement).
Retire *RetireState
}
// RetireState is a server's pending retirement: its owner gave it up, or an admin
// asked for it to be deleted (Delete). The reaper carries it out on its next run
// (archive the world, delete the volume, release the server, and with Delete also
// remove the server itself); until then the server stays stopped and cannot be
// woken or claimed.
type RetireState struct {
RequestedAt time.Time `json:"requested_at"`
Delete bool `json:"delete"`
} }
// MyServerView is a row of GET /api/v1/me/servers: a server the caller owns, // MyServerView is a row of GET /api/v1/me/servers: a server the caller owns,
@@ -38,6 +51,8 @@ type MyServerView struct {
PlayerCountUnknown bool `json:"playerCountUnknown,omitempty"` PlayerCountUnknown bool `json:"playerCountUnknown,omitempty"`
AutoRestarts int32 `json:"autoRestarts,omitempty"` AutoRestarts int32 `json:"autoRestarts,omitempty"`
StartGaveUp bool `json:"startGaveUp,omitempty"` StartGaveUp bool `json:"startGaveUp,omitempty"`
// Retiring is the pending retirement of a server the caller owns.
Retiring *RetireState `json:"retiring,omitempty"`
} }
// ServerOwnership is one live server's claim state as the fleet read joins it. // ServerOwnership is one live server's claim state as the fleet read joins it.
@@ -49,6 +64,9 @@ type ServerOwnership struct {
// Owner is the claiming account's display identity (email, or username when // Owner is the claiming account's display identity (email, or username when
// the address is absent), "" while unclaimed. // the address is absent), "" while unclaimed.
Owner string Owner string
// Retire is the server's pending retirement, nil when there is none. A server
// being deleted is not claimable even while it has no owner.
Retire *RetireState
} }
// AuditEntry is one row written to audit_logs (spec §6). Actor is display text: // AuditEntry is one row written to audit_logs (spec §6). Actor is display text:
@@ -325,6 +343,7 @@ type Repo interface {
// QuotaCheck remains the advisory pre-check for the handler's fast-path 403. // QuotaCheck remains the advisory pre-check for the handler's fast-path 403.
// A successful claim resets last_active_at to now and clears warned_*, so the // A successful claim resets last_active_at to now and clears warned_*, so the
// reaper counts idleness from the claim. // reaper counts idleness from the claim.
// A server with a pending retirement is not claimable either (false).
ClaimServer(ctx context.Context, name, userID string) (bool, error) ClaimServer(ctx context.Context, name, userID string) (bool, error)
// UserInAllowlist reports whether the user's linked UUID is on the server // UserInAllowlist reports whether the user's linked UUID is on the server
// allowlist (spec §9.4). // allowlist (spec §9.4).
@@ -345,6 +364,15 @@ type Repo interface {
// Both allowlist checks above skip revoked entries, and a change of owner // Both allowlist checks above skip revoked entries, and a change of owner
// (claim, reaper release, account deletion) empties the list. // (claim, reaper release, account deletion) empties the list.
SetAllowlistWake(ctx context.Context, name, mcUUID string, canWake bool) error SetAllowlistWake(ctx context.Context, name, mcUUID string, canWake bool) error
// RequestRetire records that the server is to be given up, or deleted when
// deleteServer, and returns the pending request. Asking again keeps the first
// request time, and a deletion once asked for stays one. ErrNotFound when the
// server does not exist.
RequestRetire(ctx context.Context, name string, deleteServer bool) (RetireState, error)
// CancelRetire drops the server's pending retirement; with none pending it
// changes nothing. A pending deletion is dropped only when mayCancelDelete,
// and otherwise ErrConflict. ErrNotFound when the server does not exist.
CancelRetire(ctx context.Context, name string, mayCancelDelete bool) error
// UserByMCUUID resolves a verified in-game UUID to the user_id it is linked to // UserByMCUUID resolves a verified in-game UUID to the user_id it is linked to
// (spec §10 account_links), or ErrNotFound when the UUID is not linked. The // (spec §10 account_links), or ErrNotFound when the UUID is not linked. The
// internal-face wake uses it to apply the owner bypass for a player known only // internal-face wake uses it to apply the owner bypass for a player known only
+10 -8
View File
@@ -647,32 +647,34 @@ func TestSeedServerReusedNameStartsClean(t *testing.T) {
} }
past := time.Now().Add(-90 * 24 * time.Hour) past := time.Now().Add(-90 * 24 * time.Hour)
exec(`UPDATE servers SET owner_id = $2, claimed_at = $3, last_active_at = $3, warned_3d_at = $3, exec(`UPDATE servers SET owner_id = $2, claimed_at = $3, last_active_at = $3, warned_3d_at = $3,
warned_1d_at = $3, cached_phase = 'Running', created_at = $3, deleted_at = $3 WHERE name = $1`, name, u.ID, past) warned_1d_at = $3, cached_phase = 'Running', created_at = $3, deleted_at = $3,
retire_requested_at = $3, retire_delete = true WHERE name = $1`, name, u.ID, past)
exec(`INSERT INTO server_aliases (subdomain, server_name) VALUES ($1, $2)`, oldSub2, name) exec(`INSERT INTO server_aliases (subdomain, server_name) VALUES ($1, $2)`, oldSub2, name)
exec(`INSERT INTO server_allowlist (server_name, mc_uuid) VALUES ($1, $2)`, name, testUUID(t)) exec(`INSERT INTO server_allowlist (server_name, mc_uuid) VALUES ($1, $2)`, name, testUUID(t))
state := func() string { state := func() string {
t.Helper() t.Helper()
var owner, phase sql.NullString var owner, phase sql.NullString
var claimed, w3, w1, deleted sql.NullTime var claimed, w3, w1, deleted, retireAt sql.NullTime
var retireDelete bool
var created, active time.Time var created, active time.Time
var cpu, mem, stor, allow int var cpu, mem, stor, allow int
var aliases string var aliases string
if err := db.QueryRowContext(ctx, if err := db.QueryRowContext(ctx,
`SELECT owner_id, claimed_at, warned_3d_at, warned_1d_at, cached_phase, deleted_at, created_at, last_active_at, `SELECT owner_id, claimed_at, warned_3d_at, warned_1d_at, cached_phase, deleted_at, created_at, last_active_at,
cached_cpu_milli, cached_memory_mb, cached_storage_mb, cached_cpu_milli, cached_memory_mb, cached_storage_mb, retire_requested_at, retire_delete,
(SELECT count(*) FROM server_allowlist WHERE server_name = s.name), (SELECT count(*) FROM server_allowlist WHERE server_name = s.name),
(SELECT COALESCE(string_agg(subdomain, ',' ORDER BY subdomain), '') FROM server_aliases WHERE server_name = s.name) (SELECT COALESCE(string_agg(subdomain, ',' ORDER BY subdomain), '') FROM server_aliases WHERE server_name = s.name)
FROM servers s WHERE name = $1`, name).Scan( FROM servers s WHERE name = $1`, name).Scan(
&owner, &claimed, &w3, &w1, &phase, &deleted, &created, &active, &cpu, &mem, &stor, &allow, &aliases); err != nil { &owner, &claimed, &w3, &w1, &phase, &deleted, &created, &active, &cpu, &mem, &stor, &retireAt, &retireDelete, &allow, &aliases); err != nil {
t.Fatalf("read the servers row: %v", err) t.Fatalf("read the servers row: %v", err)
} }
recent := func(at time.Time) bool { return time.Since(at) < time.Hour } recent := func(at time.Time) bool { return time.Since(at) < time.Hour }
return fmt.Sprintf("owner=%v claimed=%v warned=%v/%v phase=%v deleted=%v fresh=%v/%v cache=%d/%d/%d allow=%d aliases=%s", return fmt.Sprintf("owner=%v claimed=%v warned=%v/%v phase=%v deleted=%v fresh=%v/%v cache=%d/%d/%d retire=%v/%v allow=%d aliases=%s",
owner.Valid, claimed.Valid, w3.Valid, w1.Valid, phase.Valid, deleted.Valid, recent(created), recent(active), owner.Valid, claimed.Valid, w3.Valid, w1.Valid, phase.Valid, deleted.Valid, recent(created), recent(active),
cpu, mem, stor, allow, strings.ReplaceAll(strings.ReplaceAll(aliases, oldSub2, "old2"), oldSub, "old")) cpu, mem, stor, retireAt.Valid, retireDelete, allow, strings.ReplaceAll(strings.ReplaceAll(aliases, oldSub2, "old2"), oldSub, "old"))
} }
earlier := "owner=true claimed=true warned=true/true phase=true deleted=true fresh=false/false cache=1000/2048/10240 allow=1 aliases=old,old2" earlier := "owner=true claimed=true warned=true/true phase=true deleted=true fresh=false/false cache=1000/2048/10240 retire=true/true allow=1 aliases=old,old2"
if got := state(); got != earlier { if got := state(); got != earlier {
t.Fatalf("setup: %s, want %s", got, earlier) t.Fatalf("setup: %s, want %s", got, earlier)
} }
@@ -689,7 +691,7 @@ func TestSeedServerReusedNameStartsClean(t *testing.T) {
t.Fatalf("a refused seed changed the row: %s, want %s", got, earlier) t.Fatalf("a refused seed changed the row: %s, want %s", got, earlier)
} }
clean := "owner=false claimed=false warned=false/false phase=false deleted=false fresh=true/true cache=2000/4096/20480 allow=0 aliases=" + newSub clean := "owner=false claimed=false warned=false/false phase=false deleted=false fresh=true/true cache=2000/4096/20480 retire=false/false allow=0 aliases=" + newSub
for i := 0; i < 2; i++ { // a retried create lands on the same state for i := 0; i < 2; i++ { // a retried create lands on the same state
if err := repo.SeedServer(ctx, name, newSub, 2000, 4096, 20480); err != nil { if err := repo.SeedServer(ctx, name, newSub, 2000, 4096, 20480); err != nil {
t.Fatalf("seed the new server (try %d): %v", i+1, err) t.Fatalf("seed the new server (try %d): %v", i+1, err)
+5
View File
@@ -40,6 +40,11 @@ func (c *reclaimCluster) HoldWorld(ctx context.Context, _ string) (context.Conte
func (c *reclaimCluster) WorldExists(context.Context, string) (bool, error) { return true, nil } func (c *reclaimCluster) WorldExists(context.Context, string) (bool, error) { return true, nil }
// DeleteServer is only reached by a retirement, which these tests do not ask for.
func (c *reclaimCluster) DeleteServer(_ context.Context, name, _ string) error {
return errors.New("unexpected deletion of " + name)
}
type reclaimArchiver struct{ archived []string } type reclaimArchiver struct{ archived []string }
func (a *reclaimArchiver) Archive(_ context.Context, server, _ string) (backup.Archived, error) { func (a *reclaimArchiver) Archive(_ context.Context, server, _ string) (backup.Archived, error) {
+493
View File
@@ -0,0 +1,493 @@
//go:build pgint
package pgint
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/api"
"felis.lolicon.best/internal/reaper"
)
// ---- retirement (migration 0035) --------------------------------------------------
func retireColumns(t *testing.T, name string) (sql.NullTime, bool) {
t.Helper()
var at sql.NullTime
var del bool
if err := db.QueryRow(`SELECT retire_requested_at, retire_delete FROM servers WHERE name = $1`, name).Scan(&at, &del); err != nil {
t.Fatalf("retirement of %s: %v", name, err)
}
return at, del
}
// TestRetireRequestContract pins PUT and DELETE /servers/{name}/retirement at the
// SQL: asking again keeps the first request's time, a deletion once asked for
// stays one whoever asks after, only an admin takes a deletion back, and a
// server that is gone is not found.
func TestRetireRequestContract(t *testing.T) {
ctx := context.Background()
owner := newUser(t, "user", "retire")
name, sub := "rt-"+suffix(t), "rts-"+suffix(t)
if err := repo.SeedServer(ctx, name, sub, 100, 128, 1); err != nil {
t.Fatalf("SeedServer: %v", err)
}
mustExec(t, `UPDATE servers SET owner_id = $2 WHERE name = $1`, name, owner.ID)
if rec, err := repo.ServerByName(ctx, name); err != nil || rec.Retire != nil {
t.Fatalf("before any request: ServerByName = %+v, %v; want no retirement", rec, err)
}
first, err := repo.RequestRetire(ctx, name, false)
if err != nil || first.Delete || time.Since(first.RequestedAt) > time.Minute {
t.Fatalf("RequestRetire = %+v, %v; want a release asked just now", first, err)
}
for _, ask := range []struct {
del, want bool
}{{false, false}, {true, true}, {false, true}} {
st, err := repo.RequestRetire(ctx, name, ask.del)
if err != nil || !st.RequestedAt.Equal(first.RequestedAt) || st.Delete != ask.want {
t.Fatalf("RequestRetire(delete=%v) = %+v, %v; want the first time %v and delete=%v",
ask.del, st, err, first.RequestedAt, ask.want)
}
}
for how, get := range map[string]func() (*api.ServerRecord, error){
"name": func() (*api.ServerRecord, error) { return repo.ServerByName(ctx, name) },
"subdomain": func() (*api.ServerRecord, error) { return repo.ServerBySubdomain(ctx, sub) },
} {
rec, err := get()
if err != nil || rec.Retire == nil || !rec.Retire.Delete || !rec.Retire.RequestedAt.Equal(first.RequestedAt) {
t.Fatalf("server by %s = %+v, %v; want the pending deletion", how, rec, err)
}
}
if err := repo.CancelRetire(ctx, name, false); !errors.Is(err, api.ErrConflict) {
t.Fatalf("the owner cancelling a deletion = %v, want ErrConflict", err)
}
if at, del := retireColumns(t, name); !at.Valid || !at.Time.Equal(first.RequestedAt) || !del {
t.Fatalf("a refused cancel changed the request: at=%v delete=%v", at, del)
}
if err := repo.CancelRetire(ctx, name, true); err != nil {
t.Fatalf("an admin cancelling a deletion: %v", err)
}
if at, del := retireColumns(t, name); at.Valid || del {
t.Fatalf("after the cancel: at=%v delete=%v; want no request", at, del)
}
if rec, err := repo.ServerByName(ctx, name); err != nil || rec.Retire != nil {
t.Fatalf("after the cancel: ServerByName = %+v, %v", rec, err)
}
// A give-up is the owner's to take back, and a new one starts its own clock.
again, err := repo.RequestRetire(ctx, name, false)
if err != nil || again.Delete || !again.RequestedAt.After(first.RequestedAt) {
t.Fatalf("a new request = %+v, %v; want a release after %v", again, err, first.RequestedAt)
}
if err := repo.CancelRetire(ctx, name, false); err != nil {
t.Fatalf("the owner cancelling a give-up: %v", err)
}
if at, del := retireColumns(t, name); at.Valid || del {
t.Fatalf("after the owner's cancel: at=%v delete=%v", at, del)
}
gone := "rtg-" + suffix(t)
seedOwnedServer(t, gone, owner.ID, true)
for _, n := range []string{gone, "rt-none-" + suffix(t)} {
if st, err := repo.RequestRetire(ctx, n, true); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("RequestRetire(%s) = %+v, %v; want ErrNotFound", n, st, err)
}
if err := repo.CancelRetire(ctx, n, true); !errors.Is(err, api.ErrNotFound) {
t.Fatalf("CancelRetire(%s) = %v; want ErrNotFound", n, err)
}
}
if at, _ := retireColumns(t, gone); at.Valid {
t.Fatalf("a deleted server's row took a request")
}
}
// TestRetiringServerIsNotClaimable: a server an admin is releasing or deleting
// cannot be claimed, even unowned, and the claim lists say so; only its owner is
// told of the request, and the admin fleet read sees it on every row.
func TestRetiringServerIsNotClaimable(t *testing.T) {
ctx := context.Background()
u := newUser(t, "user", "rtclaim")
other := newUser(t, "user", "rtclaim-other")
sfx := suffix(t)
free, freeRetiring, mine, mineRetiring, theirs := "rcf-"+sfx, "rcr-"+sfx, "rco-"+sfx, "rcm-"+sfx, "rct-"+sfx
for _, n := range []string{free, freeRetiring} {
mustExec(t, `INSERT INTO servers (name, cached_cpu_milli, cached_memory_mb, cached_storage_mb) VALUES ($1, 100, 128, 1)`, n)
}
seedOwnedServer(t, mine, u.ID, false)
seedOwnedServer(t, mineRetiring, u.ID, false)
seedOwnedServer(t, theirs, other.ID, false)
released, err := repo.RequestRetire(ctx, freeRetiring, false)
if err != nil {
t.Fatalf("RequestRetire(%s): %v", freeRetiring, err)
}
deleting, err := repo.RequestRetire(ctx, mineRetiring, true)
if err != nil {
t.Fatalf("RequestRetire(%s): %v", mineRetiring, err)
}
if _, err := repo.RequestRetire(ctx, theirs, false); err != nil {
t.Fatalf("RequestRetire(%s): %v", theirs, err)
}
if ok, err := repo.ClaimServer(ctx, freeRetiring, u.ID); err != nil || ok {
t.Fatalf("claiming a server being released = (%v, %v), want (false, nil)", ok, err)
}
if o := serverOwner(t, freeRetiring); o != "" {
t.Fatalf("the refused claim left owner %q", o)
}
views, err := repo.MyServers(ctx, u.ID)
if err != nil {
t.Fatalf("MyServers: %v", err)
}
got := map[string]string{}
for _, v := range views {
if strings.HasSuffix(v.Name, sfx) {
retiring := "none"
if v.Retiring != nil {
retiring = fmt.Sprintf("delete=%v", v.Retiring.Delete)
if !v.Retiring.RequestedAt.Equal(deleting.RequestedAt) {
retiring += " at another time"
}
}
got[v.Name] = fmt.Sprintf("owned=%v claimable=%v retiring=%s", v.Owned, v.Claimable, retiring)
}
}
want := map[string]string{
free: "owned=false claimable=true retiring=none",
freeRetiring: "owned=false claimable=false retiring=none",
mine: "owned=true claimable=false retiring=none",
mineRetiring: "owned=true claimable=false retiring=delete=true",
}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("MyServers = %v\nwant %v", got, want)
}
owners, err := repo.ServerOwners(ctx)
if err != nil {
t.Fatalf("ServerOwners: %v", err)
}
for n, w := range map[string]*api.RetireState{free: nil, mine: nil, freeRetiring: &released, mineRetiring: &deleting} {
r := owners[n].Retire
if (r == nil) != (w == nil) || (r != nil && (r.Delete != w.Delete || !r.RequestedAt.Equal(w.RequestedAt))) {
t.Errorf("ServerOwners[%s].Retire = %+v, want %+v", n, r, w)
}
}
if r := owners[theirs].Retire; r == nil || r.Delete {
t.Errorf("ServerOwners[%s].Retire = %+v, want another owner's give-up", theirs, r)
}
// Taken back, the server may be claimed again.
if err := repo.CancelRetire(ctx, freeRetiring, true); err != nil {
t.Fatalf("CancelRetire: %v", err)
}
if ok, err := repo.ClaimServer(ctx, freeRetiring, u.ID); err != nil || !ok {
t.Fatalf("claim after the cancel = (%v, %v)", ok, err)
}
for _, n := range []string{mineRetiring, theirs} {
if err := repo.CancelRetire(ctx, n, true); err != nil {
t.Fatalf("CancelRetire(%s): %v", n, err)
}
}
}
// TestRetireReaperStore pins the reaper's reads and writes of a retirement: the
// request reaches its candidates, a retirement's archive holds the world as an
// idle reap's does and is never evicted while it is the only copy, releasing the
// world finishes a give-up and leaves a deletion for the next run, and marking
// the row deleted takes only a server asked to be deleted.
func TestRetireReaperStore(t *testing.T) {
ctx := context.Background()
st := reaper.NewPGStore(db)
u := newUser(t, "user", "rtstore")
sfx := suffix(t)
given, doomed, plain := "rsg-"+sfx, "rsd-"+sfx, "rsp-"+sfx
for _, n := range []string{given, doomed, plain} {
seedOwnedServer(t, n, u.ID, false)
}
givenAt, err := repo.RequestRetire(ctx, given, false)
if err != nil {
t.Fatalf("RequestRetire(%s): %v", given, err)
}
doomedAt, err := repo.RequestRetire(ctx, doomed, true)
if err != nil {
t.Fatalf("RequestRetire(%s): %v", doomed, err)
}
cands, err := st.ListActiveServers(ctx)
if err != nil {
t.Fatalf("ListActiveServers: %v", err)
}
seen := map[string]reaper.Candidate{}
for _, c := range cands {
seen[c.Name] = c
}
for n, w := range map[string]api.RetireState{given: givenAt, doomed: doomedAt, plain: {}} {
c, ok := seen[n]
if !ok || !c.RetireRequestedAt.Equal(w.RequestedAt) || c.RetireDelete != w.Delete {
t.Errorf("candidate %s = %+v (listed %v); want retirement %+v", n, c, ok, w)
}
}
// A retirement's archive is the reaper's own: it holds the world, and it is
// kept like an idle reap's until it has its off-site copy.
now := time.Now()
backup := func(server, id, reason string, created time.Time, offsite bool) {
t.Helper()
var offsiteAt any
if offsite {
offsiteAt = created
}
mustExec(t, `INSERT INTO world_backups (id, server_name, backup_ref, size_bytes, reason, status, created_at, expires_at, offsite_at)
VALUES ($1, $2, $3, 1, $4, 'present', $5, $6, $7)`,
id, server, "/archives/"+id+".tar.gz", reason, created, created.Add(90*reaper.Day), offsiteAt)
}
manualID, soleID, copiedID := "bk-rsm-"+sfx, "bk-rss-"+sfx, "bk-rsc-"+sfx
backup(plain, manualID, "manual", now.Add(-time.Minute), false)
if f, ok, err := st.FreshBackup(ctx, plain, now.Add(-time.Hour)); err != nil || ok {
t.Fatalf("FreshBackup over a manual backup = (%+v, %v, %v); only the reaper's archives count", f, ok, err)
}
backup(plain, soleID, reaper.ReasonReleased, now.Add(-3*time.Minute), false)
backup(plain, copiedID, reaper.ReasonReleased, now.Add(-2*time.Minute), true)
f, ok, err := st.FreshBackup(ctx, plain, now.Add(-time.Hour))
if err != nil || !ok || f.ID != copiedID || !f.Offsite {
t.Fatalf("FreshBackup = (%+v, %v, %v); want the copied retirement archive %s", f, ok, err, copiedID)
}
if f, ok, err := st.FreshBackup(ctx, plain, now); err != nil || ok {
t.Fatalf("FreshBackup since now = (%+v, %v, %v); an archive older than since holds no world", f, ok, err)
}
all, err := st.EvictableBackups(ctx)
if err != nil {
t.Fatalf("EvictableBackups: %v", err)
}
var order []string
for _, b := range all {
if b.ServerName == plain {
order = append(order, b.ID)
}
}
if strings.Join(order, ",") != manualID+","+copiedID {
t.Fatalf("eviction order = %v; want %s, then %s, and never the only copy %s", order, manualID, copiedID, soleID)
}
// Each server keeps an allowlist entry and an extra alias to see what goes.
for _, n := range []string{given, doomed, plain} {
mustExec(t, `INSERT INTO server_aliases (subdomain, server_name) VALUES ($1, $2)`, n+"-a", n)
mustExec(t, `INSERT INTO server_allowlist (server_name, mc_uuid) VALUES ($1, $2)`, n, testUUID(t))
}
state := func(n string) string {
t.Helper()
var owner sql.NullString
var deleted, retireAt sql.NullTime
var del bool
var aliases, allow int
if err := db.QueryRowContext(ctx,
`SELECT owner_id, deleted_at, retire_requested_at, retire_delete,
(SELECT count(*) FROM server_aliases WHERE server_name = s.name),
(SELECT count(*) FROM server_allowlist WHERE server_name = s.name)
FROM servers s WHERE name = $1`, n).Scan(&owner, &deleted, &retireAt, &del, &aliases, &allow); err != nil {
t.Fatalf("read %s: %v", n, err)
}
return fmt.Sprintf("owner=%v deleted=%v retire=%v/%v aliases=%d allow=%d",
owner.Valid, deleted.Valid, retireAt.Valid, del, aliases, allow)
}
// Only a server asked to be deleted loses its row.
for _, n := range []string{given, plain} {
before := state(n)
if err := st.DeleteServerRow(ctx, n, now); err != nil {
t.Fatalf("DeleteServerRow(%s): %v", n, err)
}
if got := state(n); got != before {
t.Fatalf("DeleteServerRow(%s) touched a server nobody is deleting: %s, was %s", n, got, before)
}
}
// Releasing the world finishes a give-up; a deletion stays pending.
for _, n := range []string{given, doomed} {
if err := st.ReleaseWorld(ctx, n, now); err != nil {
t.Fatalf("ReleaseWorld(%s): %v", n, err)
}
}
if got, want := state(given), "owner=false deleted=false retire=false/false aliases=1 allow=0"; got != want {
t.Fatalf("given up and released: %s, want %s", got, want)
}
if got, want := state(doomed), "owner=false deleted=false retire=true/true aliases=1 allow=0"; got != want {
t.Fatalf("released while being deleted: %s, want %s", got, want)
}
if at, _ := retireColumns(t, doomed); !at.Time.Equal(doomedAt.RequestedAt) {
t.Fatalf("the release moved the deletion's request time to %v, want %v", at.Time, doomedAt.RequestedAt)
}
mustExec(t, `INSERT INTO server_allowlist (server_name, mc_uuid) VALUES ($1, $2)`, doomed, testUUID(t))
if err := st.DeleteServerRow(ctx, doomed, now); err != nil {
t.Fatalf("DeleteServerRow(%s): %v", doomed, err)
}
if got, want := state(doomed), "owner=false deleted=true retire=false/false aliases=0 allow=0"; got != want {
t.Fatalf("deleted: %s, want %s", got, want)
}
if got, want := state(plain), "owner=true deleted=false retire=false/false aliases=1 allow=1"; got != want {
t.Fatalf("a bystander changed: %s, want %s", got, want)
}
}
// fleetCluster knows a set of servers by uid; every other row in the shared
// schema reads as a server whose CRD is gone and whose world volume is still
// there, which the reaper never touches.
type fleetCluster struct {
uids map[string]string
deletedPVC []string
deletedServer []string
}
func (c *fleetCluster) Inspect(_ context.Context, name string) (reaper.ServerCRD, error) {
uid, ok := c.uids[name]
if !ok {
return reaper.ServerCRD{}, reaper.ErrNotFound
}
return reaper.ServerCRD{PVC: reaper.WorldPVCName(name), UID: uid}, nil
}
func (c *fleetCluster) HoldWorld(ctx context.Context, _ string) (context.Context, func(), error) {
return ctx, func() {}, nil
}
func (c *fleetCluster) WorldExists(_ context.Context, pvc string) (bool, error) {
for _, gone := range c.deletedPVC {
if gone == pvc {
return false, nil
}
}
return true, nil
}
func (c *fleetCluster) DeletePVC(_ context.Context, pvc string) error {
c.deletedPVC = append(c.deletedPVC, pvc)
return nil
}
func (c *fleetCluster) DeleteServer(_ context.Context, name, uid string) error {
if c.uids[name] != uid {
return fmt.Errorf("DeleteServer(%s, %s): the server has uid %s", name, uid, c.uids[name])
}
c.deletedServer = append(c.deletedServer, name)
delete(c.uids, name)
return nil
}
// TestRetirementThroughTheReaper runs the reaper over a give-up and a deletion
// felis-api recorded, beside a server played minutes ago: the two go on the
// first run however recently they were played, each archived first (in name
// order, as the reaper lists them), and a second
// run finds nothing left to do. The deleted server's name and subdomain are free
// for a new server, which starts clean.
func TestRetirementThroughTheReaper(t *testing.T) {
ctx := context.Background()
st := reaper.NewPGStore(db)
u := newUser(t, "user", "rtreap")
sfx := suffix(t)
given, doomed, bystander := "rrg-"+sfx, "rrd-"+sfx, "rrb-"+sfx
cl := &fleetCluster{uids: map[string]string{}}
for _, n := range []string{given, doomed, bystander} {
if err := repo.SeedServer(ctx, n, n+"-s", 100, 128, 1); err != nil {
t.Fatalf("SeedServer(%s): %v", n, err)
}
mustExec(t, `UPDATE servers SET owner_id = $2, claimed_at = now() - interval '30 days', last_active_at = now() - interval '5 minutes'
WHERE name = $1`, n, u.ID)
mustExec(t, `INSERT INTO server_allowlist (server_name, mc_uuid) VALUES ($1, $2)`, n, testUUID(t))
cl.uids[n] = "uid-" + n
}
if _, err := repo.RequestRetire(ctx, given, false); err != nil {
t.Fatalf("RequestRetire(%s): %v", given, err)
}
if _, err := repo.RequestRetire(ctx, doomed, true); err != nil {
t.Fatalf("RequestRetire(%s): %v", doomed, err)
}
ar := &reclaimArchiver{}
r := &reaper.Reaper{Cfg: reaper.DefaultConfig(), Store: st, Cluster: cl, Archiver: ar}
if _, err := r.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
mineOnly := func(refs []string) string {
var out []string
for _, ref := range refs {
for _, n := range []string{given, doomed, bystander} {
if strings.Contains(ref, n) {
out = append(out, strings.Replace(ref, n, map[string]string{given: "given", doomed: "doomed", bystander: "bystander"}[n], 1))
}
}
}
return strings.Join(out, " ")
}
if got, want := mineOnly(cl.deletedPVC), "world-doomed-0 world-given-0"; got != want {
t.Fatalf("volumes deleted: %q, want %q", got, want)
}
if got, want := mineOnly(cl.deletedServer), "doomed"; got != want {
t.Fatalf("servers deleted: %q, want %q", got, want)
}
if got, want := mineOnly(ar.archived), "/archives/doomed-new.tar.gz /archives/given-new.tar.gz"; got != want {
t.Fatalf("archived: %q, want %q", got, want)
}
type row struct {
owner sql.NullString
deleted, retir sql.NullTime
aliases, allow int
archives string
audit string
}
read := func(n string) row {
t.Helper()
var r row
if err := db.QueryRowContext(ctx,
`SELECT owner_id, deleted_at, retire_requested_at,
(SELECT count(*) FROM server_aliases WHERE server_name = s.name),
(SELECT count(*) FROM server_allowlist WHERE server_name = s.name),
(SELECT COALESCE(string_agg(reason || ':' || status || ':' || COALESCE(former_owner, ''), ','), '') FROM world_backups WHERE server_name = s.name),
(SELECT COALESCE(string_agg(action, ',' ORDER BY id), '') FROM audit_logs WHERE server_name = s.name AND source = 'reaper')
FROM servers s WHERE name = $1`, n).Scan(&r.owner, &r.deleted, &r.retir, &r.aliases, &r.allow, &r.archives, &r.audit); err != nil {
t.Fatalf("read %s: %v", n, err)
}
return r
}
g := read(given)
if g.owner.Valid || g.deleted.Valid || g.retir.Valid || g.aliases != 1 || g.allow != 0 ||
g.archives != "released:present:"+u.ID || g.audit != reaper.ActionReleaseWorld {
t.Fatalf("given up: %+v; want released, still listed at its subdomain, its archive kept as the owner's", g)
}
d := read(doomed)
if d.owner.Valid || !d.deleted.Valid || d.retir.Valid || d.aliases != 0 || d.allow != 0 ||
d.archives != "released:present:"+u.ID || d.audit != reaper.ActionDeleteServer {
t.Fatalf("deleted: %+v; want the row marked deleted, its subdomain freed, its archive kept", d)
}
b := read(bystander)
if b.owner.String != u.ID || b.deleted.Valid || b.aliases != 1 || b.allow != 1 || b.archives != "" || b.audit != "" {
t.Fatalf("the bystander changed: %+v", b)
}
// Nothing is left for the next run.
if _, err := r.RunOnce(ctx); err != nil {
t.Fatalf("second RunOnce: %v", err)
}
if got := mineOnly(ar.archived) + " | " + mineOnly(cl.deletedServer); got != "/archives/doomed-new.tar.gz /archives/given-new.tar.gz | doomed" {
t.Fatalf("the second run did more: %s", got)
}
if err := repo.SeedServer(ctx, doomed, doomed+"-s", 200, 256, 2); err != nil {
t.Fatalf("a new server under the deleted one's name: %v", err)
}
if at, del := retireColumns(t, doomed); at.Valid || del {
t.Fatalf("the new server inherited a retirement: at=%v delete=%v", at, del)
}
if rec, err := repo.ServerBySubdomain(ctx, doomed+"-s"); err != nil || rec.Name != doomed || rec.OwnerID != "" || rec.Retire != nil {
t.Fatalf("the new server = %+v, %v; want it unowned at the freed subdomain", rec, err)
}
}
+6 -1
View File
@@ -15,7 +15,8 @@ import (
// backups: an owned world joined since its owner's newest intact scheduled // backups: an owned world joined since its owner's newest intact scheduled
// backup is due once that backup is older than the period, worlds without one // backup is due once that backup is older than the period, worlds without one
// first. A previous owner's, a corrupt, a deleted, a pre-claim or a manual // first. A previous owner's, a corrupt, a deleted, a pre-claim or a manual
// backup is no restore point of the current owner's world. // backup is no restore point of the current owner's world. A world being given
// up is never due: the reaper archives it anyway.
func TestScheduledBackupCandidates(t *testing.T) { func TestScheduledBackupCandidates(t *testing.T) {
ctx := context.Background() ctx := context.Background()
u := newUser(t, "user", "sched") u := newUser(t, "user", "sched")
@@ -75,6 +76,10 @@ func TestScheduledBackupCandidates(t *testing.T) {
backup(manual, u.ID, "manual", "present", now.Add(-30*time.Minute), false) backup(manual, u.ID, "manual", "present", now.Add(-30*time.Minute), false)
server("unowned", "", claimed, now.Add(-h), false) server("unowned", "", claimed, now.Add(-h), false)
server("gone", u.ID, claimed, now.Add(-h), true) server("gone", u.ID, claimed, now.Add(-h), true)
retiring := server("retiring", u.ID, claimed, now.Add(-h), false)
if _, err := repo.RequestRetire(ctx, retiring, false); err != nil {
t.Fatalf("RequestRetire: %v", err)
}
got, err := repo.ScheduledBackupCandidates(ctx, now.Add(-24*h)) got, err := repo.ScheduledBackupCandidates(ctx, now.Add(-24*h))
if err != nil { if err != nil {
+17 -13
View File
@@ -185,12 +185,16 @@ func OperatorRole(p Params) *rbacv1.Role {
}) })
} }
// ReaperRole grants felis-reaper its two destructive, disjoint powers // ReaperRole grants felis-reaper its destructive powers
// (internal/reaper.k8scluster): patch a MinecraftServer to Stop it and delete its // (internal/reaper.k8scluster): patch a MinecraftServer to Stop it, delete its
// world PVC. Candidate servers come from the Postgres store, not a cluster List, // world PVC, and delete the MinecraftServer of a server an admin deleted.
// so minecraftservers need no list/watch; the reaper uses a direct client. It can // Candidate servers come from the Postgres store, not a cluster List, so
// read+patch minecraftservers but cannot create them, and holds no power over // minecraftservers need no list/watch; the reaper uses a direct client. It can
// StatefulSets, Services, or Secrets — those belong to the operator and api. // 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 // 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 // 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 // stock local-path directory name. get is strictly weaker than the delete the
// same rule already grants, so it widens nothing. // same rule already grants, so it widens nothing.
// //
// Note no identity anywhere holds minecraftservers:delete. That is intentional, not // The reaper is the only identity with minecraftservers:delete. Reaping an idle
// a missing grant: reaping releases a server by flipping desiredState=Stopped and // world releases the server by flipping desiredState=Stopped and reclaiming the
// reclaiming the world PVC (k8scluster.go does "nothing else"), leaving the CR in // world PVC, leaving the CR in place so it can be claimed again; the CR goes only
// place so a former owner can re-claim it within the retention window (spec §466). // when an admin deletes the server (PUT /servers/{name}/retirement), and then only
// The MinecraftServer CR is the lifecycle source of truth and is retained, never // once the reaper has archived and deleted its world. felis-api records that
// hard-deleted, so the delete verb is deliberately absent from every Role. // request and never deletes a CR itself.
func ReaperRole(p Params) *rbacv1.Role { func ReaperRole(p Params) *rbacv1.Role {
p = p.withDefaults() p = p.withDefaults()
return role(p.MinecraftNamespace, "felis-reaper", ComponentReaper, []rbacv1.PolicyRule{ 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{"persistentvolumeclaims"}, []string{"get", "delete"}),
rule([]string{groupCore}, []string{"pods"}, []string{"list"}), rule([]string{groupCore}, []string{"pods"}, []string{"list"}),
rule([]string{groupBatch}, []string{"jobs"}, []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 // TestOnlyReaperDeletesMinecraftServers locks the lifecycle invariant: a
// MinecraftServer CR is the retained source of truth (released by Stop + PVC // MinecraftServer is retained when its world is reaped (released by Stop + PVC
// reclaim, never hard-deleted, so a former owner can re-claim within the retention // reclaim, so it can be claimed again), and removed only when an admin deletes the
// window — spec §466). No control-plane identity may hold minecraftservers:delete; // server, by the reaper, after it archived and deleted the world. No other
// if a future change adds it, this fails loudly so the decision is deliberate. // control-plane identity may hold minecraftservers:delete, and without a reaper
func TestNoIdentityDeletesMinecraftServers(t *testing.T) { // deployed none does.
func TestOnlyReaperDeletesMinecraftServers(t *testing.T) {
for _, role := range ControlPlaneRBAC(testParams()).Roles { for _, role := range ControlPlaneRBAC(testParams()).Roles {
if hasRule(role, groupFelis, "minecraftservers", "delete") { 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 // TestNoClusterScopedRBAC enforces the §22 red line: nothing in the bundle is a
+27 -2
View File
@@ -33,7 +33,8 @@ const gamePodComponent = "server"
// K8sCluster is the production Cluster backed by a controller-runtime client // K8sCluster is the production Cluster backed by a controller-runtime client
// (spec §4, §18). It reads spec.reaperExempt, stops a server and holds its world // (spec §4, §18). It reads spec.reaperExempt, stops a server and holds its world
// volume through the maintenance lock, and deletes the world PVC — nothing else. // volume through the maintenance lock, deletes the world PVC, and removes the
// MinecraftServer of a server an admin deleted — nothing else.
type K8sCluster struct { type K8sCluster struct {
c client.Client c client.Client
namespace string namespace string
@@ -60,7 +61,7 @@ func (k *K8sCluster) Inspect(ctx context.Context, name string) (ServerCRD, error
if err := k.get(ctx, name, &ms); err != nil { if err := k.get(ctx, name, &ms); err != nil {
return ServerCRD{}, err return ServerCRD{}, err
} }
return ServerCRD{Exempt: ms.Spec.ReaperExempt, PVC: WorldPVCName(name)}, nil return ServerCRD{Exempt: ms.Spec.ReaperExempt, PVC: WorldPVCName(name), UID: string(ms.UID)}, nil
} }
// HoldWorld implements Cluster. The lock is the same Annotation felis-api // HoldWorld implements Cluster. The lock is the same Annotation felis-api
@@ -232,6 +233,30 @@ func (k *K8sCluster) DeletePVC(ctx context.Context, pvc string) error {
return nil return nil
} }
// DeleteServer implements Cluster. The read and the delete are one step (the
// delete carries the resourceVersion read), so the object removed is the one
// whose uid was checked: a server made again under the name is refused.
func (k *K8sCluster) DeleteServer(ctx context.Context, name, uid string) error {
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
var ms v1alpha1.MinecraftServer
switch err := k.get(ctx, name, &ms); {
case errors.Is(err, ErrNotFound):
return nil
case err != nil:
return err
}
if string(ms.UID) != uid {
return fmt.Errorf("MinecraftServer %s is another server now (uid %s, inspected %s)", name, ms.UID, uid)
}
rv, u := ms.ResourceVersion, ms.UID
err := k.c.Delete(ctx, &ms, client.Preconditions{UID: &u, ResourceVersion: &rv})
if apierrors.IsNotFound(err) {
return nil
}
return err
})
}
func (k *K8sCluster) get(ctx context.Context, name string, ms *v1alpha1.MinecraftServer) error { func (k *K8sCluster) get(ctx context.Context, name string, ms *v1alpha1.MinecraftServer) error {
if err := k.c.Get(ctx, types.NamespacedName{Namespace: k.namespace, Name: name}, ms); err != nil { if err := k.c.Get(ctx, types.NamespacedName{Namespace: k.namespace, Name: name}, ms); err != nil {
if apierrors.IsNotFound(err) { if apierrors.IsNotFound(err) {
+33
View File
@@ -216,3 +216,36 @@ func TestGamePodComponentMatchesOperator(t *testing.T) {
t.Fatalf("gamePodComponent = %q, operator labels its pods %q", gamePodComponent, operator.ComponentValue) t.Fatalf("gamePodComponent = %q, operator labels its pods %q", gamePodComponent, operator.ComponentValue)
} }
} }
// DeleteServer removes the MinecraftServer Inspect returned, and nothing that
// merely carries its name: a server made again under the name (another uid)
// stays, and one already gone is not an error.
func TestDeleteServerRemovesOnlyTheInspectedServer(t *testing.T) {
ms := holdServer(v1alpha1.DesiredStopped, v1alpha1.PhaseStopped)
ms.UID = "uid-1"
k, c, _ := holdCluster(t, ms)
ctx := context.Background()
crd, err := k.Inspect(ctx, "survival")
if err != nil || crd.UID != "uid-1" {
t.Fatalf("Inspect = %+v, %v; want uid-1", crd, err)
}
if err := k.DeleteServer(ctx, "survival", "uid-0"); err == nil {
t.Fatal("DeleteServer with another uid succeeded")
}
var got v1alpha1.MinecraftServer
if err := c.Get(ctx, types.NamespacedName{Namespace: "minecraft", Name: "survival"}, &got); err != nil {
t.Fatalf("the server of another uid was deleted: %v", err)
}
if err := k.DeleteServer(ctx, "survival", crd.UID); err != nil {
t.Fatalf("DeleteServer: %v", err)
}
if err := c.Get(ctx, types.NamespacedName{Namespace: "minecraft", Name: "survival"}, &got); err == nil {
t.Fatal("the server is still there")
}
if err := k.DeleteServer(ctx, "survival", crd.UID); err != nil {
t.Fatalf("DeleteServer of a server already gone = %v, want nil", err)
}
}
+34 -11
View File
@@ -20,7 +20,7 @@ type PGStore struct {
func NewPGStore(db *sql.DB) *PGStore { return &PGStore{db: db} } func NewPGStore(db *sql.DB) *PGStore { return &PGStore{db: db} }
func (s *PGStore) ListActiveServers(ctx context.Context) ([]Candidate, error) { func (s *PGStore) ListActiveServers(ctx context.Context) ([]Candidate, error) {
const q = `SELECT name, owner_id, last_active_at, warned_3d_at, warned_1d_at const q = `SELECT name, owner_id, last_active_at, warned_3d_at, warned_1d_at, retire_requested_at, retire_delete
FROM servers WHERE deleted_at IS NULL ORDER BY name` FROM servers WHERE deleted_at IS NULL ORDER BY name`
rows, err := s.db.QueryContext(ctx, q) rows, err := s.db.QueryContext(ctx, q)
if err != nil { if err != nil {
@@ -33,8 +33,9 @@ func (s *PGStore) ListActiveServers(ctx context.Context) ([]Candidate, error) {
c Candidate c Candidate
owner sql.NullString owner sql.NullString
w3, w1 sql.NullTime w3, w1 sql.NullTime
retire sql.NullTime
) )
if err := rows.Scan(&c.Name, &owner, &c.LastActiveAt, &w3, &w1); err != nil { if err := rows.Scan(&c.Name, &owner, &c.LastActiveAt, &w3, &w1, &retire, &c.RetireDelete); err != nil {
return nil, err return nil, err
} }
c.OwnerID = owner.String c.OwnerID = owner.String
@@ -44,18 +45,21 @@ func (s *PGStore) ListActiveServers(ctx context.Context) ([]Candidate, error) {
if w1.Valid { if w1.Valid {
c.Warned1dAt = w1.Time c.Warned1dAt = w1.Time
} }
if retire.Valid {
c.RetireRequestedAt = retire.Time
}
out = append(out, c) out = append(out, c)
} }
return out, rows.Err() return out, rows.Err()
} }
func (s *PGStore) FreshBackup(ctx context.Context, server string, since time.Time) (Fresh, bool, error) { func (s *PGStore) FreshBackup(ctx context.Context, server string, since time.Time) (Fresh, bool, error) {
// Only the reaper's own archives count, and only those taken since the // Only the reaper's own archives count (an idle reap's or a retirement's),
// current owner claimed the server: a manual backup may predate a panel edit // and only those taken since the current owner claimed the server: a manual
// that did not move last_active_at, and an archive from before the claim is // backup may predate a panel edit that did not move last_active_at, and an
// the previous owner's world. One found corrupt is never reused. // archive from before the claim is the previous owner's world. One found corrupt is never reused.
const q = `SELECT b.id, b.backup_ref, COALESCE(b.sha256, ''), b.offsite_at IS NOT NULL FROM world_backups b const q = `SELECT b.id, b.backup_ref, COALESCE(b.sha256, ''), b.offsite_at IS NOT NULL FROM world_backups b
WHERE b.server_name = $1 AND b.status = 'present' AND b.reason = 'inactive_15d' AND b.created_at >= $2 WHERE b.server_name = $1 AND b.status = 'present' AND b.reason IN ('inactive_15d', 'released') AND b.created_at >= $2
AND b.corrupt_at IS NULL AND b.corrupt_at IS NULL
AND b.created_at >= COALESCE((SELECT s.claimed_at FROM servers s WHERE s.name = $1 AND s.deleted_at IS NULL), '-infinity') AND b.created_at >= COALESCE((SELECT s.claimed_at FROM servers s WHERE s.name = $1 AND s.deleted_at IS NULL), '-infinity')
ORDER BY b.offsite_at IS NOT NULL DESC, b.created_at DESC LIMIT 1` ORDER BY b.offsite_at IS NOT NULL DESC, b.created_at DESC LIMIT 1`
@@ -85,17 +89,36 @@ func (s *PGStore) InsertBackup(ctx context.Context, rec BackupRecord) error {
// gated on that size and counts it. The wake allowlist is emptied in the same // gated on that size and counts it. The wake allowlist is emptied in the same
// statement: its players were vouched for by the owner being released, and an // statement: its players were vouched for by the owner being released, and an
// ownerless server set to autostartPolicy=allowlist would otherwise stay // ownerless server set to autostartPolicy=allowlist would otherwise stay
// wakeable by them. // wakeable by them. A pending release is done with once the world is gone;
// a pending deletion stays, so an idle reap that lands on a server an admin is
// deleting leaves the deletion for the next run.
func (s *PGStore) ReleaseWorld(ctx context.Context, name string, at time.Time) error { func (s *PGStore) ReleaseWorld(ctx context.Context, name string, at time.Time) error {
const q = `WITH released AS ( const q = `WITH released AS (
UPDATE servers UPDATE servers
SET owner_id = NULL, last_active_at = $2, warned_3d_at = NULL, warned_1d_at = NULL SET owner_id = NULL, last_active_at = $2, warned_3d_at = NULL, warned_1d_at = NULL,
retire_requested_at = CASE WHEN retire_delete THEN retire_requested_at END
WHERE name = $1 AND deleted_at IS NULL RETURNING name) WHERE name = $1 AND deleted_at IS NULL RETURNING name)
DELETE FROM server_allowlist WHERE server_name IN (SELECT name FROM released)` DELETE FROM server_allowlist WHERE server_name IN (SELECT name FROM released)`
_, err := s.db.ExecContext(ctx, q, name, at) _, err := s.db.ExecContext(ctx, q, name, at)
return err return err
} }
// DeleteServerRow marks a server deleted once its MinecraftServer is gone. The
// row stays (red line ②) and so do its backups (red line ③), recorded against
// the name; the aliases go, which frees the subdomain, and the allowlist with
// them. A create under the name later starts the row over (api.SeedServer).
func (s *PGStore) DeleteServerRow(ctx context.Context, name string, at time.Time) error {
const q = `WITH gone AS (
UPDATE servers
SET deleted_at = $2, owner_id = NULL, retire_requested_at = NULL, retire_delete = false,
warned_3d_at = NULL, warned_1d_at = NULL
WHERE name = $1 AND deleted_at IS NULL AND retire_delete RETURNING name),
aliases AS (DELETE FROM server_aliases WHERE server_name IN (SELECT name FROM gone))
DELETE FROM server_allowlist WHERE server_name IN (SELECT name FROM gone)`
_, err := s.db.ExecContext(ctx, q, name, at)
return err
}
func (s *PGStore) RestartClock(ctx context.Context, name string, at time.Time) error { func (s *PGStore) RestartClock(ctx context.Context, name string, at time.Time) error {
const q = `UPDATE servers SET last_active_at = $2, warned_3d_at = NULL, warned_1d_at = NULL const q = `UPDATE servers SET last_active_at = $2, warned_3d_at = NULL, warned_1d_at = NULL
WHERE name = $1 AND deleted_at IS NULL` WHERE name = $1 AND deleted_at IS NULL`
@@ -123,8 +146,8 @@ func (s *PGStore) PresentBackupBytes(ctx context.Context) (int64, error) {
func (s *PGStore) EvictableBackups(ctx context.Context) ([]StoredBackup, error) { func (s *PGStore) EvictableBackups(ctx context.Context) ([]StoredBackup, error) {
const q = `SELECT id, server_name, backup_ref, size_bytes, reason, COALESCE(sha256, '') FROM world_backups const q = `SELECT id, server_name, backup_ref, size_bytes, reason, COALESCE(sha256, '') FROM world_backups
WHERE status = 'present' AND (reason <> 'inactive_15d' OR offsite_at IS NOT NULL) WHERE status = 'present' AND (reason NOT IN ('inactive_15d', 'released') OR offsite_at IS NOT NULL)
ORDER BY reason = 'inactive_15d', created_at ASC` ORDER BY reason IN ('inactive_15d', 'released'), created_at ASC`
return s.queryBackups(ctx, q) return s.queryBackups(ctx, q)
} }
+148 -21
View File
@@ -43,11 +43,18 @@ const Day = 24 * time.Hour
// (spec §18). It is a stable label, not a literal restatement of the deadline. // (spec §18). It is a stable label, not a literal restatement of the deadline.
const ReasonInactive = "inactive_15d" const ReasonInactive = "inactive_15d"
// ReasonReleased is the world_backups.reason for the archive of a world whose
// server its owner gave up or an admin deleted (PUT /servers/{name}/retirement).
// Like an idle reap's, it is the world's copy after the volume is gone.
const ReasonReleased = "released"
// Audit actions emitted by the reaper. The actor/source are a system identity // Audit actions emitted by the reaper. The actor/source are a system identity
// ("reaper") because no human Access email is in play here (spec §14). // ("reaper") because no human Access email is in play here (spec §14).
const ( const (
ActionReapWorld = "reap_world" ActionReapWorld = "reap_world"
ActionEvictBackup = "evict_backup_early" ActionEvictBackup = "evict_backup_early"
ActionReleaseWorld = "release_world"
ActionDeleteServer = "delete_server"
) )
// ErrNotFound is returned by Cluster.Inspect when the MinecraftServer CRD for a // ErrNotFound is returned by Cluster.Inspect when the MinecraftServer CRD for a
@@ -151,6 +158,12 @@ type Candidate struct {
LastActiveAt time.Time LastActiveAt time.Time
Warned3dAt time.Time // zero = not yet sent Warned3dAt time.Time // zero = not yet sent
Warned1dAt time.Time // zero = not yet sent Warned1dAt time.Time // zero = not yet sent
// RetireRequestedAt is when the owner gave the server up or an admin asked
// for it to be deleted (zero = no request); RetireDelete marks a deletion.
// Either is carried out on the next run, however recently the world was
// played.
RetireRequestedAt time.Time
RetireDelete bool
} }
func (c Candidate) warnedAt(t Tier) time.Time { func (c Candidate) warnedAt(t Tier) time.Time {
@@ -160,11 +173,25 @@ func (c Candidate) warnedAt(t Tier) time.Time {
return c.Warned3dAt return c.Warned3dAt
} }
func (c Candidate) retiring() bool { return !c.RetireRequestedAt.IsZero() }
// worldSince is how recent an archive must be to hold the current world: taken
// after the last join, and for a retirement after the request, so the world
// archived is the one its owner left.
func (c Candidate) worldSince() time.Time {
if c.RetireRequestedAt.After(c.LastActiveAt) {
return c.RetireRequestedAt
}
return c.LastActiveAt
}
// ServerCRD is the slice of the MinecraftServer CRD the reaper needs: the // ServerCRD is the slice of the MinecraftServer CRD the reaper needs: the
// exemption flag (red line ①) and the world PVC to archive then delete. // exemption flag (red line ①), the world PVC to archive then delete, and the
// object's uid, so a deletion removes the server that was inspected.
type ServerCRD struct { type ServerCRD struct {
Exempt bool Exempt bool
PVC string PVC string
UID string
} }
// BackupRecord is a world_backups insert. FormerOwner is captured so the // BackupRecord is a world_backups insert. FormerOwner is captured so the
@@ -233,9 +260,17 @@ type Store interface {
// ReleaseWorld is the post-delete business mutation: owner_id→NULL, // ReleaseWorld is the post-delete business mutation: owner_id→NULL,
// last_active_at→at (clock reset), warned_*→NULL. It does NOT delete the // last_active_at→at (clock reset), warned_*→NULL. It does NOT delete the
// row (red line ②). // row (red line ②). A pending release is done with; a pending deletion
// stays for the next run to finish.
ReleaseWorld(ctx context.Context, name string, at time.Time) error ReleaseWorld(ctx context.Context, name string, at time.Time) error
// DeleteServerRow finishes an admin's deletion once the MinecraftServer is
// gone: the row is marked deleted, which frees its name and subdomain, and
// loses its owner, aliases and allowlist. Only a row with a pending deletion
// is touched, so a server created again under the name is left alone. The
// row itself stays, like every reaped server's (red line ②).
DeleteServerRow(ctx context.Context, name string, at time.Time) error
// RestartClock sets last_active_at→at and clears warned_* on a server with // RestartClock sets last_active_at→at and clears warned_* on a server with
// no world to reclaim, so it is not found idle again every run. // no world to reclaim, so it is not found idle again every run.
RestartClock(ctx context.Context, name string, at time.Time) error RestartClock(ctx context.Context, name string, at time.Time) error
@@ -295,6 +330,11 @@ type Cluster interface {
WorldExists(ctx context.Context, pvc string) (bool, error) WorldExists(ctx context.Context, pvc string) (bool, error)
// DeletePVC deletes the world PersistentVolumeClaim. // DeletePVC deletes the world PersistentVolumeClaim.
DeletePVC(ctx context.Context, pvc string) error DeletePVC(ctx context.Context, pvc string) error
// DeleteServer removes the MinecraftServer whose uid Inspect returned, and
// with it what the operator made for it (StatefulSet, Service, Secret). The
// world volume is not among them: it is deleted first. A server already gone
// is not an error; one of the same name with another uid is left alone.
DeleteServer(ctx context.Context, name, uid string) error
} }
// Warner delivers an impending-reap notice. It is optional and best-effort: a // Warner delivers an impending-reap notice. It is optional and best-effort: a
@@ -323,10 +363,15 @@ type Reaper struct {
type Summary struct { type Summary struct {
Evaluated int Evaluated int
WorldsReaped int WorldsReaped int
Warned int // Released are servers given up by their owner (or released by an admin)
// and ServersDeleted the ones an admin deleted, carried out this run.
Released int
ServersDeleted int
Warned int
// Skipped are servers the run failed on (archive, store, cluster or // Skipped are servers the run failed on (archive, store, cluster or
// capacity errors); their worlds are kept and retried next run. Exempt // capacity errors); their worlds are kept and retried next run. Exempt
// servers and rows whose CRD is gone are not counted. // servers and rows whose CRD is gone are not counted, except a deletion
// left with a world volume and no MinecraftServer to hold it by.
Skipped int Skipped int
// StoreFull are the Skipped servers kept because the backup store was at // StoreFull are the Skipped servers kept because the backup store was at
// capacity and eviction could not make room. // capacity and eviction could not make room.
@@ -453,6 +498,11 @@ func (r *Reaper) evaluate(ctx context.Context, now time.Time, offs []time.Durati
crd, err := r.Cluster.Inspect(ctx, c.Name) crd, err := r.Cluster.Inspect(ctx, c.Name)
if err != nil { if err != nil {
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
if c.RetireDelete {
// An earlier run removed the MinecraftServer and stopped before
// marking the row, or it was removed by hand.
return r.forgetServer(ctx, now, c, sum)
}
// CRD gone but the row lingers — nothing safe to do; not a failure. // CRD gone but the row lingers — nothing safe to do; not a failure.
r.log().Warn("reaper: CRD missing, skipping", "server", c.Name) r.log().Warn("reaper: CRD missing, skipping", "server", c.Name)
return nil return nil
@@ -460,9 +510,18 @@ func (r *Reaper) evaluate(ctx context.Context, now time.Time, offs []time.Durati
return fmt.Errorf("inspect: %w", err) return fmt.Errorf("inspect: %w", err)
} }
if crd.Exempt { if crd.Exempt {
// Red line ①: system servers (lobby/proxy) are never reaped. // Red line ①: system servers (lobby/proxy) are never reaped. felis-api
// refuses to retire one, so a request here predates the flag.
if c.retiring() {
r.log().Warn("reaper: a system server is never given up or deleted; request ignored", "server", c.Name)
}
return nil return nil
} }
if c.retiring() {
// The owner gave the server up, or an admin is deleting it: the world
// goes now, archived like an idle one, and no warning is owed.
return r.reap(ctx, now, c, crd, sum)
}
idle := now.Sub(c.LastActiveAt) idle := now.Sub(c.LastActiveAt)
if idle > r.Cfg.IdleBeforeReap { if idle > r.Cfg.IdleBeforeReap {
@@ -490,7 +549,7 @@ func (r *Reaper) reap(ctx context.Context, now time.Time, c Candidate, crd Serve
return fmt.Errorf("look up world volume: %w", err) return fmt.Errorf("look up world volume: %w", err)
} }
if !exists { if !exists {
return r.reapNoWorld(ctx, now, c, sum) return r.reapNoWorld(ctx, now, c, crd, sum)
} }
// §26 soft cap: free space before adding a backup. If the store cannot be // §26 soft cap: free space before adding a backup. If the store cannot be
@@ -510,7 +569,7 @@ func (r *Reaper) reap(ctx context.Context, now time.Time, c Candidate, crd Serve
// world but failed before deleting the PVC, reuse that backup rather than // world but failed before deleting the PVC, reuse that backup rather than
// writing a duplicate. The world has not changed since last_active_at, so // writing a duplicate. The world has not changed since last_active_at, so
// any present backup created after it still describes the current world. // any present backup created after it still describes the current world.
fresh, ok, err := r.Store.FreshBackup(ctx, c.Name, c.LastActiveAt) fresh, ok, err := r.Store.FreshBackup(ctx, c.Name, c.worldSince())
if err != nil { if err != nil {
return fmt.Errorf("lookup fresh backup: %w", err) return fmt.Errorf("lookup fresh backup: %w", err)
} }
@@ -537,13 +596,17 @@ func (r *Reaper) reap(ctx context.Context, now time.Time, c Candidate, crd Serve
r.log().Warn("reaper: archive leaves out entries that are not plain files or directories", r.log().Warn("reaper: archive leaves out entries that are not plain files or directories",
"server", c.Name, "count", len(a.Skipped), "first", a.Skipped[:min(len(a.Skipped), 5)]) "server", c.Name, "count", len(a.Skipped), "first", a.Skipped[:min(len(a.Skipped), 5)])
} }
reason := ReasonInactive
if c.retiring() {
reason = ReasonReleased
}
rec := BackupRecord{ rec := BackupRecord{
ID: r.id(), ID: r.id(),
ServerName: c.Name, ServerName: c.Name,
FormerOwner: c.OwnerID, FormerOwner: c.OwnerID,
BackupRef: string(a.Ref), BackupRef: string(a.Ref),
SizeBytes: a.Size, SizeBytes: a.Size,
Reason: ReasonInactive, Reason: reason,
ExpiresAt: now.Add(r.Cfg.Retention), ExpiresAt: now.Add(r.Cfg.Retention),
SHA256: a.SHA256, SHA256: a.SHA256,
SkippedEntries: len(a.Skipped), SkippedEntries: len(a.Skipped),
@@ -577,16 +640,24 @@ func (r *Reaper) reap(ctx context.Context, now time.Time, c Candidate, crd Serve
// the delete, so no duplicate archive is created. // the delete, so no duplicate archive is created.
return fmt.Errorf("delete pvc: %w", err) return fmt.Errorf("delete pvc: %w", err)
} }
return r.finishReap(ctx, now, c, ref, sum) return r.finishReap(ctx, now, c, crd, ref, sum)
} }
// finishReap releases a world whose PVC is gone and records the reap. // finishReap releases a world whose PVC is gone and records the reap. A
func (r *Reaper) finishReap(ctx context.Context, now time.Time, c Candidate, ref string, sum *Summary) error { // retirement is finished with it: the server is released, or for a deletion
if err := r.Store.ReleaseWorld(ctx, c.Name, now); err != nil { // removed.
return fmt.Errorf("release world: %w", err) func (r *Reaper) finishReap(ctx context.Context, now time.Time, c Candidate, crd ServerCRD, ref string, sum *Summary) error {
} if c.retiring() {
if err := r.Store.Audit(ctx, AuditRecord{Action: ActionReapWorld, ServerName: c.Name, FormerOwner: c.OwnerID}); err != nil { if err := r.retire(ctx, now, c, crd, sum); err != nil {
r.log().Error("reaper: audit reap_world failed", "server", c.Name, "err", err) return err
}
} else {
if err := r.Store.ReleaseWorld(ctx, c.Name, now); err != nil {
return fmt.Errorf("release world: %w", err)
}
if err := r.Store.Audit(ctx, AuditRecord{Action: ActionReapWorld, ServerName: c.Name, FormerOwner: c.OwnerID}); err != nil {
r.log().Error("reaper: audit reap_world failed", "server", c.Name, "err", err)
}
} }
sum.WorldsReaped++ sum.WorldsReaped++
@@ -604,13 +675,17 @@ func (r *Reaper) finishReap(ctx context.Context, now time.Time, c Candidate, ref
// no world to reclaim: an owner who never started the server gives it up // no world to reclaim: an owner who never started the server gives it up
// (nothing to back up), and an unowned one — typically a world reaped earlier — // (nothing to back up), and an unowned one — typically a world reaped earlier —
// only has its clock restarted, so it is not reaped over and over. // only has its clock restarted, so it is not reaped over and over.
func (r *Reaper) reapNoWorld(ctx context.Context, now time.Time, c Candidate, sum *Summary) error { func (r *Reaper) reapNoWorld(ctx context.Context, now time.Time, c Candidate, crd ServerCRD, sum *Summary) error {
fresh, ok, err := r.Store.FreshBackup(ctx, c.Name, c.LastActiveAt) fresh, ok, err := r.Store.FreshBackup(ctx, c.Name, c.worldSince())
if err != nil { if err != nil {
return fmt.Errorf("lookup fresh backup: %w", err) return fmt.Errorf("lookup fresh backup: %w", err)
} }
if ok { if ok {
return r.finishReap(ctx, now, c, fresh.Ref, sum) return r.finishReap(ctx, now, c, crd, fresh.Ref, sum)
}
if c.retiring() {
// A retired server that never had a world has nothing to archive.
return r.retire(ctx, now, c, crd, sum)
} }
if c.OwnerID == "" { if c.OwnerID == "" {
if err := r.Store.RestartClock(ctx, c.Name, now); err != nil { if err := r.Store.RestartClock(ctx, c.Name, now); err != nil {
@@ -628,6 +703,58 @@ func (r *Reaper) reapNoWorld(ctx context.Context, now time.Time, c Candidate, su
return nil return nil
} }
// retire carries out a retirement once the world is archived and its volume
// deleted, or there was none: a given-up server is released for someone else to
// claim, and a deleted one loses its MinecraftServer and then its row.
func (r *Reaper) retire(ctx context.Context, now time.Time, c Candidate, crd ServerCRD, sum *Summary) error {
if !c.RetireDelete {
if err := r.Store.ReleaseWorld(ctx, c.Name, now); err != nil {
return fmt.Errorf("release world: %w", err)
}
if err := r.Store.Audit(ctx, AuditRecord{Action: ActionReleaseWorld, ServerName: c.Name, FormerOwner: c.OwnerID}); err != nil {
r.log().Error("reaper: audit release_world failed", "server", c.Name, "err", err)
}
sum.Released++
r.log().Info("reaper: server given up and released", "server", c.Name, "former_owner", c.OwnerID)
return nil
}
// The row goes last: a failure in between leaves a row that still asks for
// its deletion, and the next run finishes it (forgetServer).
if err := r.Cluster.DeleteServer(ctx, c.Name, crd.UID); err != nil {
return fmt.Errorf("delete server: %w", err)
}
return r.deleteRow(ctx, now, c, sum)
}
// forgetServer finishes the deletion of a server whose MinecraftServer is gone.
// A world volume left behind (the StatefulSet retains claims, so removing the
// MinecraftServer by hand leaves it) is never deleted unarchived, and without the
// MinecraftServer the reaper cannot hold it still to archive it: an operator
// takes it from there, and the run reports the server until then.
func (r *Reaper) forgetServer(ctx context.Context, now time.Time, c Candidate, sum *Summary) error {
pvc := WorldPVCName(c.Name)
exists, err := r.Cluster.WorldExists(ctx, pvc)
if err != nil {
return fmt.Errorf("look up world volume: %w", err)
}
if exists {
return fmt.Errorf("its MinecraftServer is gone but world volume %s is still there; archive and remove it by hand to finish the deletion", pvc)
}
return r.deleteRow(ctx, now, c, sum)
}
func (r *Reaper) deleteRow(ctx context.Context, now time.Time, c Candidate, sum *Summary) error {
if err := r.Store.DeleteServerRow(ctx, c.Name, now); err != nil {
return fmt.Errorf("mark server deleted: %w", err)
}
if err := r.Store.Audit(ctx, AuditRecord{Action: ActionDeleteServer, ServerName: c.Name, FormerOwner: c.OwnerID}); err != nil {
r.log().Error("reaper: audit delete_server failed", "server", c.Name, "err", err)
}
sum.ServersDeleted++
r.log().Info("reaper: server deleted", "server", c.Name, "former_owner", c.OwnerID)
return nil
}
// ensureCapacity frees the backup store down under MaxLocalBytes by evicting the // ensureCapacity frees the backup store down under MaxLocalBytes by evicting the
// oldest present backups early. Early eviction is destructive (it removes // oldest present backups early. Early eviction is destructive (it removes
// not-yet-expired backups), so each eviction is alerted and audited. It returns // not-yet-expired backups), so each eviction is alerted and audited. It returns
+51 -5
View File
@@ -115,6 +115,9 @@ type fakeCluster struct {
held map[string]bool // servers held right now held map[string]bool // servers held right now
holds []string holds []string
lost context.CancelCauseFunc lost context.CancelCauseFunc
deletedServers []string // name/uid of each DeleteServer
deleteServerErr error
} }
func (c *fakeCluster) HoldWorld(ctx context.Context, name string) (context.Context, func(), error) { func (c *fakeCluster) HoldWorld(ctx context.Context, name string) (context.Context, func(), error) {
@@ -167,10 +170,24 @@ func (c *fakeCluster) DeletePVC(ctx context.Context, pvc string) error {
return nil return nil
} }
func (c *fakeCluster) DeleteServer(ctx context.Context, name, uid string) error {
if c.deleteServerErr != nil {
return c.deleteServerErr
}
if ctx.Err() != nil {
return ctx.Err()
}
c.deletedServers = append(c.deletedServers, name+"/"+uid)
delete(c.crds, name)
c.rec.add("deleteServer")
return nil
}
// ---- fake Store ----------------------------------------------------------- // ---- fake Store -----------------------------------------------------------
type fakeBackup struct { type fakeBackup struct {
id, server, ref string id, server, ref string
owner string
reason string reason string
size int64 size int64
status string // present | deleted status string // present | deleted
@@ -190,6 +207,8 @@ type fakeStore struct {
backups []*fakeBackup backups []*fakeBackup
audits []AuditRecord audits []AuditRecord
released []string released []string
deleted []string
deleteErr error
listErr error listErr error
insertErr error insertErr error
liveErr error liveErr error
@@ -210,7 +229,7 @@ func (s *fakeStore) ListActiveServers(context.Context) ([]Candidate, error) {
func (s *fakeStore) FreshBackup(_ context.Context, server string, since time.Time) (Fresh, bool, error) { func (s *fakeStore) FreshBackup(_ context.Context, server string, since time.Time) (Fresh, bool, error) {
var found *fakeBackup var found *fakeBackup
for _, b := range s.backups { for _, b := range s.backups {
if b.server == server && b.status == "present" && b.reason == ReasonInactive && !b.createdAt.Before(since) && b.corruptAt.IsZero() { if b.server == server && b.status == "present" && (b.reason == ReasonInactive || b.reason == ReasonReleased) && !b.createdAt.Before(since) && b.corruptAt.IsZero() {
if found == nil || (b.offsite && !found.offsite) { if found == nil || (b.offsite && !found.offsite) {
found = b found = b
} }
@@ -227,7 +246,7 @@ func (s *fakeStore) InsertBackup(_ context.Context, rec BackupRecord) error {
return s.insertErr return s.insertErr
} }
s.backups = append(s.backups, &fakeBackup{ s.backups = append(s.backups, &fakeBackup{
id: rec.ID, server: rec.ServerName, ref: rec.BackupRef, reason: rec.Reason, size: rec.SizeBytes, id: rec.ID, server: rec.ServerName, owner: rec.FormerOwner, ref: rec.BackupRef, reason: rec.Reason, size: rec.SizeBytes,
status: "present", createdAt: s.clock, expires: rec.ExpiresAt, sha: rec.SHA256, skipped: rec.SkippedEntries, status: "present", createdAt: s.clock, expires: rec.ExpiresAt, sha: rec.SHA256, skipped: rec.SkippedEntries,
}) })
s.rec.add("insert") s.rec.add("insert")
@@ -240,11 +259,36 @@ func (s *fakeStore) ReleaseWorld(_ context.Context, name string, at time.Time) e
c.LastActiveAt = at c.LastActiveAt = at
c.Warned3dAt = time.Time{} c.Warned3dAt = time.Time{}
c.Warned1dAt = time.Time{} c.Warned1dAt = time.Time{}
if !c.RetireDelete {
c.RetireRequestedAt = time.Time{}
}
s.released = append(s.released, name) s.released = append(s.released, name)
s.rec.add("release") s.rec.add("release")
return nil return nil
} }
// DeleteServerRow drops the row from the listing, as deleted_at does, and only
// when it asks for its deletion (PGStore's condition).
func (s *fakeStore) DeleteServerRow(_ context.Context, name string, _ time.Time) error {
if s.deleteErr != nil {
return s.deleteErr
}
c := s.byName[name]
if c == nil || !c.RetireDelete {
return nil
}
delete(s.byName, name)
for i, n := range s.order {
if n == name {
s.order = append(s.order[:i], s.order[i+1:]...)
break
}
}
s.deleted = append(s.deleted, name)
s.rec.add("deleteRow")
return nil
}
func (s *fakeStore) RestartClock(_ context.Context, name string, at time.Time) error { func (s *fakeStore) RestartClock(_ context.Context, name string, at time.Time) error {
c := s.byName[name] c := s.byName[name]
c.LastActiveAt = at c.LastActiveAt = at
@@ -277,12 +321,12 @@ func (s *fakeStore) PresentBackupBytes(context.Context) (int64, error) {
func (s *fakeStore) EvictableBackups(context.Context) ([]StoredBackup, error) { func (s *fakeStore) EvictableBackups(context.Context) ([]StoredBackup, error) {
var ps []*fakeBackup var ps []*fakeBackup
for _, b := range s.backups { for _, b := range s.backups {
if b.status == "present" && (b.reason != ReasonInactive || b.offsite) { if b.status == "present" && ((b.reason != ReasonInactive && b.reason != ReasonReleased) || b.offsite) {
ps = append(ps, b) ps = append(ps, b)
} }
} }
sort.SliceStable(ps, func(i, j int) bool { sort.SliceStable(ps, func(i, j int) bool {
if ri, rj := ps[i].reason == ReasonInactive, ps[j].reason == ReasonInactive; ri != rj { if ri, rj := isArchive(ps[i].reason), isArchive(ps[j].reason); ri != rj {
return rj return rj
} }
return ps[i].createdAt.Before(ps[j].createdAt) return ps[i].createdAt.Before(ps[j].createdAt)
@@ -294,6 +338,8 @@ func (s *fakeStore) EvictableBackups(context.Context) ([]StoredBackup, error) {
return out, nil return out, nil
} }
func isArchive(reason string) bool { return reason == ReasonInactive || reason == ReasonReleased }
func (s *fakeStore) ListExpiredBackups(_ context.Context, now time.Time) ([]StoredBackup, error) { func (s *fakeStore) ListExpiredBackups(_ context.Context, now time.Time) ([]StoredBackup, error) {
var out []StoredBackup var out []StoredBackup
for _, b := range s.backups { for _, b := range s.backups {
@@ -397,7 +443,7 @@ func newReaper(cfg Config, cands ...Candidate) (*Reaper, *fakeStore, *fakeCluste
cc := cands[i] cc := cands[i]
st.byName[cc.Name] = &cc st.byName[cc.Name] = &cc
st.order = append(st.order, cc.Name) st.order = append(st.order, cc.Name)
cl.crds[cc.Name] = ServerCRD{PVC: "world-" + cc.Name + "-0"} cl.crds[cc.Name] = ServerCRD{PVC: "world-" + cc.Name + "-0", UID: "uid-" + cc.Name}
} }
ar := &fakeArchiver{rec: rec} ar := &fakeArchiver{rec: rec}
r := &Reaper{ r := &Reaper{
+211
View File
@@ -0,0 +1,211 @@
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)
}
}
@@ -0,0 +1,8 @@
-- An owner can give a server up, and an admin can retire one for good
-- (PUT /servers/{name}/retirement). The request is recorded here and carried out
-- by the reaper, the one component that deletes a world: on its next run it
-- archives the world, deletes the volume and releases the server, and with
-- retire_delete it also removes the MinecraftServer and marks this row deleted.
-- Until then the server stays stopped and cannot be woken or claimed.
ALTER TABLE servers ADD COLUMN retire_requested_at timestamptz;
ALTER TABLE servers ADD COLUMN retire_delete boolean NOT NULL DEFAULT false;
+52 -2
View File
@@ -761,7 +761,7 @@ function fleetView(state: MockState, accountInfo: MockAccount): FleetServer[] {
playersOnline: s.ready ? s.playersOnline : 0, playersOnline: s.ready ? s.playersOnline : 0,
owner: owner ? state.accounts[owner].email : "", owner: owner ? state.accounts[owner].email : "",
owned: owner === accountInfo.id, owned: owner === accountInfo.id,
claimable: owner === null, claimable: owner === null && !s.retiring,
}; };
}); });
} }
@@ -775,7 +775,7 @@ function myServerView(serverInfo: MockServer, accountInfo: MockAccount): MyServe
name: serverInfo.name, name: serverInfo.name,
subdomain: serverInfo.subdomain, subdomain: serverInfo.subdomain,
owned, owned,
claimable: serverInfo.owner === null && accountInfo.linked && !owned, claimable: serverInfo.owner === null && !serverInfo.retiring && accountInfo.linked && !owned,
phase: serverInfo.phase, phase: serverInfo.phase,
playersOnline: serverInfo.playersOnline, playersOnline: serverInfo.playersOnline,
playersMax: serverInfo.playersMax, playersMax: serverInfo.playersMax,
@@ -784,6 +784,7 @@ function myServerView(serverInfo: MockServer, accountInfo: MockAccount): MyServe
desiredState: serverInfo.desiredState, desiredState: serverInfo.desiredState,
autostartPolicy: serverInfo.autostartPolicy, autostartPolicy: serverInfo.autostartPolicy,
playerCountUnknown: serverInfo.playerCountUnknown, playerCountUnknown: serverInfo.playerCountUnknown,
retiring: serverInfo.retiring,
}), }),
}; };
} }
@@ -2234,6 +2235,10 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account"); sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true; return true;
} }
if (serverInfo.retiring) {
sendError(ctx.res, 409, "server_retiring", "this server is being given up or deleted; cancel that first");
return true;
}
setPhase(serverInfo, "Starting"); setPhase(serverInfo, "Starting");
sendJSON(ctx.res, 200, { name: serverInfo.name, desiredState: "Running" }); sendJSON(ctx.res, 200, { name: serverInfo.name, desiredState: "Running" });
return true; return true;
@@ -2255,6 +2260,10 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
handleCommandMock(ctx, serverInfo); handleCommandMock(ctx, serverInfo);
return true; return true;
} }
if (ctx.parts[4] === "retirement" && (is("PUT", ctx) || is("DELETE", ctx))) {
await handleRetirementMock(ctx, serverInfo);
return true;
}
if (is("POST", ctx) && ctx.parts[4] === "claim") { if (is("POST", ctx) && ctx.parts[4] === "claim") {
claimServer(ctx, serverInfo); claimServer(ctx, serverInfo);
return true; return true;
@@ -2695,6 +2704,47 @@ function mockCommandReply(cmd: string): string {
return `[mock] command "${cmd}" executed`; return `[mock] command "${cmd}" executed`;
} }
// handleRetirementMock mirrors handlers_retire.go: owner-or-admin, delete is an
// admin's, the typed name must match, a system server is refused. The mock has no
// reaper, so the request just sits until cancelled; the cancel of a deletion is an
// admin's too.
async function handleRetirementMock(ctx: SessionContext, serverInfo: MockServer): Promise<void> {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return;
}
const admin = isAdmin(ctx.account.role);
if (is("DELETE", ctx)) {
if (serverInfo.retiring?.delete && !admin) {
sendError(ctx.res, 403, "forbidden", "only an administrator can cancel the deletion of a server");
return;
}
serverInfo.retiring = undefined;
ctx.res.statusCode = 204;
ctx.res.end();
return;
}
const body = await readJSON<{ confirm?: string; delete?: boolean }>(ctx.req);
if (body.delete && !admin) {
sendError(ctx.res, 403, "forbidden", "only an administrator can delete a server");
return;
}
if (body.confirm !== serverInfo.name) {
sendError(ctx.res, 400, "confirm_mismatch", "type the server's name to confirm");
return;
}
if (serverInfo.reaperExempt) {
sendError(ctx.res, 409, "system_server", "a system server cannot be given up or deleted");
return;
}
setPhase(serverInfo, "Stopped");
serverInfo.retiring = {
requested_at: serverInfo.retiring?.requested_at ?? new Date().toISOString(),
delete: (serverInfo.retiring?.delete ?? false) || body.delete === true,
};
sendJSON(ctx.res, 202, { name: serverInfo.name, retiring: serverInfo.retiring });
}
function claimServer(ctx: SessionContext, serverInfo: MockServer): void { function claimServer(ctx: SessionContext, serverInfo: MockServer): void {
if (serverInfo.owner !== null) { if (serverInfo.owner !== null) {
sendError(ctx.res, 409, "already_claimed", "server is already claimed"); sendError(ctx.res, 409, "already_claimed", "server is already claimed");
+18
View File
@@ -255,3 +255,21 @@ describe("PowerButton on a server already moving", () => {
}); });
} }
}); });
describe("PowerButton on a server given up or being deleted", () => {
it("offers nothing to press, only the badge saying why", () => {
const requested_at = new Date().toISOString();
const { rerender } = render(
<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" retiring={{ requested_at, delete: false }} onChanged={vi.fn()} />,
);
expect(screen.queryByRole("button")).toBeNull();
expect(screen.getByText(t("servers:retiring_badge_release"))).toBeTruthy();
// A failed start would otherwise offer its retry.
rerender(
<PowerButton name="lobby" phase="Failed" desiredState="Running" failed retiring={{ requested_at, delete: true }} onChanged={vi.fn()} />,
);
expect(screen.queryByRole("button")).toBeNull();
expect(screen.getByText(t("servers:retiring_badge_delete"))).toBeTruthy();
});
});
+10 -2
View File
@@ -3,8 +3,9 @@ import { Loader2, Play, RotateCcw, Square } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { pendingPower } from "@/components/PhaseBadge"; import { pendingPower } from "@/components/PhaseBadge";
import { RetiringBadge } from "@/components/Retirement";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import type { Phase } from "@/lib/types"; import type { Phase, RetireState } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** How long a sent wake or stop shows as in progress when the view never /** How long a sent wake or stop shows as in progress when the view never
@@ -25,6 +26,9 @@ interface Props {
playersOnline?: number; playersOnline?: number;
/** The operator cannot read the player count, so players may be online. */ /** The operator cannot read the player count, so players may be online. */
playerCountUnknown?: boolean; playerCountUnknown?: boolean;
/** A pending retirement: nothing starts the server until it is cancelled, so
* the control gives way to a badge saying why. */
retiring?: RetireState;
/** Called after the wake or stop was accepted, so the parent refetches. */ /** Called after the wake or stop was accepted, so the parent refetches. */
onChanged: () => void; onChanged: () => void;
size?: "sm" | "default"; size?: "sm" | "default";
@@ -49,6 +53,7 @@ function isUp(phase: Phase): boolean {
// again into a 429. A server on its way down offers nothing until it is down; // again into a 429. A server on its way down offers nothing until it is down;
// one asked to run with no pod yet offers Stop, which is the way out when it // one asked to run with no pod yet offers Stop, which is the way out when it
// never comes up. // never comes up.
// A server given up or being deleted offers nothing to press, only why.
export function PowerButton({ export function PowerButton({
name, name,
phase, phase,
@@ -56,6 +61,7 @@ export function PowerButton({
failed = false, failed = false,
playersOnline, playersOnline,
playerCountUnknown, playerCountUnknown,
retiring,
onChanged, onChanged,
size = "sm", size = "sm",
className, className,
@@ -125,7 +131,9 @@ export function PowerButton({
); );
let control; let control;
if (submitted !== null) { if (retiring) {
control = <RetiringBadge retiring={retiring} />;
} else if (submitted !== null) {
control = inProgress(submitted); control = inProgress(submitted);
} else if (failed) { } else if (failed) {
control = ( control = (
+126
View File
@@ -0,0 +1,126 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import i18next from "i18next";
import { RetireCard, RetireNotice } from "./Retirement";
const calls = vi.hoisted(() => ({ retireServer: vi.fn(), cancelRetire: vi.fn() }));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, ...calls } };
});
const t = (key: string, opts?: Record<string, unknown>) => i18next.t(key, opts);
const hourAgo = () => new Date(Date.now() - 3600_000).toISOString();
beforeEach(() => {
calls.retireServer.mockReset();
calls.cancelRetire.mockReset();
});
describe("RetireCard", () => {
it("lets an owner give the server up once its name is typed out, and never offers a deletion", async () => {
calls.retireServer.mockResolvedValue({ name: "survival", retiring: { requested_at: hourAgo(), delete: false } });
const onChanged = vi.fn();
render(<RetireCard name="survival" label="Survival World" isAdmin={false} onChanged={onChanged} />);
expect(screen.queryByRole("button", { name: t("servers:retire_delete") })).toBeNull();
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_release") }));
const dialog = screen.getByRole("dialog");
expect(dialog.textContent).toContain(t("servers:retire_release_title", { name: "Survival World" }));
expect(dialog.textContent).toContain(t("servers:retire_step_cancel_owner"));
const confirm = screen.getByRole("button", { name: t("servers:retire_confirm_release") });
const input = screen.getByLabelText(t("servers:retire_confirm_label"), { exact: false });
// The display name, a different case or a prefix is not the server's name.
for (const wrong of ["Survival World", "Survival", "surviva"]) {
await userEvent.clear(input);
await userEvent.type(input, wrong + "{Enter}");
expect(confirm).toHaveProperty("disabled", true);
}
expect(calls.retireServer).not.toHaveBeenCalled();
await userEvent.clear(input);
await userEvent.type(input, "survival");
expect(confirm).toHaveProperty("disabled", false);
await userEvent.click(confirm);
expect(calls.retireServer).toHaveBeenCalledExactlyOnceWith("survival", { confirm: "survival", delete: false });
expect(onChanged).toHaveBeenCalledOnce();
expect(screen.queryByRole("dialog")).toBeNull();
});
it("lets an admin delete the server, and says only an admin can take that back", async () => {
calls.retireServer.mockResolvedValue({ name: "survival", retiring: { requested_at: hourAgo(), delete: true } });
const onChanged = vi.fn();
render(<RetireCard name="survival" label="survival" isAdmin onChanged={onChanged} />);
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_delete") }));
const dialog = screen.getByRole("dialog");
expect(dialog.textContent).toContain(t("servers:retire_step_delete"));
expect(dialog.textContent).toContain(t("servers:retire_step_cancel_admin"));
await userEvent.type(screen.getByLabelText(t("servers:retire_confirm_label"), { exact: false }), "survival{Enter}");
expect(calls.retireServer).toHaveBeenCalledExactlyOnceWith("survival", { confirm: "survival", delete: true });
expect(onChanged).toHaveBeenCalledOnce();
});
it("keeps the dialog open with the reason when the request is refused", async () => {
calls.retireServer.mockRejectedValue({ status: 409, code: "world_volume_orphaned", message: "raw" });
const onChanged = vi.fn();
render(<RetireCard name="survival" label="survival" isAdmin onChanged={onChanged} />);
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_delete") }));
await userEvent.type(screen.getByLabelText(t("servers:retire_confirm_label"), { exact: false }), "survival");
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_confirm_delete") }));
expect((await screen.findByRole("alert")).textContent).toBe(t("errors:world_volume_orphaned"));
expect(screen.getByRole("dialog")).toBeTruthy();
expect(onChanged).not.toHaveBeenCalled();
});
});
describe("RetireNotice", () => {
it("lets the owner take back giving the server up", async () => {
calls.cancelRetire.mockResolvedValue(undefined);
const onChanged = vi.fn();
render(
<RetireNotice name="survival" retiring={{ requested_at: hourAgo(), delete: false }} isAdmin={false} onChanged={onChanged} />,
);
expect(screen.getByRole("status").textContent).toContain(t("servers:retiring_title_release"));
expect(screen.getByRole("status").textContent).toContain("1 hour ago");
await userEvent.click(screen.getByRole("button", { name: t("servers:retiring_cancel") }));
expect(calls.cancelRetire).toHaveBeenCalledExactlyOnceWith("survival");
expect(onChanged).toHaveBeenCalledOnce();
});
it("offers the owner no cancel of an admin's deletion, and says who can", () => {
render(
<RetireNotice name="survival" retiring={{ requested_at: hourAgo(), delete: true }} isAdmin={false} onChanged={vi.fn()} />,
);
expect(screen.getByRole("status").textContent).toContain(t("servers:retiring_title_delete"));
expect(screen.getByRole("status").textContent).toContain(t("servers:retiring_cancel_admin_only"));
expect(screen.queryByRole("button", { name: t("servers:retiring_cancel") })).toBeNull();
});
it("lets an admin cancel a deletion, and shows why a cancel failed", async () => {
calls.cancelRetire.mockRejectedValue({ status: 403, code: "forbidden", message: "raw" });
const onChanged = vi.fn();
render(
<RetireNotice name="survival" retiring={{ requested_at: hourAgo(), delete: true }} isAdmin onChanged={onChanged} />,
);
expect(screen.getByRole("status").textContent).not.toContain(t("servers:retiring_cancel_admin_only"));
await userEvent.click(screen.getByRole("button", { name: t("servers:retiring_cancel") }));
expect(calls.cancelRetire).toHaveBeenCalledWith("survival");
expect(await screen.findByRole("alert")).toBeTruthy();
expect(onChanged).not.toHaveBeenCalled();
});
});
+244
View File
@@ -0,0 +1,244 @@
import { useState } from "react";
import { Hourglass, Loader2, PackageX } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import { MessageLine } from "@/components/MessageLine";
import { api, humanizeError } from "@/lib/api";
import { formatAbsolute, formatRelative } from "@/lib/format";
import type { RetireState } from "@/lib/types";
import { cn } from "@/lib/utils";
// A server leaves its owner, or the platform, through a retirement
// (internal/api/handlers_retire.go): the owner gives it up, or an admin deletes
// it. felis-api stops the server and records the request; the reaper archives the
// world and carries it out on its next daily run. Until then the server cannot be
// started or claimed, and the request can be cancelled: a give-up by its owner or
// an admin, a deletion by an admin only.
/** RetiringBadge stands in for the start/stop control of a server with a pending
* retirement: nothing can start it until the request is cancelled or done. */
export function RetiringBadge({ retiring, className }: { retiring: RetireState; className?: string }) {
const { t } = useTranslation("servers");
return (
<span
title={t("retiring_badge_hint")}
className={cn(
"inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300",
className,
)}
>
<Hourglass className="h-3 w-3" />
{retiring.delete ? t("retiring_badge_delete") : t("retiring_badge_release")}
</span>
);
}
/** RetireNotice heads the console of a server with a pending retirement: what
* happens at the next reclaim run, and the way back while there still is one. */
export function RetireNotice({
name,
retiring,
isAdmin,
onChanged,
}: {
name: string;
retiring: RetireState;
isAdmin: boolean;
onChanged: () => void;
}) {
const { t, i18n } = useTranslation("servers");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const canCancel = isAdmin || !retiring.delete;
const when = formatRelative(retiring.requested_at, Date.now(), i18n.language);
async function cancel() {
setBusy(true);
setError(null);
try {
await api.cancelRetire(name);
onChanged();
} catch (e) {
setError(humanizeError(e));
} finally {
setBusy(false);
}
}
return (
<div
role="status"
className="flex shrink-0 flex-col gap-3 rounded-lg border border-amber-500/40 bg-amber-500/10 p-4 text-sm sm:flex-row sm:items-start"
>
<Hourglass className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium text-foreground">
{retiring.delete ? t("retiring_title_delete") : t("retiring_title_release")}
</p>
<p className="text-muted-foreground" title={formatAbsolute(retiring.requested_at, i18n.language)}>
{retiring.delete ? t("retiring_body_delete", { when }) : t("retiring_body_release", { when })}
</p>
{!canCancel && <p className="text-xs text-muted-foreground">{t("retiring_cancel_admin_only")}</p>}
{error && <MessageLine kind="error" message={error} compact />}
</div>
{canCancel && (
<Button size="sm" variant="outline" className="shrink-0 self-start" onClick={() => void cancel()} disabled={busy}>
{busy && <Loader2 className="animate-spin" />}
{t("retiring_cancel")}
</Button>
)}
</div>
);
}
type Mode = "release" | "delete";
/** RetireCard is the console sidebar's way to give a server up (its owner or an
* admin) or delete it (an admin). Either asks for the server's name typed out,
* the same confirmation felis-api requires, before anything is sent. */
export function RetireCard({
name,
label,
isAdmin,
onChanged,
}: {
name: string;
/** What the dialog calls the server: its display name, else its name. */
label: string;
isAdmin: boolean;
onChanged: () => void;
}) {
const { t } = useTranslation("servers");
const [mode, setMode] = useState<Mode | null>(null);
return (
<div className="space-y-3 rounded-lg border border-destructive/30 bg-card p-4">
<div className="flex items-start gap-3">
<PackageX className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{isAdmin ? t("retire_card_title_admin") : t("retire_card_title")}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{isAdmin ? t("retire_card_desc_admin") : t("retire_card_desc")}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
className="border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setMode("release")}
>
{t("retire_release")}
</Button>
{isAdmin && (
<Button size="sm" variant="destructive" onClick={() => setMode("delete")}>
{t("retire_delete")}
</Button>
)}
</div>
{mode && (
<RetireDialog
name={name}
label={label}
mode={mode}
isAdmin={isAdmin}
onClose={() => setMode(null)}
onDone={onChanged}
/>
)}
</div>
);
}
function RetireDialog({
name,
label,
mode,
isAdmin,
onClose,
onDone,
}: {
name: string;
label: string;
mode: Mode;
isAdmin: boolean;
onClose: () => void;
onDone: () => void;
}) {
const { t } = useTranslation("servers");
const [typed, setTyped] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const del = mode === "delete";
function setOpen(open: boolean) {
if (!open && !busy) onClose();
}
async function confirm() {
if (typed !== name || busy) return;
setBusy(true);
setError(null);
try {
await api.retireServer(name, { confirm: typed, delete: del });
setBusy(false);
onClose();
onDone();
} catch (e) {
setError(humanizeError(e));
setBusy(false);
}
}
return (
<Dialog open onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{del ? t("retire_delete_title", { name: label }) : t("retire_release_title", { name: label })}</DialogTitle>
<DialogDescription asChild>
<ul className="list-disc space-y-1 pl-5 text-left">
<li>{t("retire_step_stop")}</li>
<li>{del ? t("retire_step_delete") : t("retire_step_release")}</li>
<li>{isAdmin ? t("retire_step_cancel_admin") : t("retire_step_cancel_owner")}</li>
</ul>
</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<label htmlFor="retire-confirm" className="text-sm">
{t("retire_confirm_label")} <code className="rounded bg-muted px-1 font-mono text-xs">{name}</code>
</label>
<Input
id="retire-confirm"
value={typed}
onChange={(e) => setTyped(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void confirm();
}}
autoComplete="off"
spellCheck={false}
className="font-mono"
/>
</div>
{error && <MessageLine kind="error" message={error} compact />}
<ConfirmFooter
onCancel={() => setOpen(false)}
onConfirm={() => void confirm()}
disabled={typed !== name}
loading={busy}
cancelLabel={t("access_cancel")}
confirmLabel={del ? t("retire_confirm_delete") : t("retire_confirm_release")}
/>
</DialogContent>
</Dialog>
);
}
+16
View File
@@ -39,6 +39,22 @@ function notRunning(
} }
describe("NotRunning", () => { describe("NotRunning", () => {
it("offers no wake for a server given up or being deleted, only why", () => {
render(
<NotRunning
title="Server is asleep"
body="Wake it to manage players."
serverName="survival"
phase="Stopped"
desiredState="Stopped"
retiring={{ requested_at: new Date().toISOString(), delete: false }}
onWoken={vi.fn()}
/>,
);
expect(screen.queryByRole("button")).toBeNull();
expect(screen.getByText("Given up")).toBeTruthy();
});
it("offers the wake for a server that is down and meant to stay down", () => { it("offers the wake for a server that is down and meant to stay down", () => {
render(notRunning("Stopped", "Stopped")); render(notRunning("Stopped", "Stopped"));
expect(screen.getByText("Server is asleep")).toBeTruthy(); expect(screen.getByText("Server is asleep")).toBeTruthy();
+5 -1
View File
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { MAX_AUTO_RESTARTS, shownPhase, type StartFailure } from "@/components/PhaseBadge"; import { MAX_AUTO_RESTARTS, shownPhase, type StartFailure } from "@/components/PhaseBadge";
import { PowerButton } from "@/components/PowerButton"; import { PowerButton } from "@/components/PowerButton";
import { humanizeError } from "@/lib/api"; import { humanizeError } from "@/lib/api";
import type { Phase } from "@/lib/types"; import type { Phase, RetireState } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function Loading({ label }: { label?: string }) { export function Loading({ label }: { label?: string }) {
@@ -174,6 +174,8 @@ export interface NotRunningProps {
/** From startFailure: a Failed start gets its own copy and a retry. */ /** From startFailure: a Failed start gets its own copy and a retry. */
failure?: StartFailure | null; failure?: StartFailure | null;
autoRestarts?: number; autoRestarts?: number;
/** A pending retirement: the page offers no wake, only why. */
retiring?: RetireState;
/** Called once the wake was accepted, so the page refetches its status. */ /** Called once the wake was accepted, so the page refetches its status. */
onWoken: () => void; onWoken: () => void;
} }
@@ -190,6 +192,7 @@ export function NotRunning({
desiredState, desiredState,
failure = null, failure = null,
autoRestarts = 0, autoRestarts = 0,
retiring,
onWoken, onWoken,
}: NotRunningProps) { }: NotRunningProps) {
const { t } = useTranslation("servers"); const { t } = useTranslation("servers");
@@ -232,6 +235,7 @@ export function NotRunning({
phase={phase} phase={phase}
desiredState={desiredState} desiredState={desiredState}
failed={failure !== null} failed={failure !== null}
retiring={retiring}
onChanged={onWoken} onChanged={onWoken}
className="items-center" className="items-center"
/> />
@@ -7,6 +7,7 @@
"latest_title": "Latest backup", "latest_title": "Latest backup",
"history_note": "Backups can be restored until they expire, then they are cleaned up automatically. Each server keeps only its most recent manual backups and its most recent scheduled backups (older ones of the same kind are removed once a new one finishes), and its last 3 pre-restore snapshots.", "history_note": "Backups can be restored until they expire, then they are cleaned up automatically. Each server keeps only its most recent manual backups and its most recent scheduled backups (older ones of the same kind are removed once a new one finishes), and its last 3 pre-restore snapshots.",
"reason_inactive": "Idle archive", "reason_inactive": "Idle archive",
"reason_released": "Archived when given up or deleted",
"reason_manual": "Manual backup", "reason_manual": "Manual backup",
"reason_pre_restore": "Pre-restore snapshot", "reason_pre_restore": "Pre-restore snapshot",
"reason_scheduled": "Scheduled backup", "reason_scheduled": "Scheduled backup",
@@ -13,6 +13,10 @@
"subdomain_taken": "That subdomain is already in use.", "subdomain_taken": "That subdomain is already in use.",
"already_exists": "A server with that name already exists.", "already_exists": "A server with that name already exists.",
"world_volume_exists": "An earlier server with that name left its world volume behind. Choose another name, or ask the operator to delete the old volume.", "world_volume_exists": "An earlier server with that name left its world volume behind. Choose another name, or ask the operator to delete the old volume.",
"server_retiring": "This server has been given up or is being deleted, which happens at the next daily reclaim run. It can't be started or claimed unless that is cancelled.",
"confirm_mismatch": "The name you typed doesn't match the server's name.",
"system_server": "A system server is managed by the platform and can't be given up or deleted.",
"world_volume_orphaned": "This server's MinecraftServer was deleted by hand but its world volume is still there. Ask the operator to archive and remove that volume first, then delete the server.",
"cooldown": "Wake is cooling down — try again shortly.", "cooldown": "Wake is cooling down — try again shortly.",
"not_running": "The server isn't running — wake it before managing access.", "not_running": "The server isn't running — wake it before managing access.",
"console_unavailable": "Can't reach the server console right now — try again shortly.", "console_unavailable": "Can't reach the server console right now — try again shortly.",
+26 -1
View File
@@ -219,5 +219,30 @@
"idle_stop_hours": "{{count}} h", "idle_stop_hours": "{{count}} h",
"stop_confirm_players_one": "1 player is online and will be disconnected. Stop anyway?", "stop_confirm_players_one": "1 player is online and will be disconnected. Stop anyway?",
"stop_confirm_players_other": "{{count}} players are online and will be disconnected. Stop anyway?", "stop_confirm_players_other": "{{count}} players are online and will be disconnected. Stop anyway?",
"stop_confirm_unknown": "The player count can't be read, so players may be online. Stop anyway?" "stop_confirm_unknown": "The player count can't be read, so players may be online. Stop anyway?",
"retire_card_title": "Give up server",
"retire_card_title_admin": "Give up or delete server",
"retire_card_desc": "Hand it back when you no longer need it: the world is archived, then the server is freed for someone else to claim.",
"retire_card_desc_admin": "Giving up archives the world and frees the server; deleting archives the world and removes the server, freeing its name and subdomain.",
"retire_release": "Give up",
"retire_delete": "Delete",
"retire_release_title": "Give up \"{{name}}\"?",
"retire_delete_title": "Delete \"{{name}}\"?",
"retire_step_stop": "The server stops now and can't be started until this is done.",
"retire_step_release": "At the next daily reclaim run the world is archived (kept 90 days by default), its storage deleted, and the server freed for someone else to claim.",
"retire_step_delete": "At the next daily reclaim run the world is archived (kept 90 days by default), then its storage and this server are deleted, freeing the name and subdomain.",
"retire_step_cancel_owner": "You can cancel from the console until the run; until then it still counts toward your quota.",
"retire_step_cancel_admin": "An administrator can cancel from the console until the run.",
"retire_confirm_label": "Type the server name to confirm:",
"retire_confirm_release": "Give up server",
"retire_confirm_delete": "Delete server",
"retiring_title_release": "This server has been given up",
"retiring_title_delete": "This server is about to be deleted",
"retiring_body_release": "Requested {{when}}. At the next daily reclaim run the world is archived, then its storage is deleted and the server freed. Until then it stays stopped, can't be started and still counts toward the quota.",
"retiring_body_delete": "Requested {{when}}. At the next daily reclaim run the world is archived, then its storage and this server are deleted. Until then it stays stopped and can't be started.",
"retiring_cancel": "Cancel request",
"retiring_cancel_admin_only": "An administrator asked for the deletion, so only an administrator can cancel it.",
"retiring_badge_release": "Given up",
"retiring_badge_delete": "Deleting",
"retiring_badge_hint": "Given up or being deleted; handled at the next daily reclaim run, and it can't be started until then."
} }
@@ -7,6 +7,7 @@
"latest_title": "最新备份", "latest_title": "最新备份",
"history_note": "历史备份在过期前均可用于恢复,到期后自动清理。手动备份和定时备份每台服务器各只保留最近几份,新备份完成后会自动删除更早的同类备份;恢复前快照保留最近 3 份。", "history_note": "历史备份在过期前均可用于恢复,到期后自动清理。手动备份和定时备份每台服务器各只保留最近几份,新备份完成后会自动删除更早的同类备份;恢复前快照保留最近 3 份。",
"reason_inactive": "闲置自动回收", "reason_inactive": "闲置自动回收",
"reason_released": "放弃或删除时归档",
"reason_manual": "手动备份", "reason_manual": "手动备份",
"reason_pre_restore": "恢复前快照", "reason_pre_restore": "恢复前快照",
"reason_scheduled": "定时备份", "reason_scheduled": "定时备份",
@@ -13,6 +13,10 @@
"subdomain_taken": "该子域名已被占用。", "subdomain_taken": "该子域名已被占用。",
"already_exists": "同名服务器已存在。", "already_exists": "同名服务器已存在。",
"world_volume_exists": "同名的旧服务器留下了世界卷。请换一个名称,或请运维删除旧的世界卷。", "world_volume_exists": "同名的旧服务器留下了世界卷。请换一个名称,或请运维删除旧的世界卷。",
"server_retiring": "这台服务器已被放弃或正在删除,下一次每日回收时处理。撤销之前它不能启动或被认领。",
"confirm_mismatch": "输入的名称和服务器名称不一致。",
"system_server": "系统服务器由平台管理,不能放弃或删除。",
"world_volume_orphaned": "这台服务器的 MinecraftServer 已被手动删除,但世界卷还在。请运维先归档并删除这个世界卷,再删除服务器。",
"cooldown": "启动冷却中——请稍后再试。", "cooldown": "启动冷却中——请稍后再试。",
"not_running": "服务器未在运行——请先启动它再管理访问权限。", "not_running": "服务器未在运行——请先启动它再管理访问权限。",
"console_unavailable": "暂时无法连接服务器控制台,请稍后重试。", "console_unavailable": "暂时无法连接服务器控制台,请稍后重试。",
+26 -1
View File
@@ -218,5 +218,30 @@
"idle_stop_minutes": "{{count}} 分钟", "idle_stop_minutes": "{{count}} 分钟",
"idle_stop_hours": "{{count}} 小时", "idle_stop_hours": "{{count}} 小时",
"stop_confirm_players": "{{count}} 名玩家在线,停服会断开他们。确定停止?", "stop_confirm_players": "{{count}} 名玩家在线,停服会断开他们。确定停止?",
"stop_confirm_unknown": "读不到在线人数,可能有玩家在线。确定停止?" "stop_confirm_unknown": "读不到在线人数,可能有玩家在线。确定停止?",
"retire_card_title": "放弃服务器",
"retire_card_title_admin": "放弃或删除服务器",
"retire_card_desc": "不再需要时交还给平台:世界归档后,服务器释放给其他人认领。",
"retire_card_desc_admin": "放弃会归档世界并释放服务器;删除会归档世界并移除服务器,名称和子域名可以重新使用。",
"retire_release": "放弃服务器",
"retire_delete": "删除服务器",
"retire_release_title": "放弃「{{name}}」?",
"retire_delete_title": "删除「{{name}}」?",
"retire_step_stop": "服务器会立即停止,处理完之前不能启动。",
"retire_step_release": "下一次每日回收运行时,世界先归档(默认保留 90 天),然后删除世界存储,服务器释放给其他人认领。",
"retire_step_delete": "下一次每日回收运行时,世界先归档(默认保留 90 天),然后删除世界存储和这台服务器,名称和子域名释放出来。",
"retire_step_cancel_owner": "回收运行之前可以在控制台撤销;在此之前它仍计入你的配额。",
"retire_step_cancel_admin": "回收运行之前管理员可以在控制台撤销。",
"retire_confirm_label": "输入服务器名称以确认:",
"retire_confirm_release": "放弃服务器",
"retire_confirm_delete": "删除服务器",
"retiring_title_release": "这台服务器已被放弃",
"retiring_title_delete": "这台服务器即将被删除",
"retiring_body_release": "{{when}}提出。下一次每日回收运行时会归档世界,然后删除世界存储并释放服务器。在此之前它保持停止、不能启动,仍计入配额。",
"retiring_body_delete": "{{when}}提出。下一次每日回收运行时会归档世界,然后删除世界存储和这台服务器。在此之前它保持停止、不能启动。",
"retiring_cancel": "撤销",
"retiring_cancel_admin_only": "删除由管理员提出,只有管理员可以撤销。",
"retiring_badge_release": "等待回收",
"retiring_badge_delete": "等待删除",
"retiring_badge_hint": "已放弃或正在删除,下一次每日回收时处理;在此之前不能启动。"
} }
+23
View File
@@ -273,6 +273,29 @@ describe("api access-control wire shapes", () => {
beforeEach(() => vi.restoreAllMocks()); beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
it("retireServer PUTs the typed name and the delete flag to the retirement path", async () => {
const requested_at = "2026-09-27T12:00:00Z";
const fetchSpy = fakeFetch({ name: "survival", retiring: { requested_at, delete: true } }, { status: 202 });
vi.stubGlobal("fetch", fetchSpy);
const res = await api.retireServer("survival", { confirm: "survival", delete: true });
expect(res.retiring).toEqual({ requested_at, delete: true });
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/servers/survival/retirement");
expect((opts as RequestInit).method).toBe("PUT");
expect(JSON.parse(String((opts as RequestInit).body))).toEqual({ confirm: "survival", delete: true });
});
it("cancelRetire DELETEs the retirement path", async () => {
const fetchSpy = vi.fn(async () => ({ ok: true, status: 204, statusText: "No Content", text: async () => "" })) as unknown as typeof fetch;
vi.stubGlobal("fetch", fetchSpy);
await api.cancelRetire("survival");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/servers/survival/retirement");
expect((opts as RequestInit).method).toBe("DELETE");
});
it("accessWhitelistList GETs the whitelist path and returns players + raw output", async () => { it("accessWhitelistList GETs the whitelist path and returns players + raw output", async () => {
const fetchSpy = fakeFetch({ const fetchSpy = fakeFetch({
name: "survival", name: "survival",
+23
View File
@@ -21,6 +21,7 @@ import type {
PlayersResult, PlayersResult,
QuotaInput, QuotaInput,
QuotaView, QuotaView,
RetireState,
ServerFileEntry, ServerFileEntry,
ServerJob, ServerJob,
MyServerView, MyServerView,
@@ -415,6 +416,16 @@ export const api = rejectingSync({
claim: (name: string) => claim: (name: string) =>
request<{ name: string; claimed: boolean }>("POST", urlPath`/servers/${name}/claim`), request<{ name: string; claimed: boolean }>("POST", urlPath`/servers/${name}/claim`),
// Retirement (PUT/DELETE /servers/{name}/retirement): the owner gives the server
// up, or an admin deletes it (delete: true, admin only). felis-api stops the
// server and records the request; the reaper archives the world and carries it
// out on its next daily run. confirm must repeat the server's name. The cancel
// is the owner's for a give-up and an admin's for a deletion (403 otherwise).
retireServer: (name: string, body: { confirm: string; delete: boolean }) =>
request<{ name: string; retiring: RetireState }>("PUT", urlPath`/servers/${name}/retirement`, body),
cancelRetire: (name: string) => request<void>("DELETE", urlPath`/servers/${name}/retirement`),
/** sendCommand runs one RCON command against a running server (spec §8 写=RCON). /** sendCommand runs one RCON command against a running server (spec §8 写=RCON).
* The backend strips a leading "/", rejects control characters (newline → 400) * The backend strips a leading "/", rejects control characters (newline → 400)
* and caps the command at 1000 bytes. The reply is the server's plain-text * and caps the command at 1000 bytes. The reply is the server's plain-text
@@ -1044,6 +1055,18 @@ export function humanizeError(e: unknown): string {
// new server cannot mount the old world. // new server cannot mount the old world.
case "world_volume_exists": case "world_volume_exists":
return t("world_volume_exists"); return t("world_volume_exists");
// Retirement (internal/api/handlers_retire.go): a server given up or being
// deleted cannot be woken or claimed until that is cancelled or done; the
// request repeats the server's name; a system server is never retired; a
// deletion refuses while a hand-deleted server's world volume is left.
case "server_retiring":
return t("server_retiring");
case "confirm_mismatch":
return t("confirm_mismatch");
case "system_server":
return t("system_server");
case "world_volume_orphaned":
return t("world_volume_orphaned");
case "cooldown": case "cooldown":
return t("cooldown"); return t("cooldown");
// Access control (spec §7): the server must be Running for any RCON-backed // Access control (spec §7): the server must be Running for any RCON-backed
+136 -5
View File
@@ -689,6 +689,30 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/servers/{name}/retirement": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Give the server up (owner) or delete it (admin). The reaper carries it out.
* @description The server is stopped and the request recorded; the reaper, the one component that deletes a world, carries it out on its next daily run. It archives the world as a released backup (kept for the reaper's retention, recorded against the owner), deletes the world volume and releases the server for anyone to claim; with delete it also removes the server, which frees its name and subdomain. Until then the server cannot be woken or claimed and still counts against the owner's quota, and the request can be cancelled. Repeating it keeps the first request time, and a deletion stays a deletion. confirm must repeat the server's name. Audited as server.release / server.delete.
*/
put: operations["retireServer"];
post?: never;
/**
* Cancel a pending retirement. Only an admin cancels a deletion.
* @description The server stays stopped; its owner starts it again when they want it. Cancelling when nothing is pending changes nothing. Audited as server.retire_cancel.
*/
delete: operations["cancelRetire"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/status": { "/api/v1/servers/{name}/status": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2404,6 +2428,10 @@ export interface components {
autoRestarts?: number; autoRestarts?: number;
/** @description Present and true for a Failed server no automatic retry will bring up: its start timed out with the retries spent, or its spec is invalid. A Failed server without it is still in its restart backoff and may come up on its own. */ /** @description Present and true for a Failed server no automatic retry will bring up: its start timed out with the retries spent, or its spec is invalid. A Failed server without it is still in its restart backoff and may come up on its own. */
startGaveUp?: boolean; startGaveUp?: boolean;
/** @description Present and true for a system server the reaper never touches; it cannot be given up or deleted. */
reaperExempt?: boolean;
/** @description Owner and staff only. Present while the owner has given the server up or an admin is deleting it; the reaper carries that out on its next run. */
retiring?: components["schemas"]["RetireState"];
}; };
/** @description One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView). */ /** @description One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView). */
FleetServer: components["schemas"]["ServerInfo"] & { FleetServer: components["schemas"]["ServerInfo"] & {
@@ -2411,13 +2439,19 @@ export interface components {
owner?: string; owner?: string;
/** @description True when the caller claimed this server, decided by account id so an owner without an email is still recognized. */ /** @description True when the caller claimed this server, decided by account id so an owner without an email is still recognized. */
owned: boolean; owned: boolean;
/** @description True for a live, unclaimed, non-system server, the same rule the claim route enforces. False whenever ownership is unknown. */ /** @description True for a live, unclaimed, non-system server with no pending deletion, the same rule the claim route enforces. False whenever ownership is unknown. */
claimable: boolean; claimable: boolean;
/** @description Present and true when the owner lookup failed, so an absent owner says nothing about whether the server is claimed. */ /** @description Present and true when the owner lookup failed, so an absent owner says nothing about whether the server is claimed. */
ownerUnknown?: boolean; ownerUnknown?: boolean;
/** @description True for a platform-provisioned system service (the login gate, the lobby). Their reserved names are rejected by every per-server route, so the cockpit renders them read-only instead of offering actions that would 400. */ /** @description True for a platform-provisioned system service (the login gate, the lobby). Their reserved names are rejected by every per-server route, so the cockpit renders them read-only instead of offering actions that would 400. */
system?: boolean; system?: boolean;
}; };
/** @description A pending retirement (internal/api/repo.go RetireState): the owner gave the server up, or with delete an admin is deleting it. The reaper carries it out on its next daily run: it archives the world as a released backup, deletes the world volume and releases the server, and for a deletion also removes it. Until then the server stays stopped and cannot be woken or claimed. */
RetireState: {
/** Format: date-time */
requested_at: string;
delete: boolean;
};
/** @description One player on a server's wake allowlist (internal/api/repo.go AllowlistEntry): someone who joined the server, and so may wake it under autostartPolicy=allowlist unless the owner took that away. */ /** @description One player on a server's wake allowlist (internal/api/repo.go AllowlistEntry): someone who joined the server, and so may wake it under autostartPolicy=allowlist unless the owner took that away. */
AllowlistEntry: { AllowlistEntry: {
/** Format: uuid */ /** Format: uuid */
@@ -2468,6 +2502,8 @@ export interface components {
autoRestarts?: number; autoRestarts?: number;
/** @description Owned rows only. Present and true for a Failed server no automatic retry will bring up; waking it from the panel starts it over. */ /** @description Owned rows only. Present and true for a Failed server no automatic retry will bring up; waking it from the panel starts it over. */
startGaveUp?: boolean; startGaveUp?: boolean;
/** @description Owned rows only. Present while the server is given up or being deleted. */
retiring?: components["schemas"]["RetireState"];
}; };
/** @description One world backup (internal/api/repo.go BackupView). backup_ref is withheld (spec §286). */ /** @description One world backup (internal/api/repo.go BackupView). backup_ref is withheld (spec §286). */
BackupView: { BackupView: {
@@ -2477,7 +2513,7 @@ export interface components {
former_owner?: string; former_owner?: string;
/** Format: int64 */ /** Format: int64 */
size_bytes: number; size_bytes: number;
/** @description inactive_15d (idle reclaim), manual (on demand), pre_restore (the safety snapshot in front of a restore) or scheduled (the daily restore point felis-api takes of a world played since its last one, once the server stops). */ /** @description inactive_15d (idle reclaim), released (the world of a server its owner gave up or an admin deleted), manual (on demand), pre_restore (the safety snapshot in front of a restore) or scheduled (the daily restore point felis-api takes of a world played since its last one, once the server stops). */
reason: string; reason: string;
status: string; status: string;
/** Format: date-time */ /** Format: date-time */
@@ -3218,7 +3254,7 @@ export interface operations {
401: components["responses"]["Unauthorized"]; 401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"]; 403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"]; 404: components["responses"]["NotFound"];
/** @description Nothing was started. maintenance_in_progress: a restore, backup or file write holds the server's world volume. start_failed: the last start failed and its automatic retries are spent (ServerInfo.startGaveUp); the server stays down until a person starts it from the panel. */ /** @description Nothing was started. maintenance_in_progress: a restore, backup or file write holds the server's world volume. start_failed: the last start failed and its automatic retries are spent (ServerInfo.startGaveUp); the server stays down until a person starts it from the panel. server_retiring: the owner gave the server up or an admin is deleting it; it stays down until the reaper archives it. */
409: { 409: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@@ -3789,7 +3825,7 @@ export interface operations {
401: components["responses"]["Unauthorized"]; 401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"]; 403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"]; 404: components["responses"]["NotFound"];
/** @description A restore, backup or file write holds the server's world volume (maintenance_in_progress); nothing was started. */ /** @description Nothing was started. maintenance_in_progress: a restore, backup or file write holds the server's world volume. server_retiring: the server is given up or being deleted (ServerInfo.retiring). */
409: { 409: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@@ -3882,7 +3918,7 @@ export interface operations {
}; };
}; };
404: components["responses"]["NotFound"]; 404: components["responses"]["NotFound"];
/** @description Already claimed. */ /** @description already_claimed: someone else owns it. server_retiring: the server is being deleted. */
409: { 409: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@@ -4433,6 +4469,101 @@ export interface operations {
}; };
}; };
}; };
retireServer: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description The server's name */
confirm: string;
/** @description Delete the server (admin only). Default false gives it up. */
delete?: boolean;
};
};
};
responses: {
/** @description Recorded; the server is stopped. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
name: string;
retiring: components["schemas"]["RetireState"];
};
};
};
/** @description A malformed body or name, or confirm does not match the name (confirm_mismatch). */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
/** @description Neither the owner nor an admin, or an owner asking to delete. */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
404: components["responses"]["NotFound"];
/** @description system_server: a system server is never given up or deleted. world_volume_orphaned: the server is gone from the cluster but its world volume remains, which an operator archives and removes by hand. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
cancelRetire: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Nothing is pending any more. */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
/** @description Neither the owner nor an admin, or an owner cancelling an admin's deletion. */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
404: components["responses"]["NotFound"];
};
};
status: { status: {
parameters: { parameters: {
query?: never; query?: never;
+17
View File
@@ -17,6 +17,16 @@ export type Phase =
export type AutostartPolicy = "ownerOnly" | "public" | "allowlist"; export type AutostartPolicy = "ownerOnly" | "public" | "allowlist";
/** RetireState is a pending retirement (Go RetireState): the owner gave the
* server up, or with `delete` an admin is deleting it. The reaper carries it out
* on its next daily run (archive the world, delete its volume, release the
* server, and for a deletion remove it); until then the server stays stopped and
* cannot be woken or claimed. Only the owner and admins are shown it. */
export interface RetireState {
requested_at: string;
delete: boolean;
}
/** MyServerView is the GET /me/servers row (wrapped under { servers: [...] }). /** MyServerView is the GET /me/servers row (wrapped under { servers: [...] }).
* Only this projection says whether the caller owns or may claim a server. * Only this projection says whether the caller owns or may claim a server.
* The live fields come from the CRD best-effort; desiredState, autostartPolicy * The live fields come from the CRD best-effort; desiredState, autostartPolicy
@@ -40,6 +50,8 @@ export interface MyServerView {
autoRestarts?: number; autoRestarts?: number;
/** True for a Failed server no automatic retry will bring up. */ /** True for a Failed server no automatic retry will bring up. */
startGaveUp?: boolean; startGaveUp?: boolean;
/** A pending retirement; present on the caller's own rows only. */
retiring?: RetireState;
} }
/** ServerStatus is GET /servers/{name}/status (Go ServerInfo). It never carries /** ServerStatus is GET /servers/{name}/status (Go ServerInfo). It never carries
@@ -77,6 +89,11 @@ export interface ServerStatus {
/** True for a Failed server no automatic retry will bring up; a Failed server /** True for a Failed server no automatic retry will bring up; a Failed server
* without it is still in its restart backoff. */ * without it is still in its restart backoff. */
startGaveUp?: boolean; startGaveUp?: boolean;
/** A platform system server the reaper never touches, so it cannot be given
* up or deleted. Owner and admin view only. */
reaperExempt?: boolean;
/** A pending retirement. Owner and admin view only. */
retiring?: RetireState;
} }
/** WhitelistResult projects GET /servers/{name}/access/whitelist (spec §7 access). /** WhitelistResult projects GET /servers/{name}/access/whitelist (spec §7 access).
+6
View File
@@ -99,6 +99,12 @@ describe("ServerBackups", () => {
expect(ops.map((el) => el.textContent)).toEqual(["Scheduled backup", "Backup"]); expect(ops.map((el) => el.textContent)).toEqual(["Scheduled backup", "Backup"]);
}); });
it("names the archive the reaper leaves when a server is given up or deleted", async () => {
calls.listBackups.mockResolvedValue({ backups: [{ ...backup("bk-rel", 4), reason: "released" }], total: 1 });
renderPage();
expect((await screen.findByText("4 hours ago")).closest("tr")?.textContent).toContain("Archived when given up or deleted");
});
it("shows no pager when the server's backups fit on one page", async () => { it("shows no pager when the server's backups fit on one page", async () => {
calls.listBackups.mockResolvedValue({ backups: [backup("bk-1", 3)], total: 1 }); calls.listBackups.mockResolvedValue({ backups: [backup("bk-1", 3)], total: 1 });
renderPage(); renderPage();
+2
View File
@@ -71,6 +71,8 @@ function BackupRow({
const reasonLabel = const reasonLabel =
b.reason === "inactive_15d" b.reason === "inactive_15d"
? t("reason_inactive") ? t("reason_inactive")
: b.reason === "released"
? t("reason_released")
: b.reason === "manual" : b.reason === "manual"
? t("reason_manual") ? t("reason_manual")
: b.reason === "pre_restore" : b.reason === "pre_restore"
+68 -7
View File
@@ -7,14 +7,13 @@ import { ServerConsole } from "./ServerConsole";
import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS } from "@/lib/hooks"; import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS } from "@/lib/hooks";
const calls = vi.hoisted(() => ({ status: vi.fn(), myServers: vi.fn(), listImages: vi.fn() })); const calls = vi.hoisted(() => ({ status: vi.fn(), myServers: vi.fn(), listImages: vi.fn() }));
vi.mock("@/lib/tier", () => ({ const tier = vi.hoisted(() => ({
useTier: () => ({ loading: false,
loading: false, identity: { user_id: "admin-1", email: "[email protected]", role: "admin" },
identity: { user_id: "admin-1", email: "[email protected]", role: "admin" }, isAdmin: true,
isAdmin: true, isOwner: false,
isOwner: false,
}),
})); }));
vi.mock("@/lib/tier", () => ({ useTier: () => tier }));
vi.mock("@/lib/config", async (importActual) => { vi.mock("@/lib/config", async (importActual) => {
const actual = await importActual<typeof import("@/lib/config")>(); const actual = await importActual<typeof import("@/lib/config")>();
return { return {
@@ -30,6 +29,7 @@ vi.mock("@/lib/api", async (importActual) => {
vi.mock("@/components/LogConsole", () => ({ LogConsole: () => <div data-testid="log-stream" /> })); vi.mock("@/components/LogConsole", () => ({ LogConsole: () => <div data-testid="log-stream" /> }));
beforeEach(() => { beforeEach(() => {
tier.isAdmin = true;
calls.status.mockReset(); calls.status.mockReset();
calls.myServers.mockReset(); calls.myServers.mockReset();
calls.myServers.mockResolvedValue([]); calls.myServers.mockResolvedValue([]);
@@ -151,3 +151,64 @@ describe("ServerConsole following the server", () => {
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
}); });
}); });
describe("ServerConsole retirement", () => {
const stopped = (over: Record<string, unknown> = {}) => status({ phase: "Stopped", desiredState: "Stopped", ...over });
const mine = (owned: boolean) => [
{ name: "survival", subdomain: "survival", owned, claimable: false, playersOnline: 0, playersMax: 20 },
];
const giveUp = () => screen.queryByRole("button", { name: "Give up" });
const del = () => screen.queryByRole("button", { name: "Delete" });
it("offers the owner a give-up and no deletion", async () => {
tier.isAdmin = false;
calls.myServers.mockResolvedValue(mine(true));
calls.status.mockResolvedValue(stopped());
renderConsole();
expect(await screen.findByRole("button", { name: "Give up" })).toBeTruthy();
expect(del()).toBeNull();
expect(screen.getByRole("button", { name: "Wake" })).toBeTruthy();
});
it("offers an admin both", async () => {
calls.status.mockResolvedValue(stopped());
renderConsole();
expect(await screen.findByRole("button", { name: "Delete" })).toBeTruthy();
expect(giveUp()).toBeTruthy();
});
it("offers someone who does not own it neither", async () => {
tier.isAdmin = false;
calls.myServers.mockResolvedValue(mine(false));
calls.status.mockResolvedValue(stopped());
renderConsole();
expect(await screen.findByText("Server is asleep")).toBeTruthy();
await waitFor(() => expect(calls.myServers).toHaveBeenCalled());
expect(giveUp()).toBeNull();
});
it("offers nothing for a system server", async () => {
calls.status.mockResolvedValue(stopped({ reaperExempt: true }));
renderConsole();
expect(await screen.findByText("Server is asleep")).toBeTruthy();
expect(giveUp()).toBeNull();
expect(del()).toBeNull();
});
it("shows a pending give-up and the way back in place of the card and the wake", async () => {
tier.isAdmin = false;
calls.myServers.mockResolvedValue(mine(true));
calls.status.mockResolvedValue(stopped({ retiring: { requested_at: new Date().toISOString(), delete: false } }));
renderConsole();
expect(await screen.findByText("This server has been given up")).toBeTruthy();
expect(screen.getByRole("button", { name: "Cancel request" })).toBeTruthy();
expect(screen.getByText("Given up")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Wake" })).toBeNull();
expect(giveUp()).toBeNull();
});
});
+13
View File
@@ -17,6 +17,7 @@ import { CopyAddress } from "@/components/CopyAddress";
import type { Phase, AutostartPolicy } from "@/lib/types"; import type { Phase, AutostartPolicy } from "@/lib/types";
import { EditServerDialog } from "@/components/EditServerDialog"; import { EditServerDialog } from "@/components/EditServerDialog";
import { PowerButton } from "@/components/PowerButton"; import { PowerButton } from "@/components/PowerButton";
import { RetireCard, RetireNotice } from "@/components/Retirement";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { InlineError } from "@/components/MessageLine"; import { InlineError } from "@/components/MessageLine";
@@ -281,6 +282,7 @@ export function ServerConsole() {
failed={failure !== null} failed={failure !== null}
playersOnline={data.playersOnline} playersOnline={data.playersOnline}
playerCountUnknown={data.playerCountUnknown} playerCountUnknown={data.playerCountUnknown}
retiring={data.retiring}
onChanged={reload} onChanged={reload}
/> />
</div> </div>
@@ -288,6 +290,10 @@ export function ServerConsole() {
className="mb-6" className="mb-6"
/> />
{data.retiring && (
<RetireNotice name={name} retiring={data.retiring} isAdmin={isAdmin} onChanged={reload} />
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4 flex-1 lg:min-h-0 min-h-0"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-4 flex-1 lg:min-h-0 min-h-0">
{/* Left/Main column: Console */} {/* Left/Main column: Console */}
<div className="lg:col-span-3 flex flex-col lg:min-h-0 min-h-0 h-full"> <div className="lg:col-span-3 flex flex-col lg:min-h-0 min-h-0 h-full">
@@ -397,6 +403,13 @@ export function ServerConsole() {
</div> </div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" /> <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</Link> </Link>
{/* Giving the server up (owner) or deleting it (admin) closes the
sidebar. A system server is never retired, and one already on its
way out shows the notice above with the way back instead. */}
{owned && !data.reaperExempt && !data.retiring && (
<RetireCard name={name} label={data.displayName || data.name} isAdmin={isAdmin} onChanged={reload} />
)}
</div> </div>
</div> </div>
</> </>
+1
View File
@@ -392,6 +392,7 @@ export function ServerLuckPerms() {
desiredState={data.desiredState} desiredState={data.desiredState}
failure={failure} failure={failure}
autoRestarts={data.autoRestarts} autoRestarts={data.autoRestarts}
retiring={data.retiring}
onWoken={reload} onWoken={reload}
/> />
) : ( ) : (
+1
View File
@@ -99,6 +99,7 @@ export function ServerPlayers() {
desiredState={data.desiredState} desiredState={data.desiredState}
failure={failure} failure={failure}
autoRestarts={data.autoRestarts} autoRestarts={data.autoRestarts}
retiring={data.retiring}
onWoken={reload} onWoken={reload}
/> />
{/* The wake list is Felis's own record, so it stays manageable while the {/* The wake list is Felis's own record, so it stays manageable while the
@@ -192,3 +192,54 @@ describe("ServersPage servers asked to move", () => {
expect(runningCard.previousElementSibling?.textContent).toBe("0"); expect(runningCard.previousElementSibling?.textContent).toBe("0");
}); });
}); });
describe("ServersPage retiring servers", () => {
it("shows a server given up or being deleted with its badge and no start", async () => {
const requested_at = new Date().toISOString();
calls.fleet.mockResolvedValue([
row("survival", { owner: "steve-mc", retiring: { requested_at, delete: false } }),
row("creative", { owner: "[email protected]", retiring: { requested_at, delete: true } }),
row("skyblock", { owner: "[email protected]" }),
]);
render(
<MemoryRouter>
<ServersPage />
</MemoryRouter>,
);
const survival = await tableRow("survival");
expect(survival.getByText("Given up")).toBeTruthy();
expect(survival.queryByRole("button", { name: /Wake/ })).toBeNull();
const creative = await tableRow("creative");
expect(creative.getByText("Deleting")).toBeTruthy();
expect(creative.queryByRole("button", { name: /Wake/ })).toBeNull();
const skyblock = await tableRow("skyblock");
expect(skyblock.getByRole("button", { name: /Wake/ })).toBeTruthy();
});
it("marks the owner's own server given up in their list", async () => {
tier.isAdmin = false;
calls.myServers.mockResolvedValue([
{
name: "survival",
subdomain: "survival",
owned: true,
claimable: false,
phase: "Stopped",
desiredState: "Stopped",
playersOnline: 0,
playersMax: 20,
retiring: { requested_at: new Date().toISOString(), delete: false },
} satisfies MyServerView,
]);
render(
<MemoryRouter>
<ServersPage />
</MemoryRouter>,
);
const survival = await tableRow("survival");
expect(survival.getByText("Given up")).toBeTruthy();
expect(survival.queryByRole("button", { name: /Wake/ })).toBeNull();
});
});
+6 -1
View File
@@ -43,7 +43,7 @@ import { useAsync, useConfig, usePolling } from "@/lib/hooks";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import { joinAddress, type RuntimeConfig } from "@/lib/config"; import { joinAddress, type RuntimeConfig } from "@/lib/config";
import { matchScore } from "@/lib/fuzzy"; import { matchScore } from "@/lib/fuzzy";
import type { AutostartPolicy, FleetServer, Phase, MyServerView } from "@/lib/types"; import type { AutostartPolicy, FleetServer, Phase, MyServerView, RetireState } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const REFRESH_MS = 10_000; const REFRESH_MS = 10_000;
@@ -90,6 +90,8 @@ interface UnifiedServer {
/** The fleet's owner lookup failed, so an absent owner proves nothing. */ /** The fleet's owner lookup failed, so an absent owner proves nothing. */
ownerUnknown?: boolean; ownerUnknown?: boolean;
system?: boolean; system?: boolean;
/** A pending retirement (owner and admin views): the row offers no start. */
retiring?: RetireState;
} }
export function ServersPage() { export function ServersPage() {
@@ -131,6 +133,7 @@ export function ServersPage() {
owned: s.owned, owned: s.owned,
ownerUnknown: s.ownerUnknown, ownerUnknown: s.ownerUnknown,
system: s.system, system: s.system,
retiring: s.retiring,
})); }));
} else { } else {
return (data as MyServerView[]).map((s) => ({ return (data as MyServerView[]).map((s) => ({
@@ -149,6 +152,7 @@ export function ServersPage() {
owner: s.owned ? t("servers:owned_filter_mine") || "me" : undefined, owner: s.owned ? t("servers:owned_filter_mine") || "me" : undefined,
claimable: s.claimable, claimable: s.claimable,
owned: s.owned, owned: s.owned,
retiring: s.retiring,
})); }));
} }
}, [data, isAdmin, t]); }, [data, isAdmin, t]);
@@ -502,6 +506,7 @@ function ServerActions({
failed={startFailure(server) !== null} failed={startFailure(server) !== null}
playersOnline={server.playersOnline} playersOnline={server.playersOnline}
playerCountUnknown={server.playerCountUnknown} playerCountUnknown={server.playerCountUnknown}
retiring={server.retiring}
onChanged={onChanged} onChanged={onChanged}
/> />
{(server.owned || isAdmin) && ( {(server.owned || isAdmin) && (
@@ -651,6 +651,16 @@ public final class WaitingRouter {
NamedTextColor.YELLOW)); NamedTextColor.YELLOW));
return; return;
} }
if ("server_retiring".equals(e.errorCode())) {
// The owner gave the server up or an admin is deleting it: it
// stays down until the reaper archives it, unless that is
// cancelled in the panel, so there is nothing to wait for.
player.sendMessage(Component.text(
zh ? "「" + serverName + "」已被放弃或正在删除,不能启动。"
: "« " + serverName + " » has been given up or is being deleted, so it can't be started.",
NamedTextColor.YELLOW));
return;
}
if ("start_failed".equals(e.errorCode())) { if ("start_failed".equals(e.errorCode())) {
// The last start failed with its retries spent. The join does // The last start failed with its retries spent. The join does
// not buy another round of them, so there is nothing to wait for. // not buy another round of them, so there is nothing to wait for.
@@ -226,6 +226,7 @@ public final class WaitingRouterTest {
{"ownerOnly, not the owner", "You're not allowed to start « gamma »", null}, {"ownerOnly, not the owner", "You're not allowed to start « gamma »", null},
{"retries spent", "« gamma » failed to start and its automatic retries are spent", null}, {"retries spent", "« gamma » failed to start and its automatic retries are spent", null},
{"409 maintenance_in_progress", "« gamma » is under maintenance", null}, {"409 maintenance_in_progress", "« gamma » is under maintenance", null},
{"409 server_retiring", "« gamma » has been given up or is being deleted", null},
{"409 conflict", "Couldn't start « gamma » right now", "wake gamma failed (status=409)"}, {"409 conflict", "Couldn't start « gamma » right now", "wake gamma failed (status=409)"},
{"503 at_capacity", "The cluster is at capacity right now", null}, {"503 at_capacity", "The cluster is at capacity right now", null},
{"503 unavailable", "Couldn't start « gamma » right now", "wake gamma failed (status=503)"}, {"503 unavailable", "Couldn't start « gamma » right now", "wake gamma failed (status=503)"},