fix(felis): 编辑服务器时内存和 CPU 可各自单改互不清除,显示名可清空,内存显示真实上限

This commit is contained in:
Lemon-miaow committed 2026-09-26 22:22:02 +08:00
1 parent 22d1e834ad
commit d65cf3af30
18 files changed
+600 -60

No files matched your search

+19 -3
View File
@@ -472,7 +472,12 @@ components:
playersMax: { type: integer, format: int32 }
displayName: { type: string }
image: { type: string }
javaMemory: { type: string }
javaMemory:
type: string
description: The JVM heap (-Xmx) derived from the memory limit.
memory:
type: string
description: The pod memory limit (the server's memory as an admin picks it), a Kubernetes quantity such as 4Gi.
storageSize: { type: string }
cpu: { type: string }
idleStopSeconds:
@@ -5773,7 +5778,9 @@ paths:
type: object
description: Only the supplied fields are patched; an empty patch is rejected.
properties:
displayName: { type: string }
displayName:
type: string
description: Trimmed. An empty name clears it, and the server goes by its name again.
autostartPolicy: { type: string }
image:
type: string
@@ -5788,12 +5795,21 @@ paths:
version, whose chunk upgrades the old one cannot read. Without it an
image that would move the server is refused with 409
image_change_unconfirmed. The audit row records image_from/image_to.
memory: { type: string }
memory:
type: string
description: >-
Sets the memory limit and request together, as create does, and
re-derives the JVM heap. The CPU limit and request are kept.
storage:
type: string
description: Rejected with 400 storage_immutable — present for a clear error, not mutation.
resources:
type: object
description: >-
Single fields of the pod block, laid over what the server has: a
field left out keeps its value, so each may be sent alone. An empty
cpu or cpuRequest removes that limit or request; the memory ceiling
cannot be emptied. A request above its limit is a 400.
properties:
cpu: { type: string }
cpuRequest: { type: string }
+17
View File
@@ -15,6 +15,7 @@ import (
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
corev1 "k8s.io/api/core/v1"
)
const testRoot = "mc.example.net" // neutral; never a deployment domain
@@ -1845,6 +1846,22 @@ func (c *fakeCluster) PatchServerSpec(_ context.Context, n string, p ServerSpecP
if p.IdleStopSeconds != nil {
info.IdleStopSeconds = *p.IdleStopSeconds
}
if p.DisplayName != nil {
info.DisplayName = *p.DisplayName
}
if p.JavaMemory != nil {
info.JavaMemory = *p.JavaMemory
}
if p.Resources != nil {
info.Resources = *p.Resources.DeepCopy()
info.Memory, info.CPU = "", ""
if q, ok := p.Resources.Limits[corev1.ResourceMemory]; ok {
info.Memory = q.String()
}
if q, ok := p.Resources.Limits[corev1.ResourceCPU]; ok {
info.CPU = q.String()
}
}
return nil
}
+12 -5
View File
@@ -26,8 +26,11 @@ type ServerInfo struct {
DisplayName string `json:"displayName,omitempty"`
Image string `json:"image,omitempty"`
JavaMemory string `json:"javaMemory,omitempty"`
StorageSize string `json:"storageSize,omitempty"`
CPU string `json:"cpu,omitempty"`
// Memory is the pod memory limit (the §22 ceiling) — what an admin picks as
// the server's memory. JavaMemory is the heap derived from it.
Memory string `json:"memory,omitempty"`
StorageSize string `json:"storageSize,omitempty"`
CPU string `json:"cpu,omitempty"`
// IdleStopSeconds is how long the server may sit empty before idle
// auto-stop scales it down; 0 means it never idles out.
IdleStopSeconds int32 `json:"idleStopSeconds"`
@@ -43,6 +46,9 @@ type ServerInfo struct {
// restart backoff and may yet come up on its own.
AutoRestarts int32 `json:"autoRestarts,omitempty"`
StartGaveUp bool `json:"startGaveUp,omitempty"`
// Resources is the spec's pod resource block. It stays off the wire; a spec
// patch reads it so the fields the admin left out keep their values.
Resources corev1.ResourceRequirements `json:"-"`
}
// CreateServerInput is the validated, structured create-server form (spec §15).
@@ -77,9 +83,10 @@ type ServerSpecPatch struct {
DisplayName *string
AutostartPolicy *v1alpha1.AutostartPolicy
Image *string
// JavaMemory is the re-derived JVM heap string; Resources carries the matching
// pod block whose memory limit is the non-zero §22 ceiling. They move together
// (felis-api resolves both from the same form) or both stay nil.
// Resources is the whole pod block after the patch — the current spec with the
// admin's changes laid over it — whose memory limit is the non-zero §22
// ceiling. JavaMemory is the heap re-derived from that ceiling; it is set only
// when the memory moved, so a CPU-only patch leaves the heap alone.
JavaMemory *string
Resources *corev1.ResourceRequirements
// IdleStopSeconds sets idle auto-stop: 0 turns it off, anything else is the
+126 -1
View File
@@ -3,6 +3,7 @@ package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
@@ -210,7 +211,8 @@ func TestPatchServerRejections(t *testing.T) {
wantCode: http.StatusBadRequest, wantErr: "bad_request",
},
{
name: "resources without memory",
// The seeded server has no pod block, so there is no ceiling to keep.
name: "resources on a server with no memory ceiling",
body: `{"resources":{"cpu":"2"}}`,
wantCode: http.StatusBadRequest, wantErr: "bad_request",
},
@@ -309,3 +311,126 @@ func TestPatchServerIdleStop(t *testing.T) {
}
}
}
// seedResources gives survival the pod block create would have written: a 4Gi
// ceiling, a 1-core CPU limit and a 500m CPU request.
func seedResources(cl *fakeCluster) {
cl.byName["survival"].JavaMemory = "3072M"
cl.byName["survival"].Resources = corev1.ResourceRequirements{
Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("4Gi"), corev1.ResourceCPU: resource.MustParse("1")},
Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("4Gi"), corev1.ResourceCPU: resource.MustParse("500m")},
}
}
func wantQuantity(t *testing.T, what string, list corev1.ResourceList, key corev1.ResourceName, want string) {
t.Helper()
got, ok := list[key]
if want == "" {
if ok {
t.Errorf("%s = %s, want none", what, got.String())
}
return
}
if !ok || got.Cmp(resource.MustParse(want)) != 0 {
t.Errorf("%s = %s (present %v), want %s", what, got.String(), ok, want)
}
}
// The edit dialog sends only the field the admin changed. Each patch lays that one
// field over the server's pod block and keeps the rest.
func TestPatchServerEditsOneResource(t *testing.T) {
t.Run("cpu only keeps the memory and the heap", func(t *testing.T) {
api, repo, cl, _ := newPatchAPI()
seedResources(cl)
repo.byName["survival"].OwnerID = "u1"
repo.quota["u1"] = true
w := patchSurvival(api, `{"resources":{"cpu":"2"}}`)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
p := cl.patched["survival"]
if p.Resources == nil {
t.Fatal("a cpu patch must carry the pod block")
}
wantQuantity(t, "memory limit", p.Resources.Limits, corev1.ResourceMemory, "4Gi")
wantQuantity(t, "cpu limit", p.Resources.Limits, corev1.ResourceCPU, "2")
wantQuantity(t, "memory request", p.Resources.Requests, corev1.ResourceMemory, "4Gi")
wantQuantity(t, "cpu request", p.Resources.Requests, corev1.ResourceCPU, "500m")
if p.JavaMemory != nil {
t.Errorf("heap = %q, want untouched by a cpu patch", *p.JavaMemory)
}
if got := repo.resourceUpdates["survival"]; got.CPUMilli != 2000 || got.MemoryMB != 4096 {
t.Errorf("resource cache = %+v, want cpu 2000 / mem 4096", got)
}
if body := w.Body.String(); !strings.Contains(body, `"patched":["resources"]`) {
t.Errorf("response = %s, want patched [resources]", body)
}
})
t.Run("memory only keeps the cpu limit and request", func(t *testing.T) {
api, _, cl, _ := newPatchAPI()
seedResources(cl)
w := patchSurvival(api, `{"memory":"8Gi"}`)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
p := cl.patched["survival"]
wantQuantity(t, "memory limit", p.Resources.Limits, corev1.ResourceMemory, "8Gi")
wantQuantity(t, "cpu limit", p.Resources.Limits, corev1.ResourceCPU, "1")
wantQuantity(t, "memory request", p.Resources.Requests, corev1.ResourceMemory, "8Gi")
wantQuantity(t, "cpu request", p.Resources.Requests, corev1.ResourceCPU, "500m")
if p.JavaMemory == nil || *p.JavaMemory != "6144M" {
t.Errorf("heap = %v, want 6144M derived from 8Gi", p.JavaMemory)
}
})
t.Run("an empty cpu removes the limit", func(t *testing.T) {
api, _, cl, _ := newPatchAPI()
seedResources(cl)
w := patchSurvival(api, `{"resources":{"cpu":""}}`)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
p := cl.patched["survival"]
wantQuantity(t, "cpu limit", p.Resources.Limits, corev1.ResourceCPU, "")
wantQuantity(t, "memory limit", p.Resources.Limits, corev1.ResourceMemory, "4Gi")
})
for _, c := range []struct{ name, body string }{
{"the memory ceiling cannot be emptied", `{"resources":{"memory":""}}`},
{"a cpu request above the kept limit", `{"resources":{"cpuRequest":"2"}}`},
{"a memory ceiling below the kept request", `{"resources":{"memory":"2Gi"}}`},
} {
t.Run(c.name, func(t *testing.T) {
api, _, cl, _ := newPatchAPI()
seedResources(cl)
w := patchSurvival(api, c.body)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Fatalf("code = %d (%s), want 400 bad_request", w.Code, w.Body.String())
}
if len(cl.patched) != 0 {
t.Errorf("a rejected patch must not write a spec, got %+v", cl.patched)
}
})
}
}
// Clearing the display name in the dialog sends an empty one; the server then
// goes by its name again. Blank space counts as empty.
func TestPatchServerClearsDisplayName(t *testing.T) {
api, _, cl, _ := newPatchAPI()
cl.byName["survival"].DisplayName = "Survival Realm"
w := patchSurvival(api, `{"displayName":" "}`)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
p := cl.patched["survival"]
if p.DisplayName == nil || *p.DisplayName != "" {
t.Fatalf("patched displayName = %v, want an empty one", p.DisplayName)
}
}
+143 -29
View File
@@ -617,17 +617,8 @@ func resolveResources(memory string, rr *resourceRequest) (string, corev1.Resour
}
}
// A request that exceeds its limit is rejected by Kubernetes; fail fast here
// with a clear 400 instead of letting the CRD write bounce.
if memReq, memLim := requests[corev1.ResourceMemory], limits[corev1.ResourceMemory]; memReq.Cmp(memLim) > 0 {
return "", corev1.ResourceRequirements{}, 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 "", corev1.ResourceRequirements{}, newError(http.StatusBadRequest, "bad_request",
"cpu request %s exceeds limit %s", cpuReq.String(), cpuLim.String())
}
if err := checkResourceBounds(limits, requests); err != nil {
return "", corev1.ResourceRequirements{}, err
}
// §22 fail-closed: never hand the operator a CRD without a concrete memory
@@ -642,6 +633,111 @@ func resolveResources(memory string, rr *resourceRequest) (string, corev1.Resour
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:
@@ -716,9 +812,10 @@ type patchServerRequest struct {
// 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 *resourceRequest `json:"resources,omitempty"`
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"`
@@ -777,9 +874,22 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
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 {
patch.DisplayName = body.DisplayName
// An empty name is allowed: the panel then shows the server's name.
displayName := strings.TrimSpace(*body.DisplayName)
patch.DisplayName = &displayName
changed = append(changed, "displayName")
}
@@ -840,7 +950,7 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
info, err := a.Cluster.GetServer(r.Context(), name)
info, err := current()
if err != nil {
a.writeLookupError(w, r, err)
return
@@ -862,33 +972,37 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
}
}
// Memory and the resource overrides move together: resolveResources derives the
// JVM heap and the §22 non-zero ceiling from the FINAL memory limit, and the
// override block is meaningless without that base. A resources-only patch has no
// base ceiling to widen (this endpoint does not read the current spec back), so
// it is rejected rather than guessed.
// 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 {
javaMemory, resources, err := resolveResources(*body.Memory, body.Resources)
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
}
patch.JavaMemory = &javaMemory
if body.Memory != nil || memMoved {
patch.JavaMemory = &javaMemory
}
patch.Resources = &resources
changed = append(changed, "memory")
if body.Memory != nil {
changed = append(changed, "memory")
}
if body.Resources != nil {
changed = append(changed, "resources")
}
newResources = resources
resUpdated = true
} else if body.Resources != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"resources overrides require memory to be set in the same patch"))
return
}
// Resource-cache consistency + quota enforcement (spec §9.3 / §22): every
+8 -5
View File
@@ -437,11 +437,12 @@ func (k *K8sCluster) PatchServerSpec(ctx context.Context, name string, p ServerS
// serverInfo projects a MinecraftServer onto the API's lifecycle view.
func serverInfo(ms *v1alpha1.MinecraftServer) *ServerInfo {
var cpuStr string
if ms.Spec.Resources.Limits != nil {
if limit, ok := ms.Spec.Resources.Limits[corev1.ResourceCPU]; ok {
cpuStr = limit.String()
}
var cpuStr, memStr string
if limit, ok := ms.Spec.Resources.Limits[corev1.ResourceCPU]; ok {
cpuStr = limit.String()
}
if limit, ok := ms.Spec.Resources.Limits[corev1.ResourceMemory]; ok {
memStr = limit.String()
}
return &ServerInfo{
@@ -458,6 +459,7 @@ func serverInfo(ms *v1alpha1.MinecraftServer) *ServerInfo {
DisplayName: ms.Spec.DisplayName,
Image: ms.Spec.Image,
JavaMemory: ms.Spec.JavaMemory,
Memory: memStr,
StorageSize: ms.Spec.Storage.Size,
CPU: cpuStr,
IdleStopSeconds: idleStopSeconds(ms),
@@ -466,6 +468,7 @@ func serverInfo(ms *v1alpha1.MinecraftServer) *ServerInfo {
LegacyForwarding: ms.Labels[v1alpha1.LabelForwarding] == v1alpha1.ForwardingLegacy,
AutoRestarts: ms.Status.AutoRestarts,
StartGaveUp: v1alpha1.StartGaveUp(&ms.Status),
Resources: *ms.Spec.Resources.DeepCopy(),
}
}
+81
View File
@@ -10,6 +10,8 @@ import (
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/naming"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
@@ -320,3 +322,82 @@ func TestServerInfoCarriesStartGaveUp(t *testing.T) {
t.Fatalf("spent: startGaveUp=%v JSON %s, want true", spent.StartGaveUp, b)
}
}
// An admin edits one resource at a time. The view hands the handler the whole
// pod block, the handler lays the change over it, and the merge patch keeps
// everything the admin left alone: memory-only keeps the CPU limit, CPU-only
// keeps the memory, and an emptied CPU field really drops the limit.
func TestResourcePatchesKeepWhatTheyLeaveOut(t *testing.T) {
scheme := runtime.NewScheme()
if err := v1alpha1.AddToScheme(scheme); err != nil {
t.Fatalf("scheme: %v", err)
}
ms := testServer("survival", "survival")
ms.Spec.JavaMemory = "3072M"
ms.Spec.Resources = corev1.ResourceRequirements{
Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("4Gi"), corev1.ResourceCPU: resource.MustParse("2")},
Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("4Gi"), corev1.ResourceCPU: resource.MustParse("500m")},
}
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(ms).Build()
k := NewK8sCluster(c, "minecraft")
ctx := context.Background()
str := func(s string) *string { return &s }
// edit is one PATCH /servers/survival: read the view, merge, write.
edit := func(memory *string, rr *patchResourceRequest) *ServerInfo {
t.Helper()
info, err := k.GetServer(ctx, "survival")
if err != nil {
t.Fatalf("GetServer: %v", err)
}
heap, moved, res, err := mergeResources(info.Resources, memory, rr)
if err != nil {
t.Fatalf("merge: %v", err)
}
p := ServerSpecPatch{Resources: &res}
if memory != nil || moved {
p.JavaMemory = &heap
}
if err := k.PatchServerSpec(ctx, "survival", p); err != nil {
t.Fatalf("patch: %v", err)
}
after, err := k.GetServer(ctx, "survival")
if err != nil {
t.Fatalf("GetServer: %v", err)
}
return after
}
info, err := k.GetServer(ctx, "survival")
if err != nil {
t.Fatalf("GetServer: %v", err)
}
if info.Memory != "4Gi" || info.CPU != "2" || info.JavaMemory != "3072M" {
t.Fatalf("view = memory %q cpu %q heap %q, want 4Gi 2 3072M", info.Memory, info.CPU, info.JavaMemory)
}
b, err := json.Marshal(info)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "requests") || strings.Contains(string(b), "Resources") {
t.Fatalf("the pod block reached the wire: %s", b)
}
after := edit(str("8Gi"), nil)
cpuReq := after.Resources.Requests[corev1.ResourceCPU]
if after.Memory != "8Gi" || after.CPU != "2" || cpuReq.String() != "500m" || after.JavaMemory != "6144M" {
t.Fatalf("memory-only: memory %q cpu %q cpuRequest %s heap %q, want 8Gi 2 500m 6144M",
after.Memory, after.CPU, cpuReq.String(), after.JavaMemory)
}
after = edit(nil, &patchResourceRequest{CPU: str("3")})
memReq := after.Resources.Requests[corev1.ResourceMemory]
if after.CPU != "3" || after.Memory != "8Gi" || memReq.String() != "8Gi" || after.JavaMemory != "6144M" {
t.Fatalf("cpu-only: cpu %q memory %q memoryRequest %s heap %q, want 3 8Gi 8Gi 6144M",
after.CPU, after.Memory, memReq.String(), after.JavaMemory)
}
after = edit(nil, &patchResourceRequest{CPU: str("")})
if _, has := after.Resources.Limits[corev1.ResourceCPU]; has || after.CPU != "" || after.Memory != "8Gi" {
t.Fatalf("cleared cpu: limits %v, want the CPU limit gone and memory 8Gi kept", after.Resources.Limits)
}
}
+2 -1
View File
@@ -256,7 +256,8 @@ function initialState(): MockState {
playersOnline: 12,
playersMax: 20,
autostartPolicy: "public",
javaMemory: "4Gi",
memory: "4Gi",
javaMemory: "3072M",
storageSize: "20Gi",
// Pinned the way felis-api stores it: the tag it was created from plus the
// digest that tag named then.
+4 -2
View File
@@ -29,10 +29,12 @@ export function ConfirmFooter({
return (
<DialogFooter className={className}>
{children}
<Button variant="outline" size="sm" onClick={onCancel} disabled={disabled ?? loading}>
{/* Backing out stays open whenever nothing is in flight: a form with nothing to
save yet, or a confirm that is not allowed, is still one to cancel. */}
<Button variant="outline" size="sm" onClick={onCancel} disabled={loading}>
{cancelLabel}
</Button>
<Button variant={confirmVariant} size="sm" onClick={onConfirm} disabled={disabled ?? loading}>
<Button variant={confirmVariant} size="sm" onClick={onConfirm} disabled={disabled || loading}>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{confirmLabel}
</Button>
@@ -0,0 +1,124 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { EditServerDialog } from "./EditServerDialog";
const calls = vi.hoisted(() => ({ listImages: vi.fn(), patchServer: vi.fn() }));
vi.mock("@/lib/api", async (importActual) => {
const actual = await importActual<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, ...calls } };
});
// Radix Select opens with pointer capture and scrolls the picked item into view,
// neither of which jsdom implements.
beforeAll(() => {
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.releasePointerCapture ??= () => {};
Element.prototype.scrollIntoView ??= () => {};
});
const onUpdated = vi.fn();
beforeEach(() => {
calls.listImages.mockReset();
calls.patchServer.mockReset();
onUpdated.mockReset();
calls.listImages.mockResolvedValue([]);
calls.patchServer.mockResolvedValue({ name: "survival", patched: [] });
});
async function openDialog() {
const user = userEvent.setup();
render(
<EditServerDialog
serverName="survival"
currentDisplayName="Survival Realm"
currentPolicy="ownerOnly"
currentImage="registry.example.test/paper:1.21"
currentMemory="4Gi"
currentStorage="10Gi"
currentCpu="1"
currentIdleStopSeconds={600}
onUpdated={onUpdated}
/>,
);
await user.click(screen.getByRole("button", { name: /Edit Server Config/ }));
await screen.findByRole("dialog");
return user;
}
const save = () => screen.getByRole("button", { name: "Save Config" }) as HTMLButtonElement;
const cpuInput = () => screen.getByLabelText("CPU Limit") as HTMLInputElement;
describe("EditServerDialog request body", () => {
it("sends the CPU alone when only the CPU changed", async () => {
const user = await openDialog();
await user.clear(cpuInput());
await user.type(cpuInput(), "2");
await user.click(save());
expect(calls.patchServer).toHaveBeenCalledWith("survival", { resources: { cpu: "2" } });
expect(onUpdated).toHaveBeenCalledTimes(1);
});
it("sends the memory alone when only the memory changed", async () => {
const user = await openDialog();
await user.click(screen.getByRole("combobox", { name: "Memory" }));
await user.click(await screen.findByRole("option", { name: "8Gi" }));
await user.click(save());
expect(calls.patchServer).toHaveBeenCalledWith("survival", { memory: "8Gi" });
});
it("clears the display name with an empty one", async () => {
const user = await openDialog();
await user.clear(screen.getByLabelText("Display name (optional)"));
await user.click(save());
expect(calls.patchServer).toHaveBeenCalledWith("survival", { displayName: "" });
});
it("lifts the CPU limit when the field is emptied", async () => {
const user = await openDialog();
await user.clear(cpuInput());
await user.click(save());
expect(calls.patchServer).toHaveBeenCalledWith("survival", { resources: { cpu: "" } });
});
it("treats surrounding space as no change", async () => {
const user = await openDialog();
await user.type(cpuInput(), " ");
await user.type(screen.getByLabelText("Display name (optional)"), " ");
expect(save().disabled).toBe(true);
});
it("refuses a CPU value the server cannot take", async () => {
const user = await openDialog();
for (const bad of ["two", "0", "1.5m", "-1"]) {
await user.clear(cpuInput());
await user.type(cpuInput(), bad);
expect(screen.getByText("Enter cores (1, 1.5) or millicores (500m), above zero."), bad).toBeTruthy();
expect(cpuInput().getAttribute("aria-invalid"), bad).toBe("true");
expect(save().disabled, bad).toBe(true);
}
for (const good of ["1.5", "500m", "4"]) {
await user.clear(cpuInput());
await user.type(cpuInput(), good);
expect(screen.queryByText(/millicores/), good).toBeNull();
expect(save().disabled, good).toBe(false);
}
expect(calls.patchServer).not.toHaveBeenCalled();
});
it("can be cancelled with nothing changed", async () => {
const user = await openDialog();
expect(save().disabled).toBe(true);
const cancel = screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement;
expect(cancel.disabled).toBe(false);
await user.click(cancel);
expect(screen.queryByRole("dialog")).toBeNull();
});
});
+28 -10
View File
@@ -27,6 +27,14 @@ import { InlineError } from "@/components/MessageLine";
const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"];
/** cpuValid accepts what the API takes as a CPU limit: a positive number of
* cores ("2", "1.5") or millicores ("500m"). Empty is valid too: no limit. */
function cpuValid(cpu: string): boolean {
if (cpu === "") return true;
const m = /^(\d+(?:\.\d+)?)(m?)$/.exec(cpu);
return m !== null && Number(m[1]) > 0 && (m[2] === "" || !m[1].includes("."));
}
/** Idle auto-stop presets in seconds; "0" is Never. The server default is 600. */
const IDLE_OPTIONS = ["0", "300", "600", "900", "1800", "3600", "7200"];
@@ -141,18 +149,22 @@ export function EditServerDialog({
const enabledImages = (images.data ?? []).filter((i) => i.enabled);
// Checks if form state has mutated from initial values
// Text fields count as what they send: surrounding space is no change, and an
// emptied field is one (it clears the name, or lifts the CPU limit).
const displayName = form.displayName.trim();
const cpu = form.cpu.trim();
const cpuOk = cpuValid(cpu);
const hasChanges =
form.displayName !== currentDisplayName ||
displayName !== currentDisplayName ||
form.autostartPolicy !== currentPolicy ||
form.image !== currentImage ||
form.memory !== currentMemory ||
form.cpu !== currentCpu ||
cpu !== currentCpu ||
form.idleStop !== currentIdleStop;
const imageChanged = form.image !== currentImage;
const pinnedBuild = splitImageRef(currentImage).short;
const canSubmit = hasChanges && !submitting && (!imageChanged || imageConfirmed);
const canSubmit = hasChanges && cpuOk && !submitting && (!imageChanged || imageConfirmed);
async function submit() {
setSubmitting(true);
@@ -160,8 +172,8 @@ export function EditServerDialog({
try {
const payload: Parameters<typeof api.patchServer>[1] = {};
if (form.displayName !== currentDisplayName) {
payload.displayName = form.displayName.trim() || undefined;
if (displayName !== currentDisplayName) {
payload.displayName = displayName;
}
if (form.autostartPolicy !== currentPolicy) {
payload.autostartPolicy = form.autostartPolicy;
@@ -170,13 +182,12 @@ export function EditServerDialog({
payload.image = form.image;
payload.confirmImageChange = imageConfirmed;
}
// Memory and CPU each go alone; the API keeps whatever is not sent.
if (form.memory !== currentMemory) {
payload.memory = form.memory;
}
if (form.cpu !== currentCpu) {
payload.resources = {
cpu: form.cpu.trim(),
};
if (cpu !== currentCpu) {
payload.resources = { cpu };
}
if (form.idleStop !== currentIdleStop) {
payload.idleStopSeconds = Number(form.idleStop);
@@ -324,7 +335,14 @@ export function EditServerDialog({
placeholder={t("edit_server_cpu_placeholder")}
value={form.cpu}
onChange={(e) => set("cpu", e.target.value)}
aria-invalid={!cpuOk}
aria-describedby={cpuOk ? undefined : "es-cpu-error"}
/>
{!cpuOk && (
<p id="es-cpu-error" className="text-xs text-destructive">
{t("edit_server_cpu_invalid")}
</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="edit-server-policy">{t("create_server_policy")}</Label>
+2 -1
View File
@@ -178,7 +178,8 @@
"luckperms_rcon_output": "RCON Console Output",
"luckperms_clear_history": "Clear history",
"edit_server_cpu": "CPU Limit",
"edit_server_cpu_placeholder": "e.g. 1, 2, 500m",
"edit_server_cpu_placeholder": "e.g. 1, 1.5, 500m — empty for no limit",
"edit_server_cpu_invalid": "Enter cores (1, 1.5) or millicores (500m), above zero.",
"owned_filter_mine": "me",
"edit_server_idle": "Idle auto-stop",
"edit_server_idle_hint": "Stops the server after this long with nobody online, freeing memory and CPU. The next player to join wakes it.",
+2 -1
View File
@@ -178,7 +178,8 @@
"luckperms_rcon_output": "RCON 控制台输出",
"luckperms_clear_history": "清除历史记录",
"edit_server_cpu": "CPU 限制",
"edit_server_cpu_placeholder": "例如 1, 2, 500m",
"edit_server_cpu_placeholder": "例如 1、1.5、500m,留空不限制",
"edit_server_cpu_invalid": "填核数(1、1.5)或毫核(500m),要大于 0。",
"owned_filter_mine": "我",
"edit_server_idle": "空闲自动停服",
"edit_server_idle_hint": "没人在线达到这个时长就自动停服,省下内存和 CPU;玩家下次进服时自动唤醒。",
+3
View File
@@ -553,6 +553,7 @@ export const api = rejectingSync({
),
patchServer: (name: string, req: {
/** Trimmed server-side; an empty one clears it. */
displayName?: string;
autostartPolicy?: AutostartPolicy;
image?: string;
@@ -562,6 +563,8 @@ export const api = rejectingSync({
memory?: string;
/** Idle auto-stop: 0 turns it off, else seconds empty before the stop (60–86400). */
idleStopSeconds?: number;
/** Single fields laid over the server's pod block: a field left out keeps its
* value, and an empty cpu or cpuRequest removes that limit or request. */
resources?: {
cpu?: string;
cpuRequest?: string;
+6
View File
@@ -2339,7 +2339,10 @@ export interface components {
playersMax: number;
displayName?: string;
image?: string;
/** @description The JVM heap (-Xmx) derived from the memory limit. */
javaMemory?: string;
/** @description The pod memory limit (the server's memory as an admin picks it), a Kubernetes quantity such as 4Gi. */
memory?: string;
storageSize?: string;
cpu?: string;
/**
@@ -7685,15 +7688,18 @@ export interface operations {
requestBody: {
content: {
"application/json": {
/** @description Trimmed. An empty name clears it, and the server goes by its name again. */
displayName?: string;
autostartPolicy?: string;
/** @description Re-admitted against the whitelist (a pinned name:tag@sha256:… ref is admitted by its name:tag) and pinned like create does. A pin equal to the current image is no change; any other needs confirmImageChange. */
image?: string;
/** @description Acknowledges that the new image opens the world with its Minecraft version, whose chunk upgrades the old one cannot read. Without it an image that would move the server is refused with 409 image_change_unconfirmed. The audit row records image_from/image_to. */
confirmImageChange?: boolean;
/** @description Sets the memory limit and request together, as create does, and re-derives the JVM heap. The CPU limit and request are kept. */
memory?: string;
/** @description Rejected with 400 storage_immutable — present for a clear error, not mutation. */
storage?: string;
/** @description Single fields of the pod block, laid over what the server has: a field left out keeps its value, so each may be sent alone. An empty cpu or cpuRequest removes that limit or request; the memory ceiling cannot be emptied. A request above its limit is a 400. */
resources?: {
cpu?: string;
cpuRequest?: string;
+3
View File
@@ -59,7 +59,10 @@ export interface ServerStatus {
playersMax: number;
displayName?: string;
image?: string;
/** The JVM heap derived from `memory`. */
javaMemory?: string;
/** The pod memory limit — the server's memory as an admin picks it (e.g. "4Gi"). */
memory?: string;
storageSize?: string;
cpu?: string;
/** Seconds empty before idle auto-stop; 0 when the server never idles out. */
+19 -1
View File
@@ -1,10 +1,11 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { ServerConsole } from "./ServerConsole";
const calls = vi.hoisted(() => ({ status: vi.fn(), myServers: vi.fn() }));
const calls = vi.hoisted(() => ({ status: vi.fn(), myServers: vi.fn(), listImages: vi.fn() }));
vi.mock("@/lib/tier", () => ({
useTier: () => ({
loading: false,
@@ -31,6 +32,8 @@ beforeEach(() => {
calls.status.mockReset();
calls.myServers.mockReset();
calls.myServers.mockResolvedValue([]);
calls.listImages.mockReset();
calls.listImages.mockResolvedValue([]);
});
function renderConsole() {
@@ -76,3 +79,18 @@ describe("ServerConsole failed start", () => {
expect(screen.queryByRole("button", { name: /Retry start/ })).toBeNull();
});
});
describe("ServerConsole edit dialog", () => {
it("offers the server's memory limit as the current memory, not the JVM heap", async () => {
calls.status.mockResolvedValue(
status({ phase: "Stopped", desiredState: "Stopped", memory: "4Gi", javaMemory: "3072M", cpu: "2" }),
);
renderConsole();
await userEvent.setup().click(await screen.findByRole("button", { name: /Edit Server Config/ }));
const memory = await screen.findByRole("combobox", { name: "Memory" });
expect(within(memory).getByText("4Gi")).toBeTruthy();
expect(screen.queryByText("3072M")).toBeNull();
expect((screen.getByLabelText("CPU Limit") as HTMLInputElement).value).toBe("2");
});
});
+1 -1
View File
@@ -308,7 +308,7 @@ export function ServerConsole() {
currentDisplayName={data.displayName}
currentPolicy={data.autostartPolicy as AutostartPolicy}
currentImage={data.image}
currentMemory={data.javaMemory}
currentMemory={data.memory}
currentStorage={data.storageSize}
currentCpu={data.cpu}
currentIdleStopSeconds={data.idleStopSeconds}