Files
Felis/internal/api/handlers_retire.go

159 lines
5.8 KiB
Go

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)
}