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