159 lines
5.8 KiB
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)
|
|
}
|