Use shared action buttons and default to the login space. Allow staff to save startup-only experience settings while running and request a durable restart through the existing operator flow. Grant the API PVC list permission needed to detect retained worlds before server creation, and show internal failures with a request ID. Cover permission, maintenance, restart and UI behavior with regression checks.
1205 lines
46 KiB
Go
1205 lines
46 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
|
"felis.lolicon.best/internal/naming"
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/resource"
|
|
)
|
|
|
|
// handleWake is the single lever (spec §9.1): it authorizes per autostartPolicy,
|
|
// applies the cooldown, and flips the CRD desiredState to Running. It does not
|
|
// transfer the player — the web flow shows status and a connect hint (spec §9.2).
|
|
func (a *API) handleWake(w http.ResponseWriter, r *http.Request) {
|
|
p := principalFromContext(r.Context())
|
|
name := r.PathValue("name")
|
|
if err := validateManagedServerName(r, name); err != nil {
|
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
|
return
|
|
}
|
|
|
|
info, err := a.Cluster.GetServer(r.Context(), name)
|
|
if err != nil {
|
|
a.writeLookupError(w, r, err)
|
|
return
|
|
}
|
|
rec, err := a.Repo.ServerByName(r.Context(), name)
|
|
if err != nil && !errors.Is(err, ErrNotFound) {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
|
|
if err := a.authorizeWake(r.Context(), p, info, rec); err != nil {
|
|
writeError(w, r, err)
|
|
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) {
|
|
writeError(w, r, newError(http.StatusTooManyRequests, "cooldown", "wake is cooling down, retry shortly"))
|
|
return
|
|
}
|
|
// Global running-server cap (spec §9.1). Distinct from the per-server cooldown:
|
|
// 503 at_capacity means the cluster is full, not that this server is throttled.
|
|
ok, err := a.withinRunningCap(r.Context(), info)
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
if !ok {
|
|
writeError(w, r, newError(http.StatusServiceUnavailable, "at_capacity",
|
|
"the cluster is at its running-server cap; retry once a server stops"))
|
|
return
|
|
}
|
|
|
|
// A start that Failed already holds desiredState Running, so writing Running
|
|
// again changes nothing and the server stayed dead once its automatic restarts
|
|
// were spent. A person pressing start on it means "try again": RetryStart
|
|
// makes the operator start it over with a fresh restart budget. Only this face
|
|
// does that; a player's join never resets the budget (handleInternalWake).
|
|
//
|
|
// Either write is refused with 409 maintenance_in_progress while a restore,
|
|
// backup or file write holds the world volume: starting on a half-written
|
|
// world corrupts it.
|
|
action := "wake"
|
|
if info.Phase == string(v1alpha1.PhaseFailed) && info.DesiredState == string(v1alpha1.DesiredRunning) {
|
|
action = "retry_start"
|
|
err = a.Cluster.RetryStart(r.Context(), name)
|
|
} else {
|
|
err = a.Cluster.SetDesiredState(r.Context(), name, v1alpha1.DesiredRunning)
|
|
}
|
|
if err != nil {
|
|
a.writeLookupError(w, r, err)
|
|
return
|
|
}
|
|
// The wake actually flipped, so consume the per-server cooldown only now: a 503
|
|
// at_capacity or the SetDesiredState failure above must not burn it (a player
|
|
// held at capacity should retry the instant a slot frees, not wait out a
|
|
// cooldown their refused wake never earned).
|
|
a.limiter().record(name)
|
|
a.audit(r, action, name)
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"name": name, "desiredState": "Running"})
|
|
}
|
|
|
|
// handleRestart records a restart request for the operator to consume once.
|
|
func (a *API) handleRestart(w http.ResponseWriter, r *http.Request) {
|
|
name, ok := a.authorizeServerFiles(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
rec, err := a.managedServerRecord(r.Context(), name)
|
|
if err != nil {
|
|
a.writeLookupError(w, r, err)
|
|
return
|
|
}
|
|
if rec.Retire != nil {
|
|
writeError(w, r, errServerRetiring)
|
|
return
|
|
}
|
|
if err := a.Cluster.RestartServer(r.Context(), rec.Name); err != nil {
|
|
a.writeLookupError(w, r, maintenanceError(err, "wait for maintenance to finish before restarting"))
|
|
return
|
|
}
|
|
a.audit(r, "server.restart", rec.Name)
|
|
writeJSON(w, http.StatusAccepted, map[string]string{"name": rec.Name, "desiredState": "Running"})
|
|
}
|
|
|
|
// handleStop flips desiredState to Stopped. Only the owner or an admin may stop a
|
|
// server (spec §14: operating someone else's server is admin-tier).
|
|
func (a *API) handleStop(w http.ResponseWriter, r *http.Request) {
|
|
p := principalFromContext(r.Context())
|
|
name := r.PathValue("name")
|
|
if err := validateManagedServerName(r, name); err != nil {
|
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
|
return
|
|
}
|
|
|
|
rec, err := a.managedServerRecord(r.Context(), name)
|
|
if err != nil {
|
|
a.writeLookupError(w, r, err)
|
|
return
|
|
}
|
|
if !a.isOwnerOrAdmin(p, rec) {
|
|
writeError(w, r, errForbidden)
|
|
return
|
|
}
|
|
|
|
if err := a.Cluster.SetDesiredState(r.Context(), name, v1alpha1.DesiredStopped); err != nil {
|
|
a.writeLookupError(w, r, err) // a row whose MinecraftServer is gone is 404, as for a wake
|
|
return
|
|
}
|
|
a.audit(r, "stop", name)
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"name": name, "desiredState": "Stopped"})
|
|
}
|
|
|
|
// handleClaim is the atomic claim transaction (spec §9.3): require a verified
|
|
// account link, enforce the quota gate, then UPDATE ... WHERE owner_id IS NULL.
|
|
// A lost race (0 rows) is 409.
|
|
func (a *API) handleClaim(w http.ResponseWriter, r *http.Request) {
|
|
p := principalFromContext(r.Context())
|
|
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
|
|
}
|
|
|
|
// ① verified account link
|
|
linked, err := a.Repo.IsLinked(r.Context(), p.UserID)
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
if !linked {
|
|
writeError(w, r, newError(http.StatusPreconditionFailed, "not_linked",
|
|
"link your Minecraft account before claiming (see /api/v1/account/link/start)"))
|
|
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
|
|
// (servers, CPU, memory, storage) are checked against the user's quota caps
|
|
// (spec §9.3, §22), with the server at its real size (claimResources). The
|
|
// server being claimed has owner_id=NULL so it is not yet in the per-owner
|
|
// aggregate.
|
|
res, err := a.claimResources(r.Context(), name)
|
|
if err != nil {
|
|
a.writeLookupError(w, r, err)
|
|
return
|
|
}
|
|
ok, err := a.Repo.QuotaCheck(r.Context(), p.UserID, "", res)
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
if !ok {
|
|
writeError(w, r, newError(http.StatusForbidden, "quota_exceeded", "server quota exhausted"))
|
|
return
|
|
}
|
|
|
|
// ③ atomic claim
|
|
claimed, err := a.Repo.ClaimServer(r.Context(), name, p.UserID)
|
|
if err != nil {
|
|
// The atomic gate re-checks quota under the per-user lock (audit #4): a
|
|
// concurrent claim that spent the last slot surfaces here, with the same
|
|
// 403 the pre-check gives sequentially.
|
|
if errors.Is(err, ErrQuotaExceeded) {
|
|
writeError(w, r, newError(http.StatusForbidden, "quota_exceeded", "server quota exhausted"))
|
|
return
|
|
}
|
|
a.writeLookupError(w, r, err)
|
|
return
|
|
}
|
|
if !claimed {
|
|
writeError(w, r, newError(http.StatusConflict, "already_claimed", "server is already claimed"))
|
|
return
|
|
}
|
|
|
|
// ④ audit. Allowlist population happens on first successful join (spec §9.4).
|
|
a.audit(r, "claim", name)
|
|
writeJSON(w, http.StatusOK, map[string]any{"name": name, "claimed": true})
|
|
}
|
|
|
|
// claimResources is the size a claim is gated on and then counted at: the server's
|
|
// spec as the cluster holds it, written through to the resource cache first. The
|
|
// cache is all the per-owner quota sums read, and a world the reaper released
|
|
// before it kept the cache holds zeros there: gated on those, a claim passed every
|
|
// resource cap and the server went uncounted for as long as its new owner kept it.
|
|
func (a *API) claimResources(ctx context.Context, name string) (ResourceSpec, error) {
|
|
info, err := a.Cluster.GetServer(ctx, name)
|
|
if err != nil {
|
|
return ResourceSpec{}, err
|
|
}
|
|
storage, _ := resource.ParseQuantity(info.StorageSize)
|
|
res := ResourceSpec{
|
|
CPUMilli: quantityToMilli(info.Resources.Limits[corev1.ResourceCPU]),
|
|
MemoryMB: quantityToMB(info.Resources.Limits[corev1.ResourceMemory]),
|
|
StorageMB: quantityToMB(storage),
|
|
}
|
|
if err := a.Repo.UpdateServerResources(ctx, name, res.CPUMilli, res.MemoryMB, res.StorageMB); err != nil {
|
|
return ResourceSpec{}, err
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// handleStatus returns the CRD status view (spec §7 GET /servers/{name}/status).
|
|
func (a *API) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
name := r.PathValue("name")
|
|
if err := validateManagedServerName(r, name); err != nil {
|
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
|
return
|
|
}
|
|
info, err := a.Cluster.GetServer(r.Context(), name)
|
|
if err != nil {
|
|
a.writeLookupError(w, r, err)
|
|
return
|
|
}
|
|
// The internal face (no principal) is the platform itself. On the external
|
|
// face the full record (image, resources, endpoint) is the owner's and the
|
|
// staff's; anyone else signed in sees what the game's own server list shows.
|
|
if p := principalFromContext(r.Context()); p != nil {
|
|
rec, err := a.Repo.ServerByName(r.Context(), name)
|
|
if err != nil && !errors.Is(err, ErrNotFound) {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
if !a.isOwnerOrAdmin(p, rec) {
|
|
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)
|
|
}
|
|
|
|
// publicServerInfo keeps the fields any signed-in caller may see of a server
|
|
// that is not theirs.
|
|
func publicServerInfo(s *ServerInfo) *ServerInfo {
|
|
return &ServerInfo{
|
|
Name: s.Name,
|
|
Subdomain: s.Subdomain,
|
|
DisplayName: s.DisplayName,
|
|
Phase: s.Phase,
|
|
Ready: s.Ready,
|
|
PlayersOnline: s.PlayersOnline,
|
|
PlayersMax: s.PlayersMax,
|
|
}
|
|
}
|
|
|
|
// handleMe returns the calling principal's own identity (spec §14 tiering). The
|
|
// panel reads it once at boot to decide which navigation surfaces to render:
|
|
// the User-Side for everyone, the Admin/SysAdmin sides only when is_admin. This
|
|
// is UX truth, NOT a security control — every admin route is independently gated
|
|
// by adminOnly + Principal.IsAdmin() server-side, so hiding a nav item never
|
|
// widens access. is_admin is computed here as IsAdmin() (Role=="admin" AND the
|
|
// admin Access path), so the client never re-derives the graded-ZT rule. App-tier:
|
|
// a principal reads only its OWN identity — the response is sourced entirely from
|
|
// the verified token, no lookup escapes it.
|
|
func (a *API) handleMe(w http.ResponseWriter, r *http.Request) {
|
|
p := principalFromContext(r.Context())
|
|
emailVerified := false
|
|
if u, err := a.Repo.UserByID(r.Context(), p.UserID); err == nil {
|
|
emailVerified = u.EmailVerified
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"user_id": p.UserID,
|
|
"email": p.Email,
|
|
"role": p.Role,
|
|
"is_admin": p.IsAdmin(),
|
|
"is_owner": p.IsOwner(),
|
|
"email_verified": emailVerified,
|
|
})
|
|
}
|
|
|
|
// handleMyServers lists what the caller owns or may claim (spec §7 GET /me/servers).
|
|
func (a *API) handleMyServers(w http.ResponseWriter, r *http.Request) {
|
|
p := principalFromContext(r.Context())
|
|
servers, err := a.Repo.MyServers(r.Context(), p.UserID)
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
// The live fields are presentational and best-effort, mirroring handleFleet's
|
|
// owner join: the list exists for ownership/claim state, so a cluster hiccup
|
|
// must degrade to 0/0 counts and the cached phase, never 500 the whole list.
|
|
// The CRD status is the only source of live state (spec §1) — Postgres never
|
|
// stores counts. Owner detail (desired state, autostart policy, whether the
|
|
// count is readable) joins only onto rows the caller owns; the panel needs
|
|
// playerCountUnknown there to ask before a stop that may drop players.
|
|
if infos, err := a.Cluster.ListServers(r.Context()); err == nil {
|
|
byName := make(map[string]ServerInfo, len(infos))
|
|
for _, s := range infos {
|
|
byName[s.Name] = s
|
|
}
|
|
for i := range servers {
|
|
info, ok := byName[servers[i].Name]
|
|
if !ok {
|
|
continue
|
|
}
|
|
v := &servers[i]
|
|
v.PlayersOnline = info.PlayersOnline
|
|
v.PlayersMax = info.PlayersMax
|
|
v.DisplayName = info.DisplayName
|
|
if info.Phase != "" {
|
|
v.Phase = info.Phase
|
|
}
|
|
if v.Owned {
|
|
v.DesiredState = info.DesiredState
|
|
v.AutostartPolicy = info.AutostartPolicy
|
|
v.PlayerCountUnknown = info.PlayerCountUnknown
|
|
v.AutoRestarts = info.AutoRestarts
|
|
v.StartGaveUp = info.StartGaveUp
|
|
}
|
|
}
|
|
}
|
|
if servers == nil {
|
|
servers = []MyServerView{} // no servers is [], never null
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"servers": servers})
|
|
}
|
|
|
|
// handleFleet is the SysAdmin cockpit's fleet-wide read: the lifecycle view of
|
|
// EVERY MinecraftServer, from CRD + status. It is the admin-tier counterpart of
|
|
// the app-tier handleMyServers — where /me/servers scopes to the caller, this
|
|
// returns the whole fleet, so it gates on the admin Zero-Trust path via adminOnly.
|
|
//
|
|
// This is a frontend-cockpit-driven extension (the SysAdmin FleetTable in
|
|
// panel/DESIGN-WEB-3SIDES.md), NOT a spec §7 route: §7 lists only the internal
|
|
// velocity pull (GET /servers, service-tier) and the app-tier GET /me/servers,
|
|
// neither of which is an external admin read. It reuses Cluster.ListServers (the
|
|
// same CRD-truth source as the velocity pull, §1) but is a DISTINCT handler so
|
|
// each route's provenance and tier stay honest, and so the two never share a
|
|
// {method, path} key — the OpenAPI parity test forbids one path carrying both the
|
|
// service and admin tiers across faces. Lifecycle is read from the CRD (§1); the
|
|
// one business field the cockpit needs — the owner — is joined READ-ONLY from
|
|
// Postgres at request time (§6 business authority) purely for display. This keeps
|
|
// §1 honest: owner is never written back to the CRD and the CRD is never treated
|
|
// as its source; the two stores keep their split, the read just renders both.
|
|
func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) {
|
|
servers, err := a.Cluster.ListServers(r.Context())
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
// Ownership is best-effort. The cockpit exists for the lifecycle view, so a
|
|
// Postgres hiccup must never 500 the whole fleet; the rows say the owner is
|
|
// unknown instead, and none offers a claim that may already be taken.
|
|
p := principalFromContext(r.Context())
|
|
owners, err := a.Repo.ServerOwners(r.Context())
|
|
unknown := err != nil
|
|
views := make([]fleetServerView, len(servers))
|
|
for i, s := range servers {
|
|
o, known := owners[s.Name]
|
|
system := naming.IsSystemServer(s.Name)
|
|
s.Retiring = o.Retire
|
|
views[i] = fleetServerView{ServerInfo: s, Owner: o.Owner, System: system,
|
|
Owned: o.OwnerID != "" && o.OwnerID == p.UserID,
|
|
Claimable: known && o.OwnerID == "" && o.Retire == nil && !system,
|
|
OwnerUnknown: unknown}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"servers": views})
|
|
}
|
|
|
|
// fleetServerView is one row of the SysAdmin cockpit's fleet read: the CRD
|
|
// lifecycle view (ServerInfo, §1 authority) with the owner's display identity
|
|
// joined alongside. The embed keeps every lifecycle field flat in the JSON so the
|
|
// shape is a strict superset of ServerInfo.
|
|
type fleetServerView struct {
|
|
ServerInfo
|
|
// Owner is the claiming user's display identity (email, or username when the
|
|
// address is absent), or "" when the server is unclaimed or the owner lookup
|
|
// failed (OwnerUnknown tells the two apart).
|
|
Owner string `json:"owner,omitempty"`
|
|
// Owned is true when the caller claimed this server, decided by account id so
|
|
// an owner without an email is still recognized.
|
|
Owned bool `json:"owned"`
|
|
// Claimable is true for a live, unclaimed, non-system server with no pending
|
|
// deletion: the same rule ClaimServer enforces. It is false whenever ownership is unknown.
|
|
Claimable bool `json:"claimable"`
|
|
// OwnerUnknown is true when the best-effort owner lookup failed, so an empty
|
|
// Owner says nothing about whether the server is claimed.
|
|
OwnerUnknown bool `json:"ownerUnknown,omitempty"`
|
|
// System marks a platform-provisioned system service (the login gate and the
|
|
// lobby, naming.IsSystemServer). Their names are reserved, so every per-server
|
|
// API route rejects them — the cockpit must render them read-only rather than
|
|
// offer claim/wake/stop/console actions that would answer 400.
|
|
System bool `json:"system,omitempty"`
|
|
}
|
|
|
|
// createServerRequest is the structured §15 create-server form. This is the
|
|
// ONLY way to create a server from the Web: every field is a typed, validated
|
|
// value and decodeJSON rejects unknown fields, so a caller can never smuggle
|
|
// free-form YAML or raw CRD fields through this endpoint.
|
|
type createServerRequest struct {
|
|
NodeName string `json:"nodeName,omitempty"`
|
|
Name string `json:"name"`
|
|
Subdomain string `json:"subdomain"`
|
|
DisplayName string `json:"displayName,omitempty"`
|
|
Image string `json:"image"`
|
|
Memory string `json:"memory"`
|
|
Storage string `json:"storage"`
|
|
AutostartPolicy string `json:"autostartPolicy,omitempty"`
|
|
Resources *resourceRequest `json:"resources,omitempty"`
|
|
}
|
|
|
|
// resourceRequest is the optional override block. The required top-level memory
|
|
// already sets the pod memory limit+request (the §22 ceiling); these fields let
|
|
// an admin widen/narrow the cgroup envelope. Each is a Kubernetes quantity
|
|
// string ("500m", "2", "1Gi").
|
|
type resourceRequest struct {
|
|
CPU string `json:"cpu,omitempty"`
|
|
CPURequest string `json:"cpuRequest,omitempty"`
|
|
Memory string `json:"memory,omitempty"`
|
|
MemoryRequest string `json:"memoryRequest,omitempty"`
|
|
}
|
|
|
|
// handleCreateServer (spec §15) is the admin-tier structured create flow:
|
|
// validate the form, admit the image against the whitelist, seed the business
|
|
// rows, then create the MinecraftServer CRD cold (DesiredState=Stopped) and
|
|
// unowned. There is no free-YAML path — the request is a typed form.
|
|
func (a *API) handleCreateServer(w http.ResponseWriter, r *http.Request) {
|
|
// The image whitelist lives in the build subsystem; with no Builder there is
|
|
// no admission source, so create cannot run safely.
|
|
if a.Builder == nil {
|
|
writeError(w, r, errBuildUnavailable)
|
|
return
|
|
}
|
|
|
|
var body createServerRequest
|
|
if err := decodeJSON(w, r, &body); err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
|
|
if a.Distribution != nil {
|
|
if err := a.Distribution.ValidateNode(r.Context(), body.NodeName); err != nil {
|
|
writeError(w, r, newError(400, "bad_node", "select an approved worker: %v", err))
|
|
return
|
|
}
|
|
} else if body.NodeName != "" {
|
|
writeError(w, r, newError(400, "bad_node", "node selection requires distributed deployment"))
|
|
return
|
|
}
|
|
// Server name and subdomain both obey the §22 portability rule and the
|
|
// reservation list.
|
|
if err := naming.ValidateServerName(body.Name); err != nil {
|
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
|
return
|
|
}
|
|
if err := naming.ValidateServerName(body.Subdomain); err != nil {
|
|
writeError(w, r, newError(http.StatusBadRequest, "bad_subdomain", "invalid subdomain: %v", err))
|
|
return
|
|
}
|
|
displayName, err := naming.CleanDisplayName(body.DisplayName)
|
|
if err != nil {
|
|
writeError(w, r, newError(http.StatusBadRequest, "bad_display_name", "invalid display name: %v", err))
|
|
return
|
|
}
|
|
|
|
policy, err := parseAutostartPolicy(body.AutostartPolicy)
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
|
|
// Memory is required: it is both the JVM heap hint and the default pod memory
|
|
// limit+request. resolveResources fails closed if the §22 ceiling would be
|
|
// zero, so a CRD is never written without a concrete memory limit.
|
|
javaMemory, resources, err := resolveResources(body.Memory, body.Resources)
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
|
|
storage, err := parseStorageSize(body.Storage)
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
|
|
// Image admission is data-driven (the whitelist), never a free image string.
|
|
if strings.TrimSpace(body.Image) == "" {
|
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "image is required"))
|
|
return
|
|
}
|
|
admitted, err := a.Builder.ImageAdmitted(r.Context(), body.Image)
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
if !admitted {
|
|
writeError(w, r, newError(http.StatusBadRequest, "image_not_whitelisted",
|
|
"image %q is not on the whitelist", body.Image))
|
|
return
|
|
}
|
|
// The spec keeps the digest the tag names now, not the tag: the world is
|
|
// created on this build and stays on it until an admin changes the image.
|
|
image, err := a.pinImage(r.Context(), body.Image)
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
|
|
// Quota is intentionally NOT enforced here. §15 creates an UNOWNED server
|
|
// (owner_id NULL); the per-user quota is charged at claim time (spec §9.3 /
|
|
// §22). The create path is quota-free by design, not by oversight.
|
|
|
|
// Reject a duplicate subdomain before any write. The CRD list is the
|
|
// lifecycle source; SeedServer re-checks the PG alias atomically below.
|
|
switch _, err := a.Cluster.GetBySubdomain(r.Context(), body.Subdomain); {
|
|
case err == nil:
|
|
writeError(w, r, newError(http.StatusConflict, "subdomain_taken",
|
|
"subdomain %q is already in use", body.Subdomain))
|
|
return
|
|
case !errors.Is(err, ErrNotFound):
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
|
|
// Reject a duplicate server NAME before any write too. Without this, a
|
|
// dup-name create (fresh subdomain) would reach SeedServer, which would bind
|
|
// the new alias onto the PRE-EXISTING server and commit it — then CreateServer
|
|
// fails 409 but the stray alias persists. Checking the CRD here keeps the
|
|
// failed create side-effect-free. The narrow concurrent same-name race stays
|
|
// inside the documented non-transactional tradeoff, backstopped by
|
|
// CreateServer's AlreadyExists→409 below.
|
|
switch _, err := a.Cluster.GetServer(r.Context(), body.Name); {
|
|
case err == nil:
|
|
writeError(w, r, newError(http.StatusConflict, "already_exists",
|
|
"a server named %q already exists", body.Name))
|
|
return
|
|
case !errors.Is(err, ErrNotFound):
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
|
|
// A server deleted outside the reaper (kubectl delete) leaves its world
|
|
// volume behind: the StatefulSet retains claims on delete. A new server of
|
|
// the same name would mount that claim and hand the old world to its new
|
|
// owner, so the name stays taken until an operator removes the volume.
|
|
switch exists, err := a.Cluster.WorldVolumeExists(r.Context(), body.Name); {
|
|
case err != nil:
|
|
writeError(w, r, err)
|
|
return
|
|
case exists:
|
|
writeError(w, r, newError(http.StatusConflict, "world_volume_exists",
|
|
"the world volume of an earlier server named %q still exists; delete it or choose another name", body.Name))
|
|
return
|
|
}
|
|
|
|
// Seed the business rows FIRST (servers + alias). ClaimServer needs the row,
|
|
// so a CRD-only server would be unclaimable. PG-first means a later CRD
|
|
// failure leaves a claimable ghost row — acceptable, not transactional.
|
|
// The resource cache (cpuMilli, memoryMB, storageMB) is seeded alongside so
|
|
// QuotaCheck can aggregate per-owner usage without cross-system CRD reads.
|
|
cpuMilli := quantityToMilli(resources.Limits[corev1.ResourceCPU])
|
|
memMB := quantityToMB(resources.Limits[corev1.ResourceMemory])
|
|
storQ, _ := resource.ParseQuantity(storage)
|
|
storMB := quantityToMB(storQ)
|
|
if err := a.Repo.SeedServer(r.Context(), body.Name, body.Subdomain, cpuMilli, memMB, storMB); err != nil {
|
|
if errors.Is(err, ErrConflict) {
|
|
writeError(w, r, newError(http.StatusConflict, "subdomain_taken",
|
|
"subdomain %q is already in use", body.Subdomain))
|
|
return
|
|
}
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
|
|
in := CreateServerInput{
|
|
NodeName: body.NodeName,
|
|
Name: body.Name,
|
|
Subdomain: body.Subdomain,
|
|
DisplayName: displayName,
|
|
Image: image,
|
|
JavaMemory: javaMemory,
|
|
StorageSize: storage,
|
|
AutostartPolicy: policy,
|
|
Resources: resources,
|
|
}
|
|
if err := a.Cluster.CreateServer(r.Context(), in); err != nil {
|
|
if errors.Is(err, ErrConflict) {
|
|
writeError(w, r, newError(http.StatusConflict, "already_exists",
|
|
"a server named %q already exists", body.Name))
|
|
return
|
|
}
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
|
|
a.audit(r, "server.create", body.Name)
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"name": body.Name,
|
|
"subdomain": body.Subdomain,
|
|
"desiredState": string(v1alpha1.DesiredStopped),
|
|
})
|
|
}
|
|
|
|
// parseAutostartPolicy maps the form value to a CRD policy. An empty value
|
|
// defaults to the safest policy (ownerOnly); any other unknown value is a 400.
|
|
func parseAutostartPolicy(s string) (v1alpha1.AutostartPolicy, error) {
|
|
switch s {
|
|
case "":
|
|
return v1alpha1.AutostartOwnerOnly, nil
|
|
case string(v1alpha1.AutostartOwnerOnly):
|
|
return v1alpha1.AutostartOwnerOnly, nil
|
|
case string(v1alpha1.AutostartPublic):
|
|
return v1alpha1.AutostartPublic, nil
|
|
case string(v1alpha1.AutostartAllowlist):
|
|
return v1alpha1.AutostartAllowlist, nil
|
|
default:
|
|
return "", newError(http.StatusBadRequest, "bad_request",
|
|
"invalid autostartPolicy %q (want ownerOnly, public, or allowlist)", s)
|
|
}
|
|
}
|
|
|
|
// resolveResources turns the required memory string and the optional override
|
|
// block into the pod resource requirements. The top-level memory seeds both the
|
|
// memory limit and request; the override block may widen CPU and memory. It
|
|
// fails closed: the returned limit's memory is guaranteed non-zero so the §22
|
|
// ceiling is never absent from the CRD. The first return value is the derived
|
|
// JVM max-heap string (JavaMemory / -Xmx), computed from the FINAL memory limit
|
|
// — NOT the raw form value — so an overridden ceiling is honored and the heap
|
|
// stays below the cgroup limit (see deriveJavaHeap).
|
|
func resolveResources(memory string, rr *resourceRequest) (string, corev1.ResourceRequirements, error) {
|
|
memQ, err := parsePositiveQuantity(memory, "memory")
|
|
if err != nil {
|
|
return "", corev1.ResourceRequirements{}, err
|
|
}
|
|
limits := corev1.ResourceList{corev1.ResourceMemory: memQ}
|
|
requests := corev1.ResourceList{corev1.ResourceMemory: memQ}
|
|
|
|
if rr != nil {
|
|
if rr.Memory != "" {
|
|
q, err := parsePositiveQuantity(rr.Memory, "resources.memory")
|
|
if err != nil {
|
|
return "", corev1.ResourceRequirements{}, err
|
|
}
|
|
limits[corev1.ResourceMemory] = q
|
|
}
|
|
if rr.MemoryRequest != "" {
|
|
q, err := parsePositiveQuantity(rr.MemoryRequest, "resources.memoryRequest")
|
|
if err != nil {
|
|
return "", corev1.ResourceRequirements{}, err
|
|
}
|
|
requests[corev1.ResourceMemory] = q
|
|
}
|
|
if rr.CPU != "" {
|
|
q, err := parsePositiveQuantity(rr.CPU, "resources.cpu")
|
|
if err != nil {
|
|
return "", corev1.ResourceRequirements{}, err
|
|
}
|
|
limits[corev1.ResourceCPU] = q
|
|
}
|
|
if rr.CPURequest != "" {
|
|
q, err := parsePositiveQuantity(rr.CPURequest, "resources.cpuRequest")
|
|
if err != nil {
|
|
return "", corev1.ResourceRequirements{}, err
|
|
}
|
|
requests[corev1.ResourceCPU] = q
|
|
}
|
|
}
|
|
|
|
if err := checkResourceBounds(limits, requests); err != nil {
|
|
return "", corev1.ResourceRequirements{}, err
|
|
}
|
|
|
|
// §22 fail-closed: never hand the operator a CRD without a concrete memory
|
|
// ceiling. This cannot trigger given the positive memQ above, but the assert
|
|
// guarantees the invariant survives future edits.
|
|
memLim, ok := limits[corev1.ResourceMemory]
|
|
if !ok || memLim.IsZero() {
|
|
return "", corev1.ResourceRequirements{}, newError(http.StatusInternalServerError, "internal",
|
|
"refusing to create a server without a memory ceiling")
|
|
}
|
|
|
|
return deriveJavaHeap(memLim), corev1.ResourceRequirements{Limits: limits, Requests: requests}, nil
|
|
}
|
|
|
|
// checkResourceBounds refuses a request above its limit. Kubernetes rejects one
|
|
// too; failing here gives a clear 400 instead of a bounced CRD write.
|
|
func checkResourceBounds(limits, requests corev1.ResourceList) error {
|
|
if memReq, hasReq := requests[corev1.ResourceMemory]; hasReq {
|
|
if memLim, hasLim := limits[corev1.ResourceMemory]; hasLim && memReq.Cmp(memLim) > 0 {
|
|
return newError(http.StatusBadRequest, "bad_request",
|
|
"memory request %s exceeds limit %s", memReq.String(), memLim.String())
|
|
}
|
|
}
|
|
if cpuReq, hasReq := requests[corev1.ResourceCPU]; hasReq {
|
|
if cpuLim, hasLim := limits[corev1.ResourceCPU]; hasLim && cpuReq.Cmp(cpuLim) > 0 {
|
|
return newError(http.StatusBadRequest, "bad_request",
|
|
"cpu request %s exceeds limit %s", cpuReq.String(), cpuLim.String())
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// patchResourceRequest is the resources block of a patch. A field left out keeps
|
|
// what the server has. An empty cpu or cpuRequest removes that limit or request;
|
|
// the memory ceiling can be changed but never removed (§22).
|
|
type patchResourceRequest struct {
|
|
CPU *string `json:"cpu,omitempty"`
|
|
CPURequest *string `json:"cpuRequest,omitempty"`
|
|
Memory *string `json:"memory,omitempty"`
|
|
MemoryRequest *string `json:"memoryRequest,omitempty"`
|
|
}
|
|
|
|
// mergeResources lays a patch's memory and resources over the server's current
|
|
// pod block, so a field the admin left out keeps its value: a CPU-only patch
|
|
// keeps the memory, a memory-only patch keeps the CPU limit. The top-level
|
|
// memory sets the memory limit and request together, as create does; the block
|
|
// then overrides single fields. It returns the heap derived from the final
|
|
// ceiling and whether the ceiling moved, which is when the heap must follow.
|
|
func mergeResources(cur corev1.ResourceRequirements, memory *string, rr *patchResourceRequest) (string, bool, corev1.ResourceRequirements, error) {
|
|
out := *cur.DeepCopy()
|
|
if out.Limits == nil {
|
|
out.Limits = corev1.ResourceList{}
|
|
}
|
|
if out.Requests == nil {
|
|
out.Requests = corev1.ResourceList{}
|
|
}
|
|
fail := func(err error) (string, bool, corev1.ResourceRequirements, error) {
|
|
return "", false, corev1.ResourceRequirements{}, err
|
|
}
|
|
// set parses a present field onto one list entry. An empty value removes the
|
|
// entry where that is allowed and is refused where it is not.
|
|
set := func(list corev1.ResourceList, key corev1.ResourceName, v *string, field string, removable bool) error {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
if *v == "" {
|
|
if !removable {
|
|
return newError(http.StatusBadRequest, "bad_request", "%s cannot be empty", field)
|
|
}
|
|
delete(list, key)
|
|
return nil
|
|
}
|
|
q, err := parsePositiveQuantity(*v, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
list[key] = q
|
|
return nil
|
|
}
|
|
|
|
if err := set(out.Limits, corev1.ResourceMemory, memory, "memory", false); err != nil {
|
|
return fail(err)
|
|
}
|
|
if memory != nil {
|
|
out.Requests[corev1.ResourceMemory] = out.Limits[corev1.ResourceMemory]
|
|
}
|
|
if rr != nil {
|
|
for _, f := range []struct {
|
|
list corev1.ResourceList
|
|
key corev1.ResourceName
|
|
v *string
|
|
field string
|
|
removable bool
|
|
}{
|
|
{out.Limits, corev1.ResourceMemory, rr.Memory, "resources.memory", false},
|
|
{out.Requests, corev1.ResourceMemory, rr.MemoryRequest, "resources.memoryRequest", false},
|
|
{out.Limits, corev1.ResourceCPU, rr.CPU, "resources.cpu", true},
|
|
{out.Requests, corev1.ResourceCPU, rr.CPURequest, "resources.cpuRequest", true},
|
|
} {
|
|
if err := set(f.list, f.key, f.v, f.field, f.removable); err != nil {
|
|
return fail(err)
|
|
}
|
|
}
|
|
}
|
|
if err := checkResourceBounds(out.Limits, out.Requests); err != nil {
|
|
return fail(err)
|
|
}
|
|
|
|
// §22 fail-closed: a server that somehow has no ceiling gets none invented
|
|
// here; the admin has to pick the memory.
|
|
memLim, ok := out.Limits[corev1.ResourceMemory]
|
|
if !ok || memLim.IsZero() {
|
|
return fail(newError(http.StatusBadRequest, "bad_request",
|
|
"this server has no memory ceiling; set memory in the same patch"))
|
|
}
|
|
curLim := cur.Limits[corev1.ResourceMemory]
|
|
return deriveJavaHeap(memLim), memLim.Cmp(curLim) != 0, out, nil
|
|
}
|
|
|
|
// deriveJavaHeap converts the pod memory ceiling into a JVM max-heap string
|
|
// (JavaMemory → JAVA_MEMORY → -Xmx). Two reasons the raw K8s quantity cannot be
|
|
// forwarded as-is:
|
|
//
|
|
// - Format: the JVM's -Xmx accepts k/m/g (1024-based) suffixes, NOT the
|
|
// Kubernetes "Ki/Mi/Gi" forms. "-Xmx2Gi" fails to start the JVM, so we emit
|
|
// a plain "<N>M" value, which both -Xmx and the container entrypoint accept.
|
|
// - Headroom: metaspace, thread stacks, Netty direct buffers and GC structures
|
|
// live OUTSIDE the heap. Setting -Xmx to the full cgroup limit guarantees an
|
|
// eventual OOMKill, so we reserve off-heap room (the larger of 512Mi or 25%,
|
|
// capped at half the limit) and size the heap to what remains.
|
|
//
|
|
// This is a sane default the operator/runtime may later refine; it is purely a
|
|
// derivation of the §22 ceiling and never exceeds it.
|
|
func deriveJavaHeap(limit resource.Quantity) string {
|
|
const mib = int64(1024 * 1024)
|
|
bytes := limit.Value()
|
|
|
|
reserve := bytes / 4
|
|
if floor := 512 * mib; reserve < floor {
|
|
reserve = floor
|
|
}
|
|
if half := bytes / 2; reserve > half {
|
|
reserve = half
|
|
}
|
|
|
|
heapMiB := (bytes - reserve) / mib
|
|
if heapMiB < 1 {
|
|
heapMiB = 1
|
|
}
|
|
return fmt.Sprintf("%dM", heapMiB)
|
|
}
|
|
|
|
// parseStorageSize validates the required storage size and returns the
|
|
// canonical quantity string for the PVC (spec §15).
|
|
func parseStorageSize(s string) (string, error) {
|
|
q, err := parsePositiveQuantity(s, "storage")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return q.String(), nil
|
|
}
|
|
|
|
// parsePositiveQuantity parses a Kubernetes quantity string and rejects any
|
|
// non-positive value with a 400 naming the offending field.
|
|
func parsePositiveQuantity(s, field string) (resource.Quantity, error) {
|
|
q, err := resource.ParseQuantity(s)
|
|
if err != nil {
|
|
return resource.Quantity{}, newError(http.StatusBadRequest, "bad_request",
|
|
"invalid %s quantity %q: %v", field, s, err)
|
|
}
|
|
if q.Sign() <= 0 {
|
|
return resource.Quantity{}, newError(http.StatusBadRequest, "bad_request",
|
|
"%s must be a positive quantity", field)
|
|
}
|
|
return q, nil
|
|
}
|
|
|
|
// patchServerRequest is the structured §7 PATCH /servers/{name} form. Like the
|
|
// §15 create form it is a CLOSED set of typed fields (decodeJSON rejects unknown
|
|
// fields), so an admin can never smuggle raw CRD/YAML knobs through a patch.
|
|
// Every field is a pointer: a nil pointer means "absent — leave unchanged",
|
|
// which a plain zero value could not distinguish from "set to empty". It mutates
|
|
// only CRD-authoritative spec fields (spec §22); it deliberately has no field for
|
|
// the dual-write routing identity (name is the immutable object key; subdomain
|
|
// would desync the Postgres alias) nor for the world PVC size (see below).
|
|
type patchServerRequest struct {
|
|
DisplayName *string `json:"displayName,omitempty"`
|
|
AutostartPolicy *string `json:"autostartPolicy,omitempty"`
|
|
Image *string `json:"image,omitempty"`
|
|
// ConfirmImageChange acknowledges that a new image opens the world with
|
|
// whatever Minecraft version it carries. Chunks a newer version has upgraded
|
|
// cannot be read by the older one again, so without it an image change that
|
|
// would actually move the server is refused (image_change_unconfirmed).
|
|
ConfirmImageChange bool `json:"confirmImageChange,omitempty"`
|
|
Memory *string `json:"memory,omitempty"`
|
|
// Resources overrides single fields of the pod block; see patchResourceRequest.
|
|
Resources *patchResourceRequest `json:"resources,omitempty"`
|
|
// IdleStopSeconds sets idle auto-stop: 0 turns it off, otherwise the server
|
|
// stops after that many seconds with nobody online (60 to 86400).
|
|
IdleStopSeconds *int32 `json:"idleStopSeconds,omitempty"`
|
|
// Storage is recognized only so the endpoint can reject it with a precise
|
|
// reason rather than an opaque "unknown field": a StatefulSet's PVC capacity
|
|
// is immutable except for storage-class-gated expansion, which this build does
|
|
// not orchestrate. Accepting it would write a CRD change the operator cannot
|
|
// honor, so it is refused (storage_immutable) instead of silently dropped.
|
|
Storage *string `json:"storage,omitempty"`
|
|
}
|
|
|
|
// The idle auto-stop range an admin may pick through PATCH /servers/{name}.
|
|
const (
|
|
minIdleStopSeconds = 60
|
|
maxIdleStopSeconds = 86400
|
|
)
|
|
|
|
// handlePatchServer (spec §7 PATCH /servers/{name}) is the admin-tier spec
|
|
// mutation: it validates the structured form, re-admits any new image against the
|
|
// whitelist, re-derives the §22 memory ceiling, and applies a merge patch to the
|
|
// MinecraftServer CRD. Only CRD-authoritative fields move; the business layer
|
|
// (Postgres) is untouched, so the two never desync (spec §22). The admin gate is
|
|
// the adminOnly wrapper in routing — every caller here is already an admin.
|
|
func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
|
|
name := r.PathValue("name")
|
|
if err := validateManagedServerName(r, name); err != nil {
|
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
|
return
|
|
}
|
|
|
|
var body patchServerRequest
|
|
if err := decodeJSON(w, r, &body); err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
|
|
if naming.IsSystemServer(name) &&
|
|
((body.AutostartPolicy != nil && *body.AutostartPolicy != string(v1alpha1.AutostartPublic)) ||
|
|
(body.IdleStopSeconds != nil && *body.IdleStopSeconds != 0)) {
|
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "system services must remain public and exempt from idle stop"))
|
|
return
|
|
}
|
|
|
|
// An empty patch is a client mistake, not a no-op success.
|
|
if body.DisplayName == nil && body.AutostartPolicy == nil && body.Image == nil &&
|
|
body.Memory == nil && body.Resources == nil && body.Storage == nil && body.IdleStopSeconds == nil {
|
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
|
"patch must set at least one field"))
|
|
return
|
|
}
|
|
if body.Storage != nil {
|
|
writeError(w, r, newError(http.StatusBadRequest, "storage_immutable",
|
|
"storage size cannot be changed through this endpoint (PVC capacity is immutable)"))
|
|
return
|
|
}
|
|
|
|
// Build the resolved patch field-by-field, validating each present field with
|
|
// the SAME helpers the create form uses. `changed` records what actually moves
|
|
// so the response and audit name the real mutation.
|
|
var patch ServerSpecPatch
|
|
// Non-nil so a patch that moves nothing (re-picking the image a server is
|
|
// already pinned to) still answers "patched": [], as the API documents.
|
|
changed := []string{}
|
|
// imageFrom is the image a confirmed image change replaced, for the audit row.
|
|
var imageFrom string
|
|
// current reads the server once, for the fields that are checked or merged
|
|
// against what it has now.
|
|
var cur *ServerInfo
|
|
current := func() (*ServerInfo, error) {
|
|
if cur != nil {
|
|
return cur, nil
|
|
}
|
|
info, err := a.Cluster.GetServer(r.Context(), name)
|
|
cur = info
|
|
return info, err
|
|
}
|
|
|
|
if body.DisplayName != nil {
|
|
// An empty name is allowed: the panel then shows the server's name.
|
|
displayName, err := naming.CleanDisplayName(*body.DisplayName)
|
|
if err != nil {
|
|
writeError(w, r, newError(http.StatusBadRequest, "bad_display_name", "invalid display name: %v", err))
|
|
return
|
|
}
|
|
patch.DisplayName = &displayName
|
|
changed = append(changed, "displayName")
|
|
}
|
|
|
|
if body.AutostartPolicy != nil {
|
|
// Unlike create, an explicit empty policy is rejected rather than defaulted:
|
|
// a patch states an intent, so "" is ambiguous, not "the safe default".
|
|
if *body.AutostartPolicy == "" {
|
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
|
"autostartPolicy cannot be empty"))
|
|
return
|
|
}
|
|
policy, err := parseAutostartPolicy(*body.AutostartPolicy)
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
patch.AutostartPolicy = &policy
|
|
changed = append(changed, "autostartPolicy")
|
|
}
|
|
|
|
if body.IdleStopSeconds != nil {
|
|
// A minute is the floor: below it a player who drops for a reconnect
|
|
// finds the server stopping under them. A day is the ceiling; longer is
|
|
// what "off" is for.
|
|
if s := *body.IdleStopSeconds; s != 0 && (s < minIdleStopSeconds || s > maxIdleStopSeconds) {
|
|
writeError(w, r, newError(http.StatusBadRequest, "bad_idle_stop",
|
|
"idleStopSeconds must be 0 (off) or between %d and %d", minIdleStopSeconds, maxIdleStopSeconds))
|
|
return
|
|
}
|
|
patch.IdleStopSeconds = body.IdleStopSeconds
|
|
changed = append(changed, "idleStopSeconds")
|
|
}
|
|
|
|
if body.Image != nil {
|
|
// A new image must be re-admitted against the whitelist, exactly as create
|
|
// does — admission is the only source of a legal image. With no Builder
|
|
// there is no whitelist to check against, so the change cannot run safely.
|
|
if a.Builder == nil {
|
|
writeError(w, r, errBuildUnavailable)
|
|
return
|
|
}
|
|
if strings.TrimSpace(*body.Image) == "" {
|
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "image cannot be empty"))
|
|
return
|
|
}
|
|
admitted, err := a.Builder.ImageAdmitted(r.Context(), *body.Image)
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
if !admitted {
|
|
writeError(w, r, newError(http.StatusBadRequest, "image_not_whitelisted",
|
|
"image %q is not on the whitelist", *body.Image))
|
|
return
|
|
}
|
|
image, err := a.pinImage(r.Context(), *body.Image)
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
info, err := current()
|
|
if err != nil {
|
|
a.writeLookupError(w, r, err)
|
|
return
|
|
}
|
|
// Re-picking the tag a server was created from resolves to that tag's
|
|
// newest build, which is as much a version move as picking another image.
|
|
// Only a pin that lands on exactly the current image is no change at all.
|
|
if image != info.Image {
|
|
if !body.ConfirmImageChange {
|
|
writeError(w, r, newError(http.StatusConflict, "image_change_unconfirmed",
|
|
"changing the image from %q to %q opens this world with the new image's Minecraft version, "+
|
|
"and chunks it upgrades cannot be opened by the old one again; back the world up first, "+
|
|
"then resend with confirmImageChange", info.Image, image))
|
|
return
|
|
}
|
|
patch.Image = &image
|
|
changed = append(changed, "image")
|
|
imageFrom = info.Image
|
|
}
|
|
}
|
|
|
|
// Memory and the resource overrides are laid over the server's current pod
|
|
// block (mergeResources), so each may come alone and whatever the admin left
|
|
// out keeps its value. The heap follows the ceiling whenever memory is picked
|
|
// or the ceiling moves.
|
|
var (
|
|
newResources corev1.ResourceRequirements
|
|
resUpdated bool
|
|
)
|
|
if body.Memory != nil || body.Resources != nil {
|
|
info, err := current()
|
|
if err != nil {
|
|
a.writeLookupError(w, r, err)
|
|
return
|
|
}
|
|
javaMemory, memMoved, resources, err := mergeResources(info.Resources, body.Memory, body.Resources)
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
if body.Memory != nil || memMoved {
|
|
patch.JavaMemory = &javaMemory
|
|
}
|
|
patch.Resources = &resources
|
|
if body.Memory != nil {
|
|
changed = append(changed, "memory")
|
|
}
|
|
if body.Resources != nil {
|
|
changed = append(changed, "resources")
|
|
}
|
|
newResources = resources
|
|
resUpdated = true
|
|
}
|
|
|
|
// Resource-cache consistency + quota enforcement (spec §9.3 / §22): the cached
|
|
// columns are all the per-owner quota sums read, so the new size is written
|
|
// there before the cluster sees it, in the owner's claim lane and only if it
|
|
// fits (ResizeServer). Only growth is held to the caps: a change that grows
|
|
// neither CPU nor memory cannot push the owner past one, and it is how an admin
|
|
// brings a server back under a cap lowered below what the owner already uses.
|
|
if resUpdated && !naming.IsSystemServer(name) {
|
|
newCPU := quantityToMilli(newResources.Limits[corev1.ResourceCPU])
|
|
newMemMB := quantityToMB(newResources.Limits[corev1.ResourceMemory])
|
|
|
|
prev, err := a.Repo.ResizeServer(r.Context(), name, newCPU, newMemMB)
|
|
if errors.Is(err, ErrQuotaExceeded) {
|
|
writeError(w, r, newError(http.StatusForbidden, "quota_exceeded",
|
|
"this change would exceed the server owner's resource quota"))
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, r, err)
|
|
return
|
|
}
|
|
if err := a.Cluster.PatchServerSpec(r.Context(), name, patch); err != nil {
|
|
// The cache now holds a size the cluster may not: it goes back to the size
|
|
// the cluster reports, or, when that cannot be read either, to the one it
|
|
// held before.
|
|
ctx := context.WithoutCancel(r.Context())
|
|
if _, rerr := a.claimResources(ctx, name); rerr != nil {
|
|
if rerr := a.Repo.UpdateServerResources(ctx, name, prev.CPUMilli, prev.MemoryMB, prev.StorageMB); rerr != nil {
|
|
log.Printf("api: resize of %s refused by the cluster, resource cache left at the new size (request_id=%s): %v",
|
|
name, requestIDFromContext(ctx), rerr)
|
|
}
|
|
}
|
|
a.writeLookupError(w, r, err)
|
|
return
|
|
}
|
|
} else {
|
|
if err := a.Cluster.PatchServerSpec(r.Context(), name, patch); err != nil {
|
|
a.writeLookupError(w, r, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
if patch.Image != nil {
|
|
a.auditImageChange(r, name, imageFrom, *patch.Image)
|
|
} else {
|
|
a.audit(r, "server.patch", name)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"name": name,
|
|
"patched": changed,
|
|
})
|
|
}
|
|
|
|
// ---- authorization helpers ----
|
|
|
|
// authorizeWake applies the autostartPolicy gate (spec §9.4). The owner and any
|
|
// admin may always wake; otherwise the policy decides. An empty/unknown policy
|
|
// fails safe (owner-only).
|
|
func (a *API) authorizeWake(ctx context.Context, p *Principal, info *ServerInfo, rec *ServerRecord) error {
|
|
if p.IsAdmin() {
|
|
return nil
|
|
}
|
|
if rec != nil && rec.OwnerID != "" && rec.OwnerID == p.UserID {
|
|
return nil
|
|
}
|
|
switch info.AutostartPolicy {
|
|
case string(v1alpha1.AutostartPublic):
|
|
return nil
|
|
case string(v1alpha1.AutostartAllowlist):
|
|
ok, err := a.Repo.UserInAllowlist(ctx, info.Name, p.UserID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ok {
|
|
return nil
|
|
}
|
|
return errForbidden
|
|
default: // ownerOnly or unset → only owner/admin, already handled above
|
|
return errForbidden
|
|
}
|
|
}
|
|
|
|
// isOwnerOrAdmin reports whether p owns rec or is an admin.
|
|
func (a *API) isOwnerOrAdmin(p *Principal, rec *ServerRecord) bool {
|
|
if p.IsAdmin() {
|
|
return true
|
|
}
|
|
return rec != nil && rec.OwnerID != "" && rec.OwnerID == p.UserID
|
|
}
|
|
|
|
// quantityToMilli converts a K8s resource.Quantity to millicores (e.g. "2"→2000,
|
|
// "500m"→500). A zero/unset quantity returns 0.
|
|
func quantityToMilli(q resource.Quantity) int {
|
|
if q.IsZero() {
|
|
return 0
|
|
}
|
|
return int(q.MilliValue())
|
|
}
|
|
|
|
// quantityToMB converts a K8s resource.Quantity to whole MiB, the MB of the quota
|
|
// columns, rounding up so a size between two counts as the larger: "4Gi"→4096,
|
|
// "1G"→954. A zero/unset quantity returns 0.
|
|
func quantityToMB(q resource.Quantity) int {
|
|
const mib = 1 << 20
|
|
return int((q.Value() + mib - 1) / mib)
|
|
}
|