diff --git a/api/docs/openapi.yaml b/api/docs/openapi.yaml index 89fb097f3..9694ce52f 100644 --- a/api/docs/openapi.yaml +++ b/api/docs/openapi.yaml @@ -512,6 +512,11 @@ paths: responses: '200': description: OK + '/servers/{name}/template': + get: + responses: + '200': + description: OK '/servers/{name}:capture': delete: responses: diff --git a/api/internal/handlers/config_redact_revision_test.go b/api/internal/handlers/config_redact_revision_test.go index a26209168..df05e780a 100644 --- a/api/internal/handlers/config_redact_revision_test.go +++ b/api/internal/handlers/config_redact_revision_test.go @@ -66,6 +66,27 @@ func catalogWithoutPasswords() *unstructured.Unstructured { return tmpl } +func TestServerTemplateUsesAdmittedSchema(t *testing.T) { + k := fakeKubeClient(catalogWithoutPasswords()) + gs := serverWithConfig("alpha", plainConfig()) + tmpl := redactTemplate() + pinRedactionTemplate(t, k, gs, tmpl) + if _, err := k.Dynamic.Resource(kube.GVRs["servers"]).Namespace(redactNS).Create(t.Context(), gs, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + router := mountResourcesRouter(k) + if denied := do(t, router, http.MethodGet, "/servers/alpha/template", nil); denied.Code != http.StatusForbidden { + t.Fatalf("template read without permission: %d", denied.Code) + } + req := httptest.NewRequestWithContext(auth.WithUser(t.Context(), inventoryUser("local", "*", "templates:read")), http.MethodGet, "/servers/alpha/template", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + var got map[string]any + if rr.Code != http.StatusOK || json.Unmarshal(rr.Body.Bytes(), &got) != nil || !reflect.DeepEqual(got["spec"], tmpl.Object["spec"]) { + t.Fatalf("unexpected effective template: %d %s", rr.Code, rr.Body) + } +} + func TestRedact_PinnedTemplateSurvivesCatalogPasswordRemoval(t *testing.T) { k := fakeKubeClient(catalogWithoutPasswords()) gs := serverWithConfig("alpha", plainConfig()) diff --git a/api/internal/handlers/resources.go b/api/internal/handlers/resources.go index 4477a4612..404ced592 100644 --- a/api/internal/handlers/resources.go +++ b/api/internal/handlers/resources.go @@ -59,11 +59,43 @@ func mountOne(r chi.Router, reg *kube.Registry, store *db.Store, path string, gv r.Get("/", listHandler(reg, gvr)) r.Post("/", createHandler(reg, gvr)) r.Get("/{name}", getHandler(reg, gvr)) + if path == "servers" { + r.Get("/{name}/template", effectiveTemplateHandler(reg)) + } r.Put("/{name}", updateHandler(reg, gvr)) r.Delete("/{name}", deleteHandler(reg, store, gvr)) }) } +// Effective templates can differ from the catalog while a server's rollout +// is pending. Resolve the snapshot through its authorized server identity. +func effectiveTemplateHandler(reg *kube.Registry) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + if !auth.UserFromContext(req.Context()).Can("templates:read", false, scope.RequestedCluster(req), "") { + httperr.WriteMessage(w, req, messages.New("server.forbidden", nil)) + return + } + k, ok := resolveCluster(w, req, reg) + if !ok { + return + } + ns, ok := resolveNS(w, req) + if !ok { + return + } + server, ok := authorizedServer(w, req, k, ns, chi.URLParam(req, "name")) + if !ok { + return + } + tmpl, err := k.EffectiveGameTemplate(req.Context(), server) + if err != nil { + httperr.Write(w, req, err) + return + } + writeJSON(w, tmpl.Object) + } +} + // resolveNS validates the namespace query parameter and writes a 403 // response when the caller is not permitted to use it. ok=false means // the caller should stop processing and return. diff --git a/api/specs.md b/api/specs.md index 469452d18..b705c2588 100644 --- a/api/specs.md +++ b/api/specs.md @@ -661,6 +661,7 @@ Foreign keys are enforced only on Postgres (modernc-sqlite runs with FK OFF); th ### GameServer config password redaction - Every covered response omits `metadata.annotations["kubectl.kubernetes.io/last-applied-configuration"]`: this historical manifest can contain passwords absent from the current config or template. Omission applies even when current config is absent, empty, or malformed; stored Kubernetes metadata and other annotations remain unchanged. `GameServer.spec.config` stores wizard values in clear, including those of `type: password` fields in the template's `configSchema`. Anyone holding `servers:read` (or the owner/collaborator fallback) could therefore read every password. The API now redacts them; implementation and tests are in `api/internal/handlers/config_redact.go` and `config_redact_test.go`. +- **Effective template.** `GET /servers/{name}/template` reads the server's verified, admitted snapshot or the installed template before its first pin. It requires read access to the exact server and cluster-wide `templates:read` on the target cluster. - **Marker:** `__gameplane_redacted__`. On reads, each non-empty password-type value is replaced by the marker (an empty value stays empty so clients can show "not set"). The password-field set comes from the server's effective `GameTemplate.spec.configSchema`. When `gameplane.local/effective-template-revision` is set, the API reads the server's immutable snapshot from the selected cluster and namespace, verifies its controller owner, identity, digest and template name, and rejects deleting snapshots. The exact stored JSON must match the pinned SHA-256 digest. An OnRequest server keeps its admitted password rules even when the catalog removes or retypes those fields. Servers without a pin use the current catalog. Rules are memoised only within one request or SSE event, keyed by template, namespace, server name, UID and revision. - **Covered paths (one helper):** GET/POST/PUT `/servers` and `/servers/{name}` (including the create and update replies), `POST /servers/{name}:clone` reply (the clone itself stores the source's values verbatim), `GET /users/me/servers`, `GET /fleet/servers`, and the `/events` SSE stream (each watch event is redacted on a copy). Share links expose only name/status/address/player count; capture, mod, tunnel-credential, notification and WebSocket paths never carry `spec.config`; the audit log and its sinks record actor/method/path/target/status/ip/reason only and never request bodies. - **Fail closed:** when the template cannot be read (no `templateRef`, not found, API error, malformed `configSchema`), or a pinned snapshot is missing, unreadable or invalid, every non-empty config value is redacted. A failed pin lookup never falls back to the latest catalog. The API needs `get` on ConfigMaps in each allowed game namespace; the chart grants it through the games Role, and remote kubeconfigs need the same permission. Failed lookups are not cached. PUT secret preservation uses this same effective schema. diff --git a/assets/design-export/MANIFEST.md b/assets/design-export/MANIFEST.md index b7e0ccd34..364c61094 100644 --- a/assets/design-export/MANIFEST.md +++ b/assets/design-export/MANIFEST.md @@ -2874,3 +2874,9 @@ Nodes now follows the Backups toolbar: a plain search input, capped at 320px on All seven roots have JSON from Pencil `Get` and 2× PNG from `Export`. Toolbar, popover, desktop, fixed-cluster and mobile layouts were inspected using `TakeScreenshot`; the full screens retain their previous dimensions and content. JSON exports retain component references without elided children or editing placeholders. No React source, tests or lint suites were changed or run for this design update. +## Minecraft JVM startup settings + +| Screen ID | State | +| --- | --- | +| `KJdxg` | Saved JVM options apply on the next start. Both heap arguments appear below the fields. | +| `ZUAq1` | A 4 GiB limit and a 2G maximum make the automatic 3072M initial heap invalid. The adoption notice and disabled Save button are visible. | diff --git a/assets/design-export/json/KJdxg.json b/assets/design-export/json/KJdxg.json new file mode 100644 index 000000000..dc82c830a --- /dev/null +++ b/assets/design-export/json/KJdxg.json @@ -0,0 +1,572 @@ +{ + "type": "frame", + "id": "KJdxg", + "x": 9740, + "y": 40770, + "name": "Screen/Server Detail — Settings · Minecraft JVM startup", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 980, + "fill": "$background/background", + "children": [ + { + "id": "ytgbe", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "i8Z1B": { + "fill": "$accent/soft" + }, + "i8Z1B/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "i8Z1B/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "UDkXl", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "tsJw8", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "mc-survival" + } + } + }, + { + "type": "frame", + "id": "l1VGcA", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "fobFz", + "type": "ref", + "ref": "S4k0x", + "name": "Detail Header", + "width": "fill_container" + }, + { + "id": "yJwJ0", + "type": "ref", + "ref": "I9kvlZ", + "name": "Tabs Bar", + "width": "fill_container", + "padding": [ + 0, + 0, + 0, + 24 + ], + "descendants": { + "Wp6q1": { + "fill": "#00000000" + }, + "Wp6q1/YUR8N": { + "fill": "$muted" + }, + "Y4QES": { + "fill": "$segment" + }, + "Y4QES/VcxMI": { + "fill": "$foreground/segment" + } + } + }, + { + "type": "frame", + "id": "rZRLg", + "name": "Settings Content", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "id": "NNqua", + "type": "ref", + "ref": "EV8mp", + "name": "Settings Sub Nav", + "width": 224, + "height": "fill_container", + "descendants": { + "oXEaP": { + "fill": "#00000000" + }, + "K2QiS9": { + "fill": "#000000" + }, + "KfnMt": { + "fill": "$muted" + }, + "axMXL": { + "fill": "#00000000" + }, + "P8u2Kj": { + "fill": "$muted" + }, + "VQPTx": { + "fill": "$muted" + }, + "p3HCVj": { + "fill": "$surface/secondary" + }, + "nbQNd": { + "fill": "$foreground/foreground" + }, + "nnoXy": { + "fill": "#00000000" + }, + "F1IWTf": { + "fill": "$muted" + }, + "bHNH0": { + "fill": "$muted" + }, + "QrhqW": { + "fill": "#00000000" + }, + "b0WSG": { + "fill": "$muted" + }, + "J4128": { + "fill": "$muted" + }, + "DnXAN": { + "fill": "#00000000" + }, + "RK6jY": { + "fill": "$muted" + }, + "rWM9D": { + "fill": "$muted" + }, + "tv3iR": { + "fill": "#00000000" + }, + "nfFt4": { + "fill": "$muted" + }, + "Y2PGH": { + "fill": "$muted" + }, + "bLFiu": { + "fill": "#00000000" + }, + "S7IwYB": { + "fill": "$muted" + }, + "IUddK": { + "fill": "$muted" + }, + "hNpsj": { + "fill": "#00000000" + }, + "lU1pD": { + "fill": "$muted" + }, + "Fkfhz": { + "fill": "$muted" + }, + "faW0l": { + "fill": "#00000000" + }, + "dDJ3f": { + "fill": "$muted" + }, + "v3XCDT": { + "fill": "$muted" + }, + "LktID": { + "fill": "#00000000" + }, + "OdmLx": { + "fill": "$muted" + }, + "paOZA": { + "fill": "$muted" + }, + "JlFDJ": { + "fill": "#00000000" + }, + "Hw7vh": { + "fill": "$muted" + }, + "Qbxnz": { + "fill": "$muted" + }, + "WLobW": { + "fill": "#00000000" + }, + "zpxa4": { + "fill": "$danger/danger" + }, + "xlVdh": { + "fill": "$danger/danger" + } + } + }, + { + "type": "frame", + "id": "m5ZN39", + "name": "Settings Panel Wrap", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "gv4BL", + "name": "Settings Form", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "PadmY", + "name": "Form Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 20, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "Ik51N", + "name": "Section Header", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "Y7DKmh", + "name": "Section Title", + "fill": "$foreground/foreground", + "content": "Game configuration", + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "JUzzO", + "name": "Section Description", + "fill": "$muted", + "content": "Settings from this server's game template.", + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "id": "wiDPc", + "type": "ref", + "ref": "uJ3KZ", + "name": "Restart Alert", + "width": "fill_container", + "descendants": { + "xzGxP": { + "content": "These configuration changes apply at the next container start." + }, + "yLGJ9": { + "enabled": false, + "width": 240 + } + } + }, + { + "type": "frame", + "id": "hdVkN", + "name": "JVM startup settings", + "width": "fill_container", + "cornerRadius": 12, + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 14, + "padding": 16, + "children": [ + { + "type": "text", + "id": "KoaG5", + "name": "Startup section title", + "fill": "$foreground/foreground", + "content": "JVM startup", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "etNiM", + "name": "JVM max heap", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "VY24T", + "name": "JVM max heap label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "JVM max heap", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "B5n12", + "type": "ref", + "ref": "D0cDM", + "name": "JVM max heap control", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "2G" + } + } + } + ] + }, + { + "type": "frame", + "id": "tRonq", + "name": "JVM initial heap", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "RHqzA", + "name": "JVM initial heap label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "JVM initial heap", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "tFclr", + "type": "ref", + "ref": "D0cDM", + "name": "JVM initial heap control", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "1536M" + } + } + } + ] + }, + { + "type": "frame", + "id": "mZXbs", + "name": "Extra JVM options", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qrks1", + "name": "Extra JVM options label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "Additional JVM options", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "efbEq", + "type": "ref", + "ref": "D0cDM", + "name": "Extra JVM options control", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "-Dexample=true" + } + } + } + ] + }, + { + "type": "frame", + "id": "PC16s", + "name": "Extra JVM -XX options", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "JXPIG", + "name": "Extra JVM -XX options label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "Additional JVM tuning options", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "B3Wm1", + "type": "ref", + "ref": "D0cDM", + "name": "Extra JVM -XX options control", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "" + } + } + } + ] + }, + { + "type": "frame", + "id": "R0AkRu", + "name": "Use Aikar’s flags", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "GaccA", + "name": "Use Aikar’s flags label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "Use Aikar’s flags", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "w4MZ11", + "type": "ref", + "ref": "AT7ya", + "name": "Use Aikar’s flags control", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "false" + } + } + } + ] + }, + { + "type": "text", + "id": "p1NxFe", + "name": "Heap argument preview", + "fill": "$muted", + "content": "Heap options before additional JVM arguments: -Xms1536M -Xmx2G", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "P7pcNN", + "name": "Form Footer", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "top": 1 + }, + "strokeAlignment": "inner", + "strokeLinejoin": "round", + "gap": 12, + "padding": [ + 12, + 24 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "id": "Soyh3", + "type": "ref", + "ref": "J09iP", + "name": "Btn Discard", + "descendants": { + "eWkIT/WtPjS": { + "icon": "pencil" + } + } + }, + { + "id": "WjBDT", + "type": "ref", + "ref": "z9ShNE", + "name": "Btn Save Changes", + "descendants": { + "nWTJb": { + "enabled": false + }, + "IVgQ8/U1xvSo": { + "content": "Save changes" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/assets/design-export/json/ZUAq1.json b/assets/design-export/json/ZUAq1.json new file mode 100644 index 000000000..7e75ceae1 --- /dev/null +++ b/assets/design-export/json/ZUAq1.json @@ -0,0 +1,584 @@ +{ + "type": "frame", + "id": "ZUAq1", + "x": 11280, + "y": 40770, + "name": "Screen/Server Detail — Settings · Minecraft JVM heap validation", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 980, + "fill": "$background/background", + "children": [ + { + "id": "cbpYZ", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "i8Z1B": { + "fill": "$accent/soft" + }, + "i8Z1B/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "i8Z1B/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "sP4Mp", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "DIdDB", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "mc-survival" + } + } + }, + { + "type": "frame", + "id": "Sr0hy", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "MvP6f", + "type": "ref", + "ref": "S4k0x", + "name": "Detail Header", + "width": "fill_container" + }, + { + "id": "RER4g", + "type": "ref", + "ref": "I9kvlZ", + "name": "Tabs Bar", + "width": "fill_container", + "padding": [ + 0, + 0, + 0, + 24 + ], + "descendants": { + "Wp6q1": { + "fill": "#00000000" + }, + "Wp6q1/YUR8N": { + "fill": "$muted" + }, + "Y4QES": { + "fill": "$segment" + }, + "Y4QES/VcxMI": { + "fill": "$foreground/segment" + } + } + }, + { + "type": "frame", + "id": "Thzp3", + "name": "Settings Content", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "id": "Q8YcZN", + "type": "ref", + "ref": "EV8mp", + "name": "Settings Sub Nav", + "width": 224, + "height": "fill_container", + "descendants": { + "oXEaP": { + "fill": "#00000000" + }, + "K2QiS9": { + "fill": "#000000" + }, + "KfnMt": { + "fill": "$muted" + }, + "axMXL": { + "fill": "#00000000" + }, + "P8u2Kj": { + "fill": "$muted" + }, + "VQPTx": { + "fill": "$muted" + }, + "p3HCVj": { + "fill": "$surface/secondary" + }, + "nbQNd": { + "fill": "$foreground/foreground" + }, + "nnoXy": { + "fill": "#00000000" + }, + "F1IWTf": { + "fill": "$muted" + }, + "bHNH0": { + "fill": "$muted" + }, + "QrhqW": { + "fill": "#00000000" + }, + "b0WSG": { + "fill": "$muted" + }, + "J4128": { + "fill": "$muted" + }, + "DnXAN": { + "fill": "#00000000" + }, + "RK6jY": { + "fill": "$muted" + }, + "rWM9D": { + "fill": "$muted" + }, + "tv3iR": { + "fill": "#00000000" + }, + "nfFt4": { + "fill": "$muted" + }, + "Y2PGH": { + "fill": "$muted" + }, + "bLFiu": { + "fill": "#00000000" + }, + "S7IwYB": { + "fill": "$muted" + }, + "IUddK": { + "fill": "$muted" + }, + "hNpsj": { + "fill": "#00000000" + }, + "lU1pD": { + "fill": "$muted" + }, + "Fkfhz": { + "fill": "$muted" + }, + "faW0l": { + "fill": "#00000000" + }, + "dDJ3f": { + "fill": "$muted" + }, + "v3XCDT": { + "fill": "$muted" + }, + "LktID": { + "fill": "#00000000" + }, + "OdmLx": { + "fill": "$muted" + }, + "paOZA": { + "fill": "$muted" + }, + "JlFDJ": { + "fill": "#00000000" + }, + "Hw7vh": { + "fill": "$muted" + }, + "Qbxnz": { + "fill": "$muted" + }, + "WLobW": { + "fill": "#00000000" + }, + "zpxa4": { + "fill": "$danger/danger" + }, + "xlVdh": { + "fill": "$danger/danger" + } + } + }, + { + "type": "frame", + "id": "x4sGE", + "name": "Settings Panel Wrap", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "T8eOy2", + "name": "Settings Form", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "zjzIl", + "name": "Form Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 20, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "Q8tg6o", + "name": "Section Header", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "YrXRN", + "name": "Section Title", + "fill": "$foreground/foreground", + "content": "Game configuration", + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "OvbzJ", + "name": "Section Description", + "fill": "$muted", + "content": "Settings from this server's game template.", + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "id": "IdvNa", + "type": "ref", + "ref": "uJ3KZ", + "name": "Restart Alert", + "width": "fill_container", + "descendants": { + "xzGxP": { + "content": "The server may restart once when it adopts these startup settings." + }, + "yLGJ9": { + "enabled": false, + "width": 240 + } + } + }, + { + "type": "frame", + "id": "Ai0ep", + "name": "JVM startup settings", + "width": "fill_container", + "cornerRadius": 12, + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 14, + "padding": 16, + "children": [ + { + "type": "text", + "id": "wWVuf", + "name": "Startup section title", + "fill": "$foreground/foreground", + "content": "JVM startup", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "O6oAHX", + "name": "JVM max heap", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "U7Bjg", + "name": "JVM max heap label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "JVM max heap", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "Q18G7", + "type": "ref", + "ref": "D0cDM", + "name": "JVM max heap control", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "2G" + } + } + } + ] + }, + { + "type": "frame", + "id": "dx3Hq", + "name": "JVM initial heap", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "lwnsm", + "name": "JVM initial heap label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "JVM initial heap", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "EFCOd", + "type": "ref", + "ref": "D0cDM", + "name": "JVM initial heap control", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "Automatic", + "fill": "$field/placeholder" + } + } + } + ] + }, + { + "type": "text", + "id": "x8uhMt", + "name": "Initial heap validation error", + "fill": "$danger/danger", + "content": "The initial heap exceeds the maximum heap. Lower the initial heap or raise the maximum.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "pTPiO", + "name": "Extra JVM options", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "TFGAI", + "name": "Extra JVM options label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "Additional JVM options", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "dutXY", + "type": "ref", + "ref": "D0cDM", + "name": "Extra JVM options control", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "-Dexample=true" + } + } + } + ] + }, + { + "type": "frame", + "id": "HKnqx", + "name": "Extra JVM -XX options", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Th6la", + "name": "Extra JVM -XX options label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "Additional JVM tuning options", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "BxJ9b", + "type": "ref", + "ref": "D0cDM", + "name": "Extra JVM -XX options control", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "" + } + } + } + ] + }, + { + "type": "frame", + "id": "HboUw", + "name": "Use Aikar’s flags", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "jf14n", + "name": "Use Aikar’s flags label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "Use Aikar’s flags", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "WVVYM", + "type": "ref", + "ref": "AT7ya", + "name": "Use Aikar’s flags control", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "false" + } + } + } + ] + }, + { + "type": "text", + "id": "Hulio", + "name": "Heap argument preview", + "fill": "$muted", + "content": "Heap options before additional JVM arguments: -Xms3072M -Xmx2G", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Y5gkTT", + "name": "Form Footer", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "top": 1 + }, + "strokeAlignment": "inner", + "strokeLinejoin": "round", + "gap": 12, + "padding": [ + 12, + 24 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "id": "axDSN", + "type": "ref", + "ref": "J09iP", + "name": "Btn Discard", + "descendants": { + "eWkIT/WtPjS": { + "icon": "pencil" + } + } + }, + { + "id": "ElsA3", + "type": "ref", + "ref": "z9ShNE", + "name": "Btn Save Changes", + "opacity": 0.45, + "descendants": { + "nWTJb": { + "enabled": false + }, + "IVgQ8/U1xvSo": { + "content": "Save changes" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/assets/design-export/screenshots/KJdxg.png b/assets/design-export/screenshots/KJdxg.png new file mode 100644 index 000000000..fd3f98b6a Binary files /dev/null and b/assets/design-export/screenshots/KJdxg.png differ diff --git a/assets/design-export/screenshots/ZUAq1.png b/assets/design-export/screenshots/ZUAq1.png new file mode 100644 index 000000000..0bdf94434 Binary files /dev/null and b/assets/design-export/screenshots/ZUAq1.png differ diff --git a/changelog/unreleased/938-jvm-startup.md b/changelog/unreleased/938-jvm-startup.md new file mode 100644 index 000000000..9a0745f19 --- /dev/null +++ b/changelog/unreleased/938-jvm-startup.md @@ -0,0 +1,7 @@ +# Stage game settings for the next start + +Template authors can set `applyOnRestart` on environment fields. Saving or clearing +these fields leaves the running Pod in place. Adopting the updated template can +restart an existing server once. Minecraft Java settings now preview the initial +and maximum heap independently and show when saved changes will take effect. +[Issue #938](https://github.com/GameplanePanel/Gameplane/issues/938) diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml index 63d29c4f4..902727686 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backups.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 3b2e34cfb50a4b2312502522937d45442c012cb313195cd2a556f69af01f3a96 + gameplane.local/crd-bundle-sha256: da17248ed447b9fe58cd6e6405832175415d7aa151cfc612127f9c2eec5f104b controller-gen.kubebuilder.io/version: v0.22.0 name: backups.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml index 4beb31166..7e25e2475 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_backupschedules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 3b2e34cfb50a4b2312502522937d45442c012cb313195cd2a556f69af01f3a96 + gameplane.local/crd-bundle-sha256: da17248ed447b9fe58cd6e6405832175415d7aa151cfc612127f9c2eec5f104b controller-gen.kubebuilder.io/version: v0.22.0 name: backupschedules.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml index 18b3b8f55..d292f0f74 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_clusters.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 3b2e34cfb50a4b2312502522937d45442c012cb313195cd2a556f69af01f3a96 + gameplane.local/crd-bundle-sha256: da17248ed447b9fe58cd6e6405832175415d7aa151cfc612127f9c2eec5f104b controller-gen.kubebuilder.io/version: v0.22.0 name: clusters.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml index c9f4e670b..8c90d24da 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gameservers.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 3b2e34cfb50a4b2312502522937d45442c012cb313195cd2a556f69af01f3a96 + gameplane.local/crd-bundle-sha256: da17248ed447b9fe58cd6e6405832175415d7aa151cfc612127f9c2eec5f104b controller-gen.kubebuilder.io/version: v0.22.0 name: gameservers.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml index fb1cca85b..c358c2824 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_gametemplates.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 3b2e34cfb50a4b2312502522937d45442c012cb313195cd2a556f69af01f3a96 + gameplane.local/crd-bundle-sha256: da17248ed447b9fe58cd6e6405832175415d7aa151cfc612127f9c2eec5f104b controller-gen.kubebuilder.io/version: v0.22.0 name: gametemplates.gameplane.local spec: @@ -1091,6 +1091,13 @@ spec: description: ConfigField is a single user-tunable setting surfaced in the wizard. properties: + applyOnRestart: + description: |- + ApplyOnRestart stages this environment value for the next container start. + Edits and clears update the config Secret without rolling the game Pod. + Config files that reference the field are refreshed by their init container + on the next Pod start. Other fields keep their usual rollout behavior. + type: boolean autoFromMemoryLimit: description: |- AutoFromMemoryLimit, when set, computes this field's value from the @@ -1191,6 +1198,10 @@ spec: - name - type type: object + x-kubernetes-validations: + - message: applyOnRestart requires an environment field + rule: '!has(self.applyOnRestart) || !self.applyOnRestart || !has(self.target) + || self.target == ''env''' type: array consoleMode: description: |- diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml index 5d74582b1..42f8e93c1 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 3b2e34cfb50a4b2312502522937d45442c012cb313195cd2a556f69af01f3a96 + gameplane.local/crd-bundle-sha256: da17248ed447b9fe58cd6e6405832175415d7aa151cfc612127f9c2eec5f104b controller-gen.kubebuilder.io/version: v0.22.0 name: modules.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml index 8cf33a5ec..07e8fc19a 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_modulesources.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 3b2e34cfb50a4b2312502522937d45442c012cb313195cd2a556f69af01f3a96 + gameplane.local/crd-bundle-sha256: da17248ed447b9fe58cd6e6405832175415d7aa151cfc612127f9c2eec5f104b controller-gen.kubebuilder.io/version: v0.22.0 name: modulesources.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml index f2cd36c44..c90b7517f 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_networkcaptures.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 3b2e34cfb50a4b2312502522937d45442c012cb313195cd2a556f69af01f3a96 + gameplane.local/crd-bundle-sha256: da17248ed447b9fe58cd6e6405832175415d7aa151cfc612127f9c2eec5f104b controller-gen.kubebuilder.io/version: v0.22.0 name: networkcaptures.gameplane.local spec: diff --git a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml index d2f47f0ec..ffc1ba6f9 100644 --- a/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml +++ b/charts/gameplane/charts/gameplane-crds/crds/gameplane.local_restores.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 3b2e34cfb50a4b2312502522937d45442c012cb313195cd2a556f69af01f3a96 + gameplane.local/crd-bundle-sha256: da17248ed447b9fe58cd6e6405832175415d7aa151cfc612127f9c2eec5f104b controller-gen.kubebuilder.io/version: v0.22.0 name: restores.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_backups.yaml b/charts/gameplane/crd-manifests/gameplane.local_backups.yaml index 63d29c4f4..902727686 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_backups.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_backups.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 3b2e34cfb50a4b2312502522937d45442c012cb313195cd2a556f69af01f3a96 + gameplane.local/crd-bundle-sha256: da17248ed447b9fe58cd6e6405832175415d7aa151cfc612127f9c2eec5f104b controller-gen.kubebuilder.io/version: v0.22.0 name: backups.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml b/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml index 4beb31166..7e25e2475 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_backupschedules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 3b2e34cfb50a4b2312502522937d45442c012cb313195cd2a556f69af01f3a96 + gameplane.local/crd-bundle-sha256: da17248ed447b9fe58cd6e6405832175415d7aa151cfc612127f9c2eec5f104b controller-gen.kubebuilder.io/version: v0.22.0 name: backupschedules.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml b/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml index 18b3b8f55..d292f0f74 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_clusters.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 3b2e34cfb50a4b2312502522937d45442c012cb313195cd2a556f69af01f3a96 + gameplane.local/crd-bundle-sha256: da17248ed447b9fe58cd6e6405832175415d7aa151cfc612127f9c2eec5f104b controller-gen.kubebuilder.io/version: v0.22.0 name: clusters.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml b/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml index c9f4e670b..8c90d24da 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_gameservers.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 3b2e34cfb50a4b2312502522937d45442c012cb313195cd2a556f69af01f3a96 + gameplane.local/crd-bundle-sha256: da17248ed447b9fe58cd6e6405832175415d7aa151cfc612127f9c2eec5f104b controller-gen.kubebuilder.io/version: v0.22.0 name: gameservers.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml b/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml index fb1cca85b..c358c2824 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_gametemplates.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 3b2e34cfb50a4b2312502522937d45442c012cb313195cd2a556f69af01f3a96 + gameplane.local/crd-bundle-sha256: da17248ed447b9fe58cd6e6405832175415d7aa151cfc612127f9c2eec5f104b controller-gen.kubebuilder.io/version: v0.22.0 name: gametemplates.gameplane.local spec: @@ -1091,6 +1091,13 @@ spec: description: ConfigField is a single user-tunable setting surfaced in the wizard. properties: + applyOnRestart: + description: |- + ApplyOnRestart stages this environment value for the next container start. + Edits and clears update the config Secret without rolling the game Pod. + Config files that reference the field are refreshed by their init container + on the next Pod start. Other fields keep their usual rollout behavior. + type: boolean autoFromMemoryLimit: description: |- AutoFromMemoryLimit, when set, computes this field's value from the @@ -1191,6 +1198,10 @@ spec: - name - type type: object + x-kubernetes-validations: + - message: applyOnRestart requires an environment field + rule: '!has(self.applyOnRestart) || !self.applyOnRestart || !has(self.target) + || self.target == ''env''' type: array consoleMode: description: |- diff --git a/charts/gameplane/crd-manifests/gameplane.local_modules.yaml b/charts/gameplane/crd-manifests/gameplane.local_modules.yaml index 5d74582b1..42f8e93c1 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_modules.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_modules.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 3b2e34cfb50a4b2312502522937d45442c012cb313195cd2a556f69af01f3a96 + gameplane.local/crd-bundle-sha256: da17248ed447b9fe58cd6e6405832175415d7aa151cfc612127f9c2eec5f104b controller-gen.kubebuilder.io/version: v0.22.0 name: modules.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml b/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml index 8cf33a5ec..07e8fc19a 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_modulesources.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 3b2e34cfb50a4b2312502522937d45442c012cb313195cd2a556f69af01f3a96 + gameplane.local/crd-bundle-sha256: da17248ed447b9fe58cd6e6405832175415d7aa151cfc612127f9c2eec5f104b controller-gen.kubebuilder.io/version: v0.22.0 name: modulesources.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml b/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml index f2cd36c44..c90b7517f 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_networkcaptures.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 3b2e34cfb50a4b2312502522937d45442c012cb313195cd2a556f69af01f3a96 + gameplane.local/crd-bundle-sha256: da17248ed447b9fe58cd6e6405832175415d7aa151cfc612127f9c2eec5f104b controller-gen.kubebuilder.io/version: v0.22.0 name: networkcaptures.gameplane.local spec: diff --git a/charts/gameplane/crd-manifests/gameplane.local_restores.yaml b/charts/gameplane/crd-manifests/gameplane.local_restores.yaml index d2f47f0ec..ffc1ba6f9 100644 --- a/charts/gameplane/crd-manifests/gameplane.local_restores.yaml +++ b/charts/gameplane/crd-manifests/gameplane.local_restores.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - gameplane.local/crd-bundle-sha256: 3b2e34cfb50a4b2312502522937d45442c012cb313195cd2a556f69af01f3a96 + gameplane.local/crd-bundle-sha256: da17248ed447b9fe58cd6e6405832175415d7aa151cfc612127f9c2eec5f104b controller-gen.kubebuilder.io/version: v0.22.0 name: restores.gameplane.local spec: diff --git a/design.pen b/design.pen index 876a2bbb1..93a477730 100644 --- a/design.pen +++ b/design.pen @@ -1,5 +1,5 @@ { - "version": "2.21", + "version": "2.22", "children": [ { "type": "frame", @@ -138272,6 +138272,1162 @@ } } ] + }, + { + "type": "frame", + "id": "KJdxg", + "x": 9740, + "y": 40770, + "name": "Screen/Server Detail — Settings · Minecraft JVM startup", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 980, + "fill": "$background/background", + "children": [ + { + "id": "ytgbe", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "i8Z1B": { + "fill": "$accent/soft" + }, + "i8Z1B/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "i8Z1B/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "UDkXl", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "tsJw8", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "mc-survival" + } + } + }, + { + "type": "frame", + "id": "l1VGcA", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "fobFz", + "type": "ref", + "ref": "S4k0x", + "name": "Detail Header", + "width": "fill_container" + }, + { + "id": "yJwJ0", + "type": "ref", + "ref": "I9kvlZ", + "name": "Tabs Bar", + "width": "fill_container", + "padding": [ + 0, + 0, + 0, + 24 + ], + "descendants": { + "Wp6q1": { + "fill": "#00000000" + }, + "Wp6q1/YUR8N": { + "fill": "$muted" + }, + "Y4QES": { + "fill": "$segment" + }, + "Y4QES/VcxMI": { + "fill": "$foreground/segment" + } + } + }, + { + "type": "frame", + "id": "rZRLg", + "name": "Settings Content", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "id": "NNqua", + "type": "ref", + "ref": "EV8mp", + "name": "Settings Sub Nav", + "width": 224, + "height": "fill_container", + "descendants": { + "oXEaP": { + "fill": "#00000000" + }, + "K2QiS9": { + "fill": "#000000" + }, + "KfnMt": { + "fill": "$muted" + }, + "axMXL": { + "fill": "#00000000" + }, + "P8u2Kj": { + "fill": "$muted" + }, + "VQPTx": { + "fill": "$muted" + }, + "p3HCVj": { + "fill": "$surface/secondary" + }, + "nbQNd": { + "fill": "$foreground/foreground" + }, + "nnoXy": { + "fill": "#00000000" + }, + "F1IWTf": { + "fill": "$muted" + }, + "bHNH0": { + "fill": "$muted" + }, + "QrhqW": { + "fill": "#00000000" + }, + "b0WSG": { + "fill": "$muted" + }, + "J4128": { + "fill": "$muted" + }, + "DnXAN": { + "fill": "#00000000" + }, + "RK6jY": { + "fill": "$muted" + }, + "rWM9D": { + "fill": "$muted" + }, + "tv3iR": { + "fill": "#00000000" + }, + "nfFt4": { + "fill": "$muted" + }, + "Y2PGH": { + "fill": "$muted" + }, + "bLFiu": { + "fill": "#00000000" + }, + "S7IwYB": { + "fill": "$muted" + }, + "IUddK": { + "fill": "$muted" + }, + "hNpsj": { + "fill": "#00000000" + }, + "lU1pD": { + "fill": "$muted" + }, + "Fkfhz": { + "fill": "$muted" + }, + "faW0l": { + "fill": "#00000000" + }, + "dDJ3f": { + "fill": "$muted" + }, + "v3XCDT": { + "fill": "$muted" + }, + "LktID": { + "fill": "#00000000" + }, + "OdmLx": { + "fill": "$muted" + }, + "paOZA": { + "fill": "$muted" + }, + "JlFDJ": { + "fill": "#00000000" + }, + "Hw7vh": { + "fill": "$muted" + }, + "Qbxnz": { + "fill": "$muted" + }, + "WLobW": { + "fill": "#00000000" + }, + "zpxa4": { + "fill": "$danger/danger" + }, + "xlVdh": { + "fill": "$danger/danger" + } + } + }, + { + "type": "frame", + "id": "m5ZN39", + "name": "Settings Panel Wrap", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "gv4BL", + "name": "Settings Form", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "PadmY", + "name": "Form Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 20, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "Ik51N", + "name": "Section Header", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "Y7DKmh", + "name": "Section Title", + "fill": "$foreground/foreground", + "content": "Game configuration", + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "JUzzO", + "name": "Section Description", + "fill": "$muted", + "content": "Settings from this server's game template.", + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "id": "wiDPc", + "type": "ref", + "ref": "uJ3KZ", + "name": "Restart Alert", + "width": "fill_container", + "descendants": { + "xzGxP": { + "content": "These configuration changes apply at the next container start." + }, + "yLGJ9": { + "enabled": false, + "width": 240 + } + } + }, + { + "type": "frame", + "id": "hdVkN", + "name": "JVM startup settings", + "width": "fill_container", + "cornerRadius": 12, + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 14, + "padding": 16, + "children": [ + { + "type": "text", + "id": "KoaG5", + "name": "Startup section title", + "fill": "$foreground/foreground", + "content": "JVM startup", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "etNiM", + "name": "JVM max heap", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "VY24T", + "name": "JVM max heap label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "JVM max heap", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "B5n12", + "type": "ref", + "ref": "D0cDM", + "name": "JVM max heap control", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "2G" + } + } + } + ] + }, + { + "type": "frame", + "id": "tRonq", + "name": "JVM initial heap", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "RHqzA", + "name": "JVM initial heap label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "JVM initial heap", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "tFclr", + "type": "ref", + "ref": "D0cDM", + "name": "JVM initial heap control", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "1536M" + } + } + } + ] + }, + { + "type": "frame", + "id": "mZXbs", + "name": "Extra JVM options", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qrks1", + "name": "Extra JVM options label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "Additional JVM options", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "efbEq", + "type": "ref", + "ref": "D0cDM", + "name": "Extra JVM options control", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "-Dexample=true" + } + } + } + ] + }, + { + "type": "frame", + "id": "PC16s", + "name": "Extra JVM -XX options", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "JXPIG", + "name": "Extra JVM -XX options label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "Additional JVM tuning options", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "B3Wm1", + "type": "ref", + "ref": "D0cDM", + "name": "Extra JVM -XX options control", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "" + } + } + } + ] + }, + { + "type": "frame", + "id": "R0AkRu", + "name": "Use Aikar’s flags", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "GaccA", + "name": "Use Aikar’s flags label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "Use Aikar’s flags", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "w4MZ11", + "type": "ref", + "ref": "AT7ya", + "name": "Use Aikar’s flags control", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "false" + } + } + } + ] + }, + { + "type": "text", + "id": "p1NxFe", + "name": "Heap argument preview", + "fill": "$muted", + "content": "Heap options before additional JVM arguments: -Xms1536M -Xmx2G", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "P7pcNN", + "name": "Form Footer", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "top": 1 + }, + "strokeAlignment": "inner", + "strokeLinejoin": "round", + "gap": 12, + "padding": [ + 12, + 24 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "id": "Soyh3", + "type": "ref", + "ref": "J09iP", + "name": "Btn Discard", + "descendants": { + "eWkIT/WtPjS": { + "icon": "pencil" + } + } + }, + { + "id": "WjBDT", + "type": "ref", + "ref": "z9ShNE", + "name": "Btn Save Changes", + "descendants": { + "nWTJb": { + "enabled": false + }, + "IVgQ8/U1xvSo": { + "content": "Save changes" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ZUAq1", + "x": 11280, + "y": 40770, + "name": "Screen/Server Detail — Settings · Minecraft JVM heap validation", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 980, + "fill": "$background/background", + "children": [ + { + "id": "cbpYZ", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "i8Z1B": { + "fill": "$accent/soft" + }, + "i8Z1B/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "i8Z1B/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "sP4Mp", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "DIdDB", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "mc-survival" + } + } + }, + { + "type": "frame", + "id": "Sr0hy", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "MvP6f", + "type": "ref", + "ref": "S4k0x", + "name": "Detail Header", + "width": "fill_container" + }, + { + "id": "RER4g", + "type": "ref", + "ref": "I9kvlZ", + "name": "Tabs Bar", + "width": "fill_container", + "padding": [ + 0, + 0, + 0, + 24 + ], + "descendants": { + "Wp6q1": { + "fill": "#00000000" + }, + "Wp6q1/YUR8N": { + "fill": "$muted" + }, + "Y4QES": { + "fill": "$segment" + }, + "Y4QES/VcxMI": { + "fill": "$foreground/segment" + } + } + }, + { + "type": "frame", + "id": "Thzp3", + "name": "Settings Content", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "id": "Q8YcZN", + "type": "ref", + "ref": "EV8mp", + "name": "Settings Sub Nav", + "width": 224, + "height": "fill_container", + "descendants": { + "oXEaP": { + "fill": "#00000000" + }, + "K2QiS9": { + "fill": "#000000" + }, + "KfnMt": { + "fill": "$muted" + }, + "axMXL": { + "fill": "#00000000" + }, + "P8u2Kj": { + "fill": "$muted" + }, + "VQPTx": { + "fill": "$muted" + }, + "p3HCVj": { + "fill": "$surface/secondary" + }, + "nbQNd": { + "fill": "$foreground/foreground" + }, + "nnoXy": { + "fill": "#00000000" + }, + "F1IWTf": { + "fill": "$muted" + }, + "bHNH0": { + "fill": "$muted" + }, + "QrhqW": { + "fill": "#00000000" + }, + "b0WSG": { + "fill": "$muted" + }, + "J4128": { + "fill": "$muted" + }, + "DnXAN": { + "fill": "#00000000" + }, + "RK6jY": { + "fill": "$muted" + }, + "rWM9D": { + "fill": "$muted" + }, + "tv3iR": { + "fill": "#00000000" + }, + "nfFt4": { + "fill": "$muted" + }, + "Y2PGH": { + "fill": "$muted" + }, + "bLFiu": { + "fill": "#00000000" + }, + "S7IwYB": { + "fill": "$muted" + }, + "IUddK": { + "fill": "$muted" + }, + "hNpsj": { + "fill": "#00000000" + }, + "lU1pD": { + "fill": "$muted" + }, + "Fkfhz": { + "fill": "$muted" + }, + "faW0l": { + "fill": "#00000000" + }, + "dDJ3f": { + "fill": "$muted" + }, + "v3XCDT": { + "fill": "$muted" + }, + "LktID": { + "fill": "#00000000" + }, + "OdmLx": { + "fill": "$muted" + }, + "paOZA": { + "fill": "$muted" + }, + "JlFDJ": { + "fill": "#00000000" + }, + "Hw7vh": { + "fill": "$muted" + }, + "Qbxnz": { + "fill": "$muted" + }, + "WLobW": { + "fill": "#00000000" + }, + "zpxa4": { + "fill": "$danger/danger" + }, + "xlVdh": { + "fill": "$danger/danger" + } + } + }, + { + "type": "frame", + "id": "x4sGE", + "name": "Settings Panel Wrap", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "T8eOy2", + "name": "Settings Form", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "zjzIl", + "name": "Form Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 20, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "Q8tg6o", + "name": "Section Header", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "YrXRN", + "name": "Section Title", + "fill": "$foreground/foreground", + "content": "Game configuration", + "fontFamily": "$typography/font-sans", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "OvbzJ", + "name": "Section Description", + "fill": "$muted", + "content": "Settings from this server's game template.", + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "id": "IdvNa", + "type": "ref", + "ref": "uJ3KZ", + "name": "Restart Alert", + "width": "fill_container", + "descendants": { + "xzGxP": { + "content": "The server may restart once when it adopts these startup settings." + }, + "yLGJ9": { + "enabled": false, + "width": 240 + } + } + }, + { + "type": "frame", + "id": "Ai0ep", + "name": "JVM startup settings", + "width": "fill_container", + "cornerRadius": 12, + "stroke": "$border/border", + "strokeWidth": 1, + "layout": "vertical", + "gap": 14, + "padding": 16, + "children": [ + { + "type": "text", + "id": "wWVuf", + "name": "Startup section title", + "fill": "$foreground/foreground", + "content": "JVM startup", + "fontFamily": "$typography/font-sans", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "O6oAHX", + "name": "JVM max heap", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "U7Bjg", + "name": "JVM max heap label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "JVM max heap", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "Q18G7", + "type": "ref", + "ref": "D0cDM", + "name": "JVM max heap control", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "2G" + } + } + } + ] + }, + { + "type": "frame", + "id": "dx3Hq", + "name": "JVM initial heap", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "lwnsm", + "name": "JVM initial heap label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "JVM initial heap", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "EFCOd", + "type": "ref", + "ref": "D0cDM", + "name": "JVM initial heap control", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "Automatic", + "fill": "$field/placeholder" + } + } + } + ] + }, + { + "type": "text", + "id": "x8uhMt", + "name": "Initial heap validation error", + "fill": "$danger/danger", + "content": "The initial heap exceeds the maximum heap. Lower the initial heap or raise the maximum.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "pTPiO", + "name": "Extra JVM options", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "TFGAI", + "name": "Extra JVM options label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "Additional JVM options", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "dutXY", + "type": "ref", + "ref": "D0cDM", + "name": "Extra JVM options control", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "-Dexample=true" + } + } + } + ] + }, + { + "type": "frame", + "id": "HKnqx", + "name": "Extra JVM -XX options", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Th6la", + "name": "Extra JVM -XX options label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "Additional JVM tuning options", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "BxJ9b", + "type": "ref", + "ref": "D0cDM", + "name": "Extra JVM -XX options control", + "width": "fill_container", + "descendants": { + "PJERm": { + "content": "" + } + } + } + ] + }, + { + "type": "frame", + "id": "HboUw", + "name": "Use Aikar’s flags", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "jf14n", + "name": "Use Aikar’s flags label", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": 180, + "content": "Use Aikar’s flags", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "id": "WVVYM", + "type": "ref", + "ref": "AT7ya", + "name": "Use Aikar’s flags control", + "width": "fill_container", + "descendants": { + "cFsFk": { + "content": "false" + } + } + } + ] + }, + { + "type": "text", + "id": "Hulio", + "name": "Heap argument preview", + "fill": "$muted", + "content": "Heap options before additional JVM arguments: -Xms3072M -Xmx2G", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Y5gkTT", + "name": "Form Footer", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "top": 1 + }, + "strokeAlignment": "inner", + "strokeLinejoin": "round", + "gap": 12, + "padding": [ + 12, + 24 + ], + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "id": "axDSN", + "type": "ref", + "ref": "J09iP", + "name": "Btn Discard", + "descendants": { + "eWkIT/WtPjS": { + "icon": "pencil" + } + } + }, + { + "id": "ElsA3", + "type": "ref", + "ref": "z9ShNE", + "name": "Btn Save Changes", + "opacity": 0.45, + "descendants": { + "nWTJb": { + "enabled": false + }, + "IVgQ8/U1xvSo": { + "content": "Save changes" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] } ], "themes": { diff --git a/docs/module-authoring.md b/docs/module-authoring.md index 08a2886ec..f1ad87428 100644 --- a/docs/module-authoring.md +++ b/docs/module-authoring.md @@ -713,9 +713,17 @@ configSchema: field stays editable after the server exists. Rules to keep in mind when writing or revising a module: -- Saving a change always restarts the server (the operator re-renders the - pod's environment and config files); there is no "apply on next restart" - mode. +- By default, changing a config value rolls the game Pod. On an environment + field, set `applyOnRestart: true` to stage edits and clears for the next + container start. Kubernetes reads the value from the server's config Secret + each time it starts the container. An unset optional field has no Secret + key. A rendered file may interpolate the same field. The config init + container copies that file at the next Pod start. A container restart within + the same Pod refreshes its environment and keeps its existing files. +- Update the Gameplane CRDs and operator before installing a template that + opts in. Adopting the new environment references on an existing server can + roll the Pod once. See [template rollouts](template-rollouts.md) to schedule + adoption. Unmarked fields and other Pod template edits can still roll it. - Only values that differ from your `default` are stored in the server's `spec.config`; an unset key means "use the default" (or the `autoFromMemoryLimit` value), so changing a default in a later template diff --git a/i18n/locales/en/app.json b/i18n/locales/en/app.json index af51c12f9..ed64643c1 100644 --- a/i18n/locales/en/app.json +++ b/i18n/locales/en/app.json @@ -4339,6 +4339,14 @@ "defaultMessage": "User menu", "description": "Top bar: screen-reader name of the user avatar dropdown menu; not visible on screen" }, + "web.jvm.heap_format": { + "defaultMessage": "Enter a JVM heap size such as 512M or 2G, or a percentage from 0% to 100%.", + "description": "Settings: invalid Minecraft Java heap value" + }, + "web.jvm.heap_order": { + "defaultMessage": "The initial heap exceeds the maximum heap. Lower the initial heap or raise the maximum.", + "description": "Settings: the independently resolved initial Java heap is larger than the maximum heap" + }, "web.lib.errors.request_failed": { "defaultMessage": "request failed", "description": "Fallback error text when a failed request has no usable message" @@ -11075,6 +11083,30 @@ "defaultMessage": "Language", "description": "Accessible name of the compact language menu button in the corner of the sign-in and shared-link cards." }, + "web.settings.config_adoption": { + "defaultMessage": "The server may restart once when it adopts these startup settings.", + "description": "Settings: startup notice before the server has adopted the effective template" + }, + "web.settings.config_mixed": { + "defaultMessage": "Some settings apply at the next container start. Changing other settings restarts the server.", + "description": "Settings: initial notice for a template with both immediate and deferred settings" + }, + "web.settings.config_next_start": { + "defaultMessage": "These configuration changes apply at the next container start.", + "description": "Settings: notice when all edited configuration fields defer application until startup" + }, + "web.settings.config_schema_source": { + "defaultMessage": "Settings from this server's game template.", + "description": "Settings: source of the configuration fields for a server" + }, + "web.settings.jvm_heap_options": { + "defaultMessage": "Heap options before additional JVM arguments: {options}", + "description": "Settings: JVM arguments computed from the initial and maximum heap settings" + }, + "web.settings.jvm_startup": { + "defaultMessage": "JVM startup", + "description": "Settings: heading for Minecraft Java startup options" + }, "web.settings.language.automatic": { "defaultMessage": "Automatic (browser language)", "description": "Language option that follows the browser language on the Theme & Appearance settings page" diff --git a/operator/api/v1alpha1/gametemplate_types.go b/operator/api/v1alpha1/gametemplate_types.go index 2ebcceab1..aee02d01c 100644 --- a/operator/api/v1alpha1/gametemplate_types.go +++ b/operator/api/v1alpha1/gametemplate_types.go @@ -1122,6 +1122,7 @@ type AgentSpec struct { } // ConfigField is a single user-tunable setting surfaced in the wizard. +// +kubebuilder:validation:XValidation:rule="!has(self.applyOnRestart) || !self.applyOnRestart || !has(self.target) || self.target == 'env'",message="applyOnRestart requires an environment field" type ConfigField struct { // Name is the field identifier (also used as an env var when // Target is "env"). @@ -1163,6 +1164,13 @@ type ConfigField struct { // +optional Target string `json:"target,omitempty"` + // ApplyOnRestart stages this environment value for the next container start. + // Edits and clears update the config Secret without rolling the game Pod. + // Config files that reference the field are refreshed by their init container + // on the next Pod start. Other fields keep their usual rollout behavior. + // +optional + ApplyOnRestart bool `json:"applyOnRestart,omitempty"` + // AutoFromMemoryLimit, when set, computes this field's value from the // game container's effective memory limit whenever neither // GameServer.spec.config nor Default provides one: floor(limit × diff --git a/operator/config/crd/gameplane.local_gametemplates.yaml b/operator/config/crd/gameplane.local_gametemplates.yaml index 762373e3e..f9770e06e 100644 --- a/operator/config/crd/gameplane.local_gametemplates.yaml +++ b/operator/config/crd/gameplane.local_gametemplates.yaml @@ -1090,6 +1090,13 @@ spec: description: ConfigField is a single user-tunable setting surfaced in the wizard. properties: + applyOnRestart: + description: |- + ApplyOnRestart stages this environment value for the next container start. + Edits and clears update the config Secret without rolling the game Pod. + Config files that reference the field are refreshed by their init container + on the next Pod start. Other fields keep their usual rollout behavior. + type: boolean autoFromMemoryLimit: description: |- AutoFromMemoryLimit, when set, computes this field's value from the @@ -1190,6 +1197,10 @@ spec: - name - type type: object + x-kubernetes-validations: + - message: applyOnRestart requires an environment field + rule: '!has(self.applyOnRestart) || !self.applyOnRestart || !has(self.target) + || self.target == ''env''' type: array consoleMode: description: |- diff --git a/operator/internal/controller/gameserver_config.go b/operator/internal/controller/gameserver_config.go index 9e3f3aeb7..f915a9b18 100644 --- a/operator/internal/controller/gameserver_config.go +++ b/operator/internal/controller/gameserver_config.go @@ -22,9 +22,7 @@ import ( ) // configHashAnnotation is stamped on the pod template whenever the -// GameServer materializes any config. It covers every resolved value, -// so changes that would otherwise not alter the pod spec (e.g. a -// Secret-backed value) still roll the StatefulSet. +// GameServer materializes config requiring an immediate rollout. const configHashAnnotation = "gameplane.local/config-hash" // materializedConfig is the result of resolving GameServer.spec.config @@ -35,17 +33,19 @@ type materializedConfig struct { // Password fields appear as SecretKeyRef entries into secretData's // Secret rather than carrying the value inline. env []corev1.EnvVar - // secretData holds password-type values destined for the + // secretData holds password and deferred environment values for the // per-server `-config` Secret, keyed by field name. Pod specs // are readable by anyone with pod read access, so secrets must // never appear there as plain env values. secretData map[string][]byte + // Retain the owned Secret even while all its deferred keys are unset. + hasDeferredEnv bool // files holds the rendered configFiles in declaration order. They // live in the per-server `-files` Secret — always a Secret, // never a ConfigMap, because any template may embed password-type // values. files []renderedConfigFile - // hash fingerprints all resolved values (including secret ones) + // hash fingerprints immediate values (including secret ones) // for configHashAnnotation, so Secret-only changes still roll the // StatefulSet. Empty when no config materialized. hash string @@ -113,6 +113,21 @@ func materializeConfig( fileFieldSet := false for _, f := range schema { values[f.Name] = "" + if f.ApplyOnRestart { + if f.Target == "file" { + return nil, fmt.Errorf("config field %q: applyOnRestart requires an environment field", f.Name) + } + mc.hasDeferredEnv = true + // Keep the same reference when an optional value is added or + // cleared. A missing Secret/key leaves that variable unset. + optional := true + mc.env = append(mc.env, corev1.EnvVar{Name: f.Name, ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: configSecretName(gs)}, + Key: f.Name, Optional: &optional, + }, + }}) + } val, set := gs.Spec.Config[f.Name] if !set { val = f.Default @@ -164,6 +179,13 @@ func materializeConfig( } } values[f.Name] = val + if f.ApplyOnRestart { + if mc.secretData == nil { + mc.secretData = map[string][]byte{} + } + mc.secretData[f.Name] = []byte(val) + continue + } hashParts = append(hashParts, f.Name+"="+val) if f.Target == "file" { // File-target values exist only inside rendered @@ -199,8 +221,15 @@ func materializeConfig( return nil, err } mc.files = files - for _, f := range files { - hashParts = append(hashParts, "file:"+f.path+"="+string(f.content)) + for i, f := range files { + content := string(f.content) + if mc.hasDeferredEnv { + // File templates can interpolate deferred fields. Immediate + // values already participate in the hash above; fingerprint the + // recipe so edits to a deferred input alone do not roll the Pod. + content = tmpl.Spec.ConfigFiles[i].Template + } + hashParts = append(hashParts, "file:"+f.path+"="+content) } if len(hashParts) > 0 { sum := sha256.Sum256([]byte(strings.Join(hashParts, "\n"))) @@ -288,16 +317,14 @@ func validateConfigFilePath(p string) error { } // reconcileConfigSecret keeps the per-server `-config` Secret in -// step with the materialized password values, deleting it outright when -// no password fields are in play (same lifecycle as the managed -// BackupSchedule). +// step with password and deferred environment values. func (r *GameServerReconciler) reconcileConfigSecret( ctx context.Context, gs *gameplanev1alpha1.GameServer, mc *materializedConfig, ) error { sec := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: configSecretName(gs), Namespace: gs.Namespace}, } - if len(mc.secretData) == 0 { + if len(mc.secretData) == 0 && !mc.hasDeferredEnv { return r.deleteIfControlledBy(ctx, gs, sec) } _, err := controllerutil.CreateOrUpdate(ctx, r.Client, sec, func() error { diff --git a/operator/internal/controller/gameserver_config_test.go b/operator/internal/controller/gameserver_config_test.go index b02e8c4c4..d2c1f1e14 100644 --- a/operator/internal/controller/gameserver_config_test.go +++ b/operator/internal/controller/gameserver_config_test.go @@ -41,6 +41,41 @@ func int32Ptr(v int32) *int32 { return &v } +func TestDeferredConfigEditsAndClearsKeepPodInputs(t *testing.T) { + gs, tmpl := configFixtures([]gameplanev1alpha1.ConfigField{ + {Name: "FLAGS", Type: "string", ApplyOnRestart: true}, {Name: "LIVE", Type: "string", Default: "one"}, + }, nil) + tmpl.Spec.ConfigFiles = []gameplanev1alpha1.ConfigFile{{Path: "options", Template: "{{ .Values.FLAGS }}"}} + baseline, err := materializeConfig(gs, tmpl) + if err != nil { + t.Fatal(err) + } + for _, value := range []string{"-Xlog:gc", ""} { + gs.Spec.Config = map[string]string{"FLAGS": value} + mc, err := materializeConfig(gs, tmpl) + if err != nil { + t.Fatal(err) + } + ref := mc.env[0].ValueFrom + if ref == nil || ref.SecretKeyRef == nil || ref.SecretKeyRef.Optional == nil || !*ref.SecretKeyRef.Optional || ref.SecretKeyRef.Key != "FLAGS" { + t.Fatalf("missing stable optional Secret reference: %+v", mc.env) + } + stored, exists := mc.secretData["FLAGS"] + if string(stored) != value || exists != (value != "") || string(mc.files[0].content) != value || mc.hash != baseline.hash { + t.Fatalf("deferred value %q: secret=%v files=%v hash=%s", value, mc.secretData, mc.files, mc.hash) + } + } + gs.Spec.Config["LIVE"] = "two" + immediate, err := materializeConfig(gs, tmpl) + if err != nil || immediate.hash == baseline.hash { + t.Fatalf("ordinary field did not roll: %v", err) + } + tmpl.Spec.ConfigSchema[0].Target = "file" + if _, err := materializeConfig(gs, tmpl); err == nil { + t.Fatal("file-only deferred field accepted") + } +} + func TestMaterializeConfig_ResolvesValuesAndDefaults(t *testing.T) { gs, tmpl := configFixtures([]gameplanev1alpha1.ConfigField{ {Name: "TYPE", Type: "enum", Enum: []string{"VANILLA", "PAPER"}, Default: "VANILLA", Required: true}, diff --git a/operator/specs.md b/operator/specs.md index 10c9b06d7..770f1f6a5 100644 --- a/operator/specs.md +++ b/operator/specs.md @@ -6,6 +6,15 @@ ## Purpose +### Configuration at container startup + +`GameTemplate.spec.configSchema[].applyOnRestart` opts an environment field +into an optional Secret key reference that exists in the Pod template for +every value, including an unset field. The reconciler stages the resolved +values in the GameServer's owned config Secret. The restart hash still covers +ordinary config values and the paths and source of rendered files. Files that +interpolate staged values are copied to the data volume at the next Pod start. + ### Restricted workloads and node capture (#714) `--restricted-workloads` applies nonroot, no escalation, drop ALL and diff --git a/test/e2e/upgrade_e2e_test.go b/test/e2e/upgrade_e2e_test.go index 56736b34c..5168523e2 100644 --- a/test/e2e/upgrade_e2e_test.go +++ b/test/e2e/upgrade_e2e_test.go @@ -5,9 +5,11 @@ package e2e import ( "context" "encoding/json" + "fmt" "os" "os/exec" "path/filepath" + "reflect" "strconv" "strings" "testing" @@ -66,10 +68,18 @@ func TestUpgrade_FromPreviousRelease(t *testing.T) { envInstance.BootstrapAdmin(t, adminUser, adminPass) applyBusyboxTemplate(t, tmpl) + if out, err := envInstance.Kubectl(ctx, "patch", "gametemplate", tmpl, "--type=merge", "-p", + `{"spec":{"configSchema":[{"name":"STARTUP_OPTION","type":"string","default":"legacy"}]}}`); err != nil { + t.Fatalf("seed previous-release startup option: %v: %s", err, out) + } applyBusyboxGameServer(t, ns, gs, tmpl) waitPVCBound(t, ns, gs+"-data", 90*time.Second) waitGameContainerReady(t, ns, gs+"-0", 2*time.Minute) + if out, err := envInstance.KubectlExec(t, ns, "pod/"+gs+"-0", "printenv", "STARTUP_OPTION"); err != nil || !strings.HasSuffix(strings.TrimSpace(out), "legacy") { + t.Fatalf("previous-release game did not receive its inline environment: %v: %s", err, out) + } + // `sync` so the bytes are on the PVC's backing store before the pod is // restarted by the upgrade, not sitting in the page cache. if out, err := envInstance.KubectlExec(t, ns, "pod/"+gs+"-0", @@ -200,6 +210,8 @@ func TestUpgrade_FromPreviousRelease(t *testing.T) { return true, "" }) + verifyDeferredStartupUpgrade(t, ns, gs, tmpl) + // ---- 6. the database and its migrations survived --------------------- // Deliberately after the upgrade: the API pod was replaced, so this both @@ -332,7 +344,7 @@ func TestUpgrade_FromPreviousRelease(t *testing.T) { t.Fatalf("write %s: %v", oldCRDFile, err) } if out, err := envInstance.Kubectl(ctx, "apply", "--server-side", "--force-conflicts", - "--field-manager=helm", "-f", oldCRDFile); err != nil { + "--field-manager=helm", "-f", filepath.ToSlash(oldCRDFile)); err != nil { t.Fatalf("re-apply the %s release's CRDs: %v\n%s", fromVersion, err, out) } @@ -515,6 +527,110 @@ func keySet(m map[string]any) map[string]struct{} { // `helm install` lands on stale, leftover CRDs (F-218). const crdBundleStampAnnotation = "gameplane.local/crd-bundle-sha256" +// Check the adoption rollout once, then stage both an edit and a clear on the +// upgraded workload. Wait for reconciliation before comparing the template and +// process environment so a delayed automatic rollout cannot look like success. +func verifyDeferredStartupUpgrade(t *testing.T, ns, gs, tmpl string) { + t.Helper() + ctx := t.Context() + name := gs + "-0" + k := envInstance.K8s + run := func(args ...string) { + t.Helper() + if out, err := envInstance.Kubectl(ctx, args...); err != nil { + t.Fatalf("kubectl %v: %v: %s", args, err, out) + } + } + readStartupOption := func() (string, error) { + return envInstance.Kubectl(ctx, "exec", "-n", ns, "pod/"+name, "--", "printenv", "STARTUP_OPTION") + } + // Finish recording the old template before installing its new revision. + envInstance.Eventually(t, 3*time.Minute, func() (bool, string) { + server, err := envInstance.Dyn.Resource(gameServerGVR).Namespace(ns).Get(ctx, gs, metav1.GetOptions{}) + if err != nil { + return false, err.Error() + } + return server.GetAnnotations()["gameplane.local/effective-template-revision"] != "", "waiting for the original template snapshot" + }) + run("patch", "gametemplate", tmpl, "--type=merge", "-p", `{"spec":{"configSchema":[{"name":"STARTUP_OPTION","type":"string","default":"legacy","applyOnRestart":true}]}}`) + envInstance.Eventually(t, 4*time.Minute, func() (bool, string) { + pod, err := k.CoreV1().Pods(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, err.Error() + } + for _, c := range pod.Spec.Containers { + if c.Name != "game" { + continue + } + for _, v := range c.Env { + if v.Name == "STARTUP_OPTION" && v.ValueFrom != nil && v.ValueFrom.SecretKeyRef != nil { + return true, "" + } + } + } + return false, "waiting for the template adoption rollout" + }) + waitGameContainerReady(t, ns, name, 3*time.Minute) + for _, step := range []struct{ patch, before, after string }{ + {`{"spec":{"config":{"STARTUP_OPTION":"staged"}}}`, "legacy", "staged"}, + {`{"spec":{"config":{"STARTUP_OPTION":null}}}`, "staged", "legacy"}, + } { + pod, err := k.CoreV1().Pods(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + sts, err := k.AppsV1().StatefulSets(ns).Get(ctx, gs, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + run("-n", ns, "patch", "gameserver", gs, "--type=merge", "-p", step.patch) + envInstance.Eventually(t, 2*time.Minute, func() (bool, string) { + secret, err := k.CoreV1().Secrets(ns).Get(ctx, gs+"-config", metav1.GetOptions{}) + if err != nil { + return false, err.Error() + } + server, err := envInstance.Dyn.Resource(gameServerGVR).Namespace(ns).Get(ctx, gs, metav1.GetOptions{}) + if err != nil { + return false, err.Error() + } + conditions, _, _ := unstructured.NestedSlice(server.Object, "status", "conditions") + for _, condition := range conditions { + c, _ := condition.(map[string]any) + if c["type"] == "Ready" && c["observedGeneration"] == server.GetGeneration() && string(secret.Data["STARTUP_OPTION"]) == step.after { + return true, "" + } + } + return false, "waiting for the saved setting to reconcile" + }) + live, err := k.CoreV1().Pods(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + current, err := k.AppsV1().StatefulSets(ns).Get(ctx, gs, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + if live.UID != pod.UID || live.DeletionTimestamp != nil || !reflect.DeepEqual(sts.Spec.Template, current.Spec.Template) { + t.Fatalf("saving %q replaced the Pod or changed its template", step.after) + } + if out, err := readStartupOption(); err != nil || !strings.HasSuffix(strings.TrimSpace(out), step.before) { + t.Fatalf("running game lost %q: %v: %s", step.before, err, out) + } + run("-n", ns, "annotate", "gameserver", gs, "gameplane.local/restart-requested="+step.after, "--overwrite") + envInstance.Eventually(t, 4*time.Minute, func() (bool, string) { + fresh, err := k.CoreV1().Pods(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, err.Error() + } + if fresh.UID == pod.UID { + return false, "waiting for a new Pod" + } + out, err := readStartupOption() + return err == nil && strings.HasSuffix(strings.TrimSpace(out), step.after), fmt.Sprintf("new process: %v: %s", err, out) + }) + } +} + // upgradeFromChartRef and defaultUpgradeFromVersion mirror CHART_REF and // FROM_VERSION in deploy/kind/upgrade.sh: the published chart the upgrade // cluster was installed from. CI sets GAMEPLANE_UPGRADE_FROM for both. diff --git a/web/e2e/screenshots/slice-gameconfig.spec.ts b/web/e2e/screenshots/slice-gameconfig.spec.ts index 25175348c..1d1b7e899 100644 --- a/web/e2e/screenshots/slice-gameconfig.spec.ts +++ b/web/e2e/screenshots/slice-gameconfig.spec.ts @@ -1,9 +1,11 @@ import { test, expect, type Page } from "@playwright/test"; +import { capture as captureDesign } from "./capture"; import path from "path"; import { fileURLToPath } from "node:url"; // Settings > Game configuration (design frames OC804, QFEg9, BguST password // will be removed, YN8cE new password typed) and the 404 page (Ne5TA), captured at 1440x900 @2x = 2880x1800, same as the frames. +// The JVM screens KJdxg and ZUAq1 use their exported frame sizes. // Frames wxINm (read-only) and e9FnC (orphan key) are intentionally not // captured: they need per-state server fixtures. @@ -87,6 +89,29 @@ test.describe("Game configuration + 404 (Desktop — 1440x900) @screenshots", () await capture(page, "YN8cE"); }); + for (const state of [ + { id: "KJdxg", variant: "jvm-adopted", initial: "1536M", flag: "1536M", notice: /next container start/i, valid: true }, + { id: "ZUAq1", variant: "jvm-adopting", initial: "", flag: "3072M", notice: /may restart once/i, valid: false }, + ]) { + test(`${state.id}: Minecraft JVM startup — ${state.variant}`, async ({ context, page }) => { + await context.addCookies([{ name: "e2e_server_variant", value: state.variant, domain: "localhost", path: "/" }]); + await page.goto("/servers/mc-survival"); + await clickTab(page, "Settings"); + await clickTab(page, "Game configuration"); + await page.getByLabel(/^JVM initial heap/).fill(state.initial); + await expect(page.getByTestId("config-restart-note")).toHaveText(state.notice); + await expect(page.getByText(`Heap options before additional JVM arguments: -Xms${state.flag} -Xmx2G`)).toBeVisible(); + const save = page.getByRole("button", { name: /save changes/i }); + if (state.valid) { + await expect(save).toBeEnabled(); + } else { + await expect(page.getByText(/The initial heap exceeds the maximum heap/)).toBeVisible(); + await expect(save).toBeDisabled(); + } + await captureDesign(page, state.id); + }); + } + test("Ne5TA: Page not found", async ({ page }) => { await page.goto("/this-page-does-not-exist"); await expect(page.getByRole("heading", { name: "Page not found" })).toBeVisible({ timeout: 10_000 }); diff --git a/web/specs.md b/web/specs.md index 6f8520031..4be589dda 100644 --- a/web/specs.md +++ b/web/specs.md @@ -991,7 +991,7 @@ Save fetches the latest server and applies only the changes from the original ba 1. **General** — Server name, description, game-icon image field with placeholder from template's image or "(template image)" when no template 2. **Version** — Template version selector (triggers container restart) -2a. **Game configuration** — `GameConfigSection` (`web/src/routes/tabs/settings/GameConfig.tsx`), key `config`, shown only when `template.spec.configSchema` has entries (or the server still carries `spec.config` keys, so stray keys can be removed). Edits `draft.spec.config` through the tab's shared draft/Save footer; renders fields with the shared `ConfigFields` (`web/src/components/server/ConfigFields.tsx`, variant `settings`). An unset key means the template default (or the auto value); a value equal to the schema default removes the key, and an emptied map is written as `undefined` (never `{}`). Client validation (`validateConfig`, `web/src/lib/validation.ts`): required, enum, int (whole number, min/max), bool, string/password length; failures show inline and disable Save through `onValidityChange` (the operator stays authoritative; the API does not validate against the schema). Password fields are write-only: the API returns the marker `__gameplane_redacted__` for a stored password, the input is empty with the placeholder "Unchanged — type to replace" and a "Write-only" note, the marker is never rendered, stays in the draft until the user types (the PUT then keeps the stored value) and typing replaces it. A stored OPTIONAL password also shows a "Remove password" button (spec 021 OD-2): pressing it drops the key from the draft (the API deletes an optional password whose key is absent) and shows "Password will be removed" with an Undo button; the state is derived from `storedConfig` (the saved `gs.spec.config` marker) so it survives section switches, Undo restores the key in its saved position, required passwords and read-only sessions get no button, and emptying a typed value still returns to "unchanged". `target: file` fields carry a "Written to file" note. Keys present in `spec.config` but absent from the schema are listed with a "Remove" button ("No longer in the template") because the operator rejects unknown keys. An info alert states that saving restarts the server (always; there is no apply-later mode). When `status.conditions[type=Ready].message` starts with `invalid config:` a danger alert shows it. Without `servers:write` (`access.canWrite`) the fields are disabled, the alert is hidden and the footer shows "You need permission to change this server's settings." instead of Discard/Save. +2a. **Game configuration** — `GameConfigSection` (`web/src/routes/tabs/settings/GameConfig.tsx`), key `config`, shown only when `template.spec.configSchema` has entries (or the server still carries `spec.config` keys, so stray keys can be removed). Edits `draft.spec.config` through the tab's shared draft/Save footer; renders fields with the shared `ConfigFields` (`web/src/components/server/ConfigFields.tsx`, variant `settings`). An unset key means the template default (or the auto value); a value equal to the schema default removes the key, and an emptied map is written as `undefined` (never `{}`). Client validation (`validateConfig`, `web/src/lib/validation.ts`): required, enum, int (whole number, min/max), bool, string/password length; failures show inline and disable Save through `onValidityChange` (the operator stays authoritative; the API does not validate against the schema). Password fields are write-only: the API returns the marker `__gameplane_redacted__` for a stored password, the input is empty with the placeholder "Unchanged — type to replace" and a "Write-only" note, the marker is never rendered, stays in the draft until the user types (the PUT then keeps the stored value) and typing replaces it. A stored OPTIONAL password also shows a "Remove password" button (spec 021 OD-2): pressing it drops the key from the draft (the API deletes an optional password whose key is absent) and shows "Password will be removed" with an Undo button; the state is derived from `storedConfig` (the saved `gs.spec.config` marker) so it survives section switches, Undo restores the key in its saved position, required passwords and read-only sessions get no button, and emptying a typed value still returns to "unchanged". `target: file` fields carry a "Written to file" note. Keys present in `spec.config` but absent from the schema are listed with a "Remove" button ("No longer in the template") because the operator rejects unknown keys. The notice follows the edited fields in the effective schema. Startup-only edits apply at the next container start. Ordinary configuration edits can restart the server. Minecraft Java groups supported startup options and previews the independently resolved heap sizes for the itzg launcher. When `status.conditions[type=Ready].message` starts with `invalid config:` a danger alert shows it. Without `servers:write` (`access.canWrite`) the fields are disabled, the alert is hidden and the footer shows "You need permission to change this server's settings." instead of Discard/Save. 3. **Resources** — `ResourcesSection` (`web/src/routes/tabs/settings/Resources.tsx`): CPU and memory (`spec.resources`), storage size and StorageClass. The CPU and memory fields show the saved limit (falling back to the request, then the 2 cores / 4Gi dashboard default). By default each field writes the same value to the request and the limit (Guaranteed QoS). The "Reserve less than the limit" switch (Burstable QoS) is not stored: it is on when a saved request differs from its limit, or once the user turns it on. While it is on, the CPU and memory fields are the limits and "Reserved CPU" / "Reserved memory (GiB)" (`ResourceInput` with its `label` prop) are the requests, from 0 (reserve nothing) up to the saved limit (uncapped when there is no saved limit). Lowering a limit below a saved request lowers that request to the new limit, since Kubernetes rejects a request above its limit. Each control writes only its own value, so an absent limit or request stays absent: the displayed fallbacks are never written back. Switching off copies each saved limit onto its request; a resource without a saved limit keeps its request. 4. **Networking** — Service type (ClusterIP/NodePort/LoadBalancer), LoadBalancer hostname, address pool / explicit address request, port overrides. Tunnel validation (`tunnel.enabled`, provider-specific config, credentials) is computed during render and reported via `onValidityChange` callback in an effect; local field state for `addressPool` and `address` is held in `useState` — so consecutive edits within one render are cumulative rather than each recomputing against the same stale `net` snapshot — and is re-seeded from props by two complementary mechanisms: on identity change, via a parent `key` remount; and in-render, whenever the incoming values differ by value from the last synced pair (so a save round-trip or a reload that returns changed values for the *same* server is picked up). 5. **Environment** — Custom env var key=value pairs diff --git a/web/src/lib/endpoints.ts b/web/src/lib/endpoints.ts index 991a27686..05ec7c099 100644 --- a/web/src/lib/endpoints.ts +++ b/web/src/lib/endpoints.ts @@ -662,6 +662,7 @@ function makeResourceEndpoints(request: RequestClient) { const withCluster = (path: string, cluster?: string) => request.url(cluster ? withClusterParam(path, cluster) : path); const fetch = request.raw; const Servers = { + effectiveTemplate: (name: string, ns?: string) => api(withNS(`/servers/${name}/template`, ns)), // A bare list() call defaults to the server's default namespace scope // (scope.DefaultNamespace) exactly as before (F-263); pass ns to target // one of the namespaces from Namespaces.list() explicitly. diff --git a/web/src/lib/jvmStartup.ts b/web/src/lib/jvmStartup.ts new file mode 100644 index 000000000..8470db42a --- /dev/null +++ b/web/src/lib/jvmStartup.ts @@ -0,0 +1,99 @@ +import type { IntlShape } from "react-intl"; + +import type { GameServer, GameTemplate } from "@/types"; +import { activeVersion } from "./capabilities"; +import { memQuantityBytes } from "./quantity"; +import { CONFIG_REDACTED_MARKER, validateConfig, type ConfigError, type ConfigField } from "./validation"; + +const heapNames = ["INIT_MEMORY", "MAX_MEMORY"] as const; +type HeapName = (typeof heapNames)[number]; + +export function isJvmField(field: ConfigField): boolean { + if (field.target === "file") return false; + if (field.name === "USE_AIKAR_FLAGS") return field.type === "bool"; + return field.type === "string" && ["INIT_MEMORY", "MAX_MEMORY", "JVM_OPTS", "JVM_XX_OPTS"].includes(field.name); +} + +interface Heap { + flag?: string; + bytes?: number; + invalid?: boolean; +} + +function parseHeap(name: HeapName, value: string, limit: number): Heap { + const initial = name === "INIT_MEMORY"; + const percentage = /^(\d+(?:\.\d+)?)\s*%$/.exec(value); + if (percentage && Number(percentage[1]) <= 100) { + return { + flag: `-XX:${initial ? "Initial" : "Max"}RAMPercentage=${percentage[1]}`, + bytes: limit ? limit * Number(percentage[1]) / 100 : undefined, + }; + } + const fixed = /^(\d+)([kmgt]?)$/i.exec(value); + if (!fixed || Number(fixed[1]) === 0) return { invalid: true }; + return { + flag: `-Xm${initial ? "s" : "x"}${value}`, + bytes: Number(fixed[1]) * 1024 ** ["", "k", "m", "g", "t"].indexOf(fixed[2].toLowerCase()), + }; +} + +export function jvmStartup(template: GameTemplate | undefined, server: GameServer, intl: IntlShape) { + const fields = template?.spec.game === "minecraft-java" ? (template.spec.configSchema ?? []).filter(isJvmField) : []; + const version = activeVersion(template, server); + const image = server.spec.image || version?.image || template?.spec.image || ""; + const itzg = /^(?:(?:docker\.io|index\.docker\.io)\/)?itzg\/minecraft-server(?=[:@]|$)/.test(image); + const limit = memQuantityBytes(server.spec.resources?.limits?.memory ?? template?.spec.resources?.limits?.memory); + const flags: string[] = []; + const errors: ConfigError[] = []; + const heaps: Partial> = {}; + if (!itzg || template?.spec.command?.length || !fields.some((f) => heapNames.some((name) => f.name === name))) { + return { fields, flags, errors }; + } + + // Resolve the operator's environment layers before applying the launcher's + // MEMORY fallback. null means a valueFrom or expansion is unknown here. + const base = [...(template?.spec.env ?? []), ...(version?.env ?? [])]; + const resolveEnv = (name: string, fallback: string | null): string | null => { + const supplied = [...(server.spec.env ?? [])].reverse().find((e) => e.name === name); + const field = template?.spec.configSchema?.find((f) => f.target !== "file" && f.name === name); + let configured = field ? server.spec.config?.[name] ?? field.default ?? "" : ""; + if (!configured && field?.autoFromMemoryLimit) { + const mib = Math.floor(limit * field.autoFromMemoryLimit.percent / 100 / 1024 ** 2); + if (mib > 0) configured = `${mib}M`; + } + const inherited = [...base].reverse().find((e) => e.name === name); + const env = supplied ?? (configured ? { value: configured } : inherited); + if (!env) return fallback; + if ("valueFrom" in env && env.valueFrom) return null; + const value = env.value ?? ""; + return value === CONFIG_REDACTED_MARKER || value.includes("$(") ? null : value; + }; + const memory = resolveEnv("MEMORY", "1G"); + const values = { INIT_MEMORY: resolveEnv("INIT_MEMORY", memory), MAX_MEMORY: resolveEnv("MAX_MEMORY", memory) }; + for (const name of heapNames) { + const other = name === "INIT_MEMORY" ? values.MAX_MEMORY : values.INIT_MEMORY; + const value = values[name] === "" && other ? memory : values[name]; + if (!value) continue; + const heap = parseHeap(name, value, limit); + heaps[name] = heap; + if (heap.flag) flags.push(heap.flag); + if (heap.invalid) { + const text = intl.formatMessage({ id: "web.jvm.heap_format", defaultMessage: "Enter a JVM heap size such as 512M or 2G, or a percentage from 0% to 100%.", description: "Settings: invalid Minecraft Java heap value" }); + errors.push({ name, text, message: text }); + } + } + const initial = heaps.INIT_MEMORY?.bytes; + const maximum = heaps.MAX_MEMORY?.bytes; + if (initial !== undefined && maximum !== undefined && initial > maximum) { + const text = intl.formatMessage({ id: "web.jvm.heap_order", defaultMessage: "The initial heap exceeds the maximum heap. Lower the initial heap or raise the maximum.", description: "Settings: the independently resolved initial Java heap is larger than the maximum heap" }); + errors.push({ name: fields.some((f) => f.name === "INIT_MEMORY") ? "INIT_MEMORY" : "MAX_MEMORY", text, message: text }); + } + return { fields, flags, errors }; +} + +export function settingsConfigErrors(template: GameTemplate, server: GameServer, intl: IntlShape): ConfigError[] { + return [ + ...validateConfig(template.spec.configSchema ?? [], server.spec.config ?? {}, intl), + ...jvmStartup(template, server, intl).errors, + ]; +} diff --git a/web/src/routes/tabs/Settings.test.tsx b/web/src/routes/tabs/Settings.test.tsx index e04dc5b45..afae2b9a8 100644 --- a/web/src/routes/tabs/Settings.test.tsx +++ b/web/src/routes/tabs/Settings.test.tsx @@ -57,6 +57,23 @@ function stubTemplate() { } describe("SettingsTab", () => { + it("reads startup settings from the server's admitted revision", async () => { + fetchMock.mockImplementation(async (url: string) => { + if (url.startsWith("/servers/mc-survival/template")) return jsonRes({ + metadata: { name: "minecraft-java" }, + spec: { game: "minecraft-java", image: "itzg/minecraft-server:java21", configSchema: [ + { name: "MAX_MEMORY", type: "string", applyOnRestart: true }, + ] }, + }); + throw new Error(`unexpected fetch: ${url}`); + }); + const snapshot = gs({ metadata: { name: "mc-survival", annotations: { "gameplane.local/effective-template-revision": "pinned" } }, status: { phase: "Running", templateRevision: { running: "pinned" } } }); + renderWithQuery(); + await userEvent.click(await screen.findByRole("tab", { name: "Game configuration" })); + expect(await screen.findByLabelText("MAX_MEMORY")).toBeVisible(); + expect(screen.getByTestId("config-restart-note")).toHaveTextContent(/next container start/i); + }); + it("preserves share creation and its one-time token across server status refreshes", async () => { const link = { id: "share-id", token: "one-time-token", createdAt: new Date().toISOString(), expiresAt: null, canStart: false }; fetchMock.mockImplementation(async (url: string, init?: FetchInit) => { diff --git a/web/src/routes/tabs/Settings.tsx b/web/src/routes/tabs/Settings.tsx index f5a59b382..49c996255 100644 --- a/web/src/routes/tabs/Settings.tsx +++ b/web/src/routes/tabs/Settings.tsx @@ -22,7 +22,7 @@ import type { GameServer } from "@/types"; import { APIError } from "@/lib/api"; import { errorTextIntl } from "@/lib/errors"; import { mergeDraftOntoLatest, SettingsConflictError } from "@/lib/settingsMerge"; -import { validateConfig } from "@/lib/validation"; +import { settingsConfigErrors } from "@/lib/jvmStartup"; import { GeneralSection } from "./settings/General"; @@ -160,9 +160,12 @@ export function SettingsTab({ gs, name, ns, onDirtyChange }: SettingsTabProps) { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]); + const revision = draft?.metadata.annotations?.["gameplane.local/effective-template-revision"]; const { data: template } = useQuery({ - queryKey: resourceKey(resourceTarget, "template", draft?.spec.templateRef.name), - queryFn: ({ signal }) => resourceClient.withSignal(signal).Templates.get(draft!.spec.templateRef.name), + queryKey: resourceKey(resourceTarget, "template", draft?.spec.templateRef.name, revision), + queryFn: ({ signal }) => revision + ? resourceClient.withSignal(signal).Servers.effectiveTemplate(name, ns) + : resourceClient.withSignal(signal).Templates.get(draft!.spec.templateRef.name), enabled: !!draft?.spec.templateRef.name, }); const moduleText = useModuleText(draft?.spec.templateRef.name, resourceTarget.cluster); @@ -170,15 +173,15 @@ export function SettingsTab({ gs, name, ns, onDirtyChange }: SettingsTabProps) { // Validate config from the shared draft even when its section is unmounted. // Other sections retain their reported validity across navigation, including // invalid editor text that has not yet been committed into the draft. - const draftValid = !!template && - validateConfig(template.spec.configSchema ?? [], draft?.spec.config ?? {}, intl).length === 0 && + const draftValid = !!template && !!draft && + settingsConfigErrors(template, draft, intl).length === 0 && Object.values(sectionValidity).every(Boolean); const save = useMutation({ onMutate: () => { savingRef.current = true; }, onSettled: () => { savingRef.current = false; }, mutationFn: async (next: GameServer) => { - if (!template || validateConfig(template.spec.configSchema ?? [], next.spec.config ?? {}, intl).length > 0 || + if (!template || settingsConfigErrors(template, next, intl).length > 0 || !Object.values(sectionValidity).every(Boolean)) { throw new Error(intl.formatMessage({ id: "web.routes.tabs.settings.correct_invalid_before_saving", defaultMessage: "Correct invalid settings before saving.", description: "Settings: error shown when saving is attempted while some settings are invalid" })); } @@ -186,7 +189,11 @@ export function SettingsTab({ gs, name, ns, onDirtyChange }: SettingsTabProps) { // keeps fields the UI doesn't model (e.g. operator-managed status, // newly-added spec keys) from being clobbered. const latest = await Servers.get(name, ns); + if (latest.metadata.annotations?.["gameplane.local/effective-template-revision"] !== revision) { + throw new SettingsConflictError(intl); + } const merged = mergeDraftOntoLatest(next, baselineRef.current!, latest, intl); + if (settingsConfigErrors(template, merged, intl).length > 0) throw new SettingsConflictError(intl); return Servers.update(name, merged, ns); }, onSuccess: (saved) => { diff --git a/web/src/routes/tabs/settings/GameConfig.test.tsx b/web/src/routes/tabs/settings/GameConfig.test.tsx index b369be70d..8a3a04a14 100644 --- a/web/src/routes/tabs/settings/GameConfig.test.tsx +++ b/web/src/routes/tabs/settings/GameConfig.test.tsx @@ -19,18 +19,87 @@ const SCHEMA: Schema = [ { name: "MOTD", displayName: "MOTD", type: "string", target: "file" }, ]; +const heaps: Schema = [ + { name: "INIT_MEMORY", type: "string", autoFromMemoryLimit: { percent: 75 }, applyOnRestart: true }, + { name: "MAX_MEMORY", type: "string", autoFromMemoryLimit: { percent: 75 }, applyOnRestart: true }, +]; + +it("previews the two heap fields independently and rejects an initial heap above the maximum", () => { + const gs = server({ MAX_MEMORY: "2G" }); + const template = tmpl(heaps); + template.spec.resources = { limits: { memory: "4Gi" } }; + renderWithQuery(); + expect(screen.getByText(/3072M/)).toBeInTheDocument(); + expect(screen.getByText(/initial heap exceeds the maximum heap/i)).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("INIT_MEMORY"), { target: { value: "37.5%" } }); + expect(screen.queryByText(/initial heap exceeds the maximum heap/i)).not.toBeInTheDocument(); + expect(screen.getByText(/InitialRAMPercentage=37.5/)).toBeInTheDocument(); + expect(screen.getByTestId("config-restart-note")).toHaveTextContent(/next container start/i); +}); + +it("reports immediate fields when a custom template mixes startup and ordinary configuration", () => { + const template = tmpl([...heaps, { name: "MOTD", type: "string" }]); + renderWithQuery(); + fireEvent.change(screen.getByLabelText("MOTD"), { target: { value: "changed" } }); + expect(screen.getByTestId("config-restart-note")).toHaveTextContent(/restarts the server/i); +}); + +it("uses only declared environment options and keeps a custom launcher editable without guessing heap arguments", () => { + const gs = server({ MAX_MEMORY: "512M" }); + gs.spec.image = "example/custom-launcher"; + const template = tmpl([heaps[1], { name: "JVM_OPTS", type: "string", target: "file" }]); + renderWithQuery(); + const startup = screen.getByRole("group", { name: "JVM startup" }); + expect(startup).toHaveTextContent("MAX_MEMORY"); + expect(startup).not.toHaveTextContent("JVM_OPTS"); + expect(screen.queryByText(/Heap options/)).not.toBeInTheDocument(); + expect(screen.queryByLabelText("INIT_MEMORY")).not.toBeInTheDocument(); + expect(screen.getByLabelText("MAX_MEMORY")).toBeDisabled(); + expect(screen.queryByTestId("config-restart-note")).not.toBeInTheDocument(); +}); + +it("uses the launcher's MEMORY fallback with a partial schema and no memory limit", () => { + const gs = server({ MAX_MEMORY: "512M" }); + const template = tmpl([heaps[1]]); + template.spec.env = [{ name: "MEMORY", value: "2G" }]; + renderWithQuery(); + expect(screen.getByText(/-Xms2G -Xmx512M/)).toBeInTheDocument(); + expect(screen.getByText(/initial heap exceeds the maximum heap/i)).toBeInTheDocument(); + expect(screen.queryByLabelText("INIT_MEMORY")).not.toBeInTheDocument(); +}); + +it("omits the image-specific preview for a template that replaces its entrypoint", () => { + const template = tmpl(heaps); + template.spec.command = ["/custom/launch"]; + renderWithQuery(); + expect(screen.queryByText(/Heap options before/)).not.toBeInTheDocument(); +}); + +it("keeps the adoption warning until the running pod reports the selected revision", () => { + const gs = server({ MAX_MEMORY: "2G" }, { phase: "Running", templateRevision: { running: "old" } }); + renderWithQuery(); + expect(screen.getByTestId("config-restart-note")).toHaveTextContent(/may restart once/i); +}); + +it("warns about adoption when the running server still uses its baseline Pod", () => { + const gs = server(); + gs.metadata.annotations = { ...gs.metadata.annotations, "gameplane.local/template-baseline": "admitted" }; + renderWithQuery(); + expect(screen.getByTestId("config-restart-note")).toHaveTextContent(/may restart once/i); +}); + function tmpl(configSchema: Schema = SCHEMA): GameTemplate { return { metadata: { name: "minecraft-java" }, - spec: { displayName: "Minecraft", game: "minecraft-java", version: "1.0", image: "x", configSchema }, + spec: { displayName: "Minecraft", game: "minecraft-java", version: "1.0", image: "itzg/minecraft-server:java21", configSchema }, }; } function server(config?: Record, status?: GameServer["status"]): GameServer { return { - metadata: { name: "mc-survival", namespace: "gameplane-games", resourceVersion: "1" }, + metadata: { name: "mc-survival", namespace: "gameplane-games", resourceVersion: "1", annotations: { "gameplane.local/effective-template-revision": "admitted" } }, spec: { templateRef: { name: "minecraft-java" }, config }, - status, + status: status ?? { phase: "Running", templateRevision: { running: "admitted" } }, }; } @@ -56,6 +125,7 @@ function Harness({ { spy?.(next); diff --git a/web/src/routes/tabs/settings/GameConfig.tsx b/web/src/routes/tabs/settings/GameConfig.tsx index 5406bbee3..686b31826 100644 --- a/web/src/routes/tabs/settings/GameConfig.tsx +++ b/web/src/routes/tabs/settings/GameConfig.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from "react"; import { AlertCircle, AlertTriangle } from "lucide-react"; import { Alert, Button, Input } from "@heroui/react"; +import { jvmStartup } from "@/lib/jvmStartup"; import { ConfigFields } from "@/components/server/ConfigFields"; import { useResourceAccess } from "@/lib/resourceTarget"; import { CONFIG_REDACTED_MARKER, validateConfig } from "@/lib/validation"; @@ -26,7 +27,17 @@ export function GameConfigSection({ draft, onChange, template, moduleText, store const schema = useMemo(() => template?.spec.configSchema ?? [], [template]); const values = useMemo(() => draft.spec.config ?? {}, [draft.spec.config]); - const errors = useMemo(() => validateConfig(schema, values, intl), [schema, values, intl]); + const jvm = useMemo(() => jvmStartup(template, draft, intl), [template, draft, intl]); + const errors = useMemo(() => [...validateConfig(schema, values, intl), ...jvm.errors], [schema, values, intl, jvm]); + const ordinary = schema.filter((f) => !jvm.fields.includes(f)); + const edited = schema.filter((f) => values[f.name] !== storedConfig?.[f.name]); + const relevant = edited.length ? edited : schema; + const hasDeferred = schema.some((f) => f.applyOnRestart && f.target !== "file"); + const revision = draft.metadata.annotations?.["gameplane.local/effective-template-revision"]; + const stopped = draft.status?.phase === "Stopped" || draft.status?.phase === "Suspended"; + const adopted = !!revision && draft.metadata.annotations?.["gameplane.local/template-baseline"] !== revision && + (stopped || draft.status?.templateRevision?.running === revision); + const deferredOnly = relevant.length > 0 && relevant.every((f) => f.applyOnRestart && f.target !== "file"); const errorText = useMemo( () => Object.fromEntries(errors.map((e) => [e.name, e.text])), [errors], @@ -133,7 +144,7 @@ export function GameConfigSection({ draft, onChange, template, moduleText, store

{intl.formatMessage({ id: "web.routes.tabs.settings.game_config.game_configuration", defaultMessage: "Game configuration", description: "Game config: heading of the game configuration section, shown at the top of the section" })}

-

{intl.formatMessage({ id: "web.routes.tabs.settings.game_config.settings_from_this_server_s_game", defaultMessage: "Settings from this server's game template. Saving restarts the server.", description: "Game config: hint under the heading saying these settings come from the game template and that saving restarts the server" })}

+

{hasDeferred ? intl.formatMessage({ id: "web.settings.config_schema_source", defaultMessage: "Settings from this server's game template.", description: "Settings: source of the configuration fields for a server" }) : intl.formatMessage({ id: "web.routes.tabs.settings.game_config.settings_from_this_server_s_game", defaultMessage: "Settings from this server's game template. Saving restarts the server.", description: "Game config: hint under the heading saying these settings come from the game template and that saving restarts the server" })}

{invalidMessage && ( @@ -153,7 +164,7 @@ export function GameConfigSection({ draft, onChange, template, moduleText, store - {intl.formatMessage({ id: "web.routes.tabs.settings.game_config.saving_changes_restarts_the_server_so", defaultMessage: "Saving changes restarts the server so they apply.", description: "Game config: warning note saying that saving changes restarts the server so they apply; shown to users who can write the settings" })} + {deferredOnly && !adopted ? intl.formatMessage({ id: "web.settings.config_adoption", defaultMessage: "The server may restart once when it adopts these startup settings.", description: "Settings: startup notice before the server has adopted the effective template" }) : deferredOnly ? intl.formatMessage({ id: "web.settings.config_next_start", defaultMessage: "These configuration changes apply at the next container start.", description: "Settings: notice when all edited configuration fields defer application until startup" }) : hasDeferred && !edited.length ? intl.formatMessage({ id: "web.settings.config_mixed", defaultMessage: "Some settings apply at the next container start. Changing other settings restarts the server.", description: "Settings: initial notice for a template with both immediate and deferred settings" }) : intl.formatMessage({ id: "web.routes.tabs.settings.game_config.saving_changes_restarts_the_server_so", defaultMessage: "Saving changes restarts the server so they apply.", description: "Game config: warning note saying that saving changes restarts the server so they apply; shown to users who can write the settings" })} )} @@ -162,8 +173,15 @@ export function GameConfigSection({ draft, onChange, template, moduleText, store )}
+ {jvm.fields.length > 0 && ( +
+ {intl.formatMessage({ id: "web.settings.jvm_startup", defaultMessage: "JVM startup", description: "Settings: heading for Minecraft Java startup options" })} + + {jvm.flags.length > 0 &&

{intl.formatMessage({ id: "web.settings.jvm_heap_options", defaultMessage: "Heap options before additional JVM arguments: {options}", description: "Settings: JVM arguments computed from the initial and maximum heap settings" }, { options: {jvm.flags.join(" ")} })}

} +
+ )} { + if (cookies.e2e_server_variant === "jvm-adopting" || cookies.e2e_server_variant === "jvm-adopted") { + return HttpResponse.json(screenshotJvmTemplate); + } + }), + // Servers: all 5 screenshot servers http.get("/servers", () => HttpResponse.json({ @@ -940,6 +947,21 @@ export function buildScreenshotHandlers() { } if (name === "mc-survival" && cookies.e2e_server_variant) { const variant = cookies.e2e_server_variant; + if (variant === "jvm-adopted" || variant === "jvm-adopting") { + return HttpResponse.json({ + ...server, + metadata: { + ...server.metadata, + annotations: { ...server.metadata.annotations, "gameplane.local/effective-template-revision": "startup" }, + }, + spec: { + ...server.spec, + resources: { limits: { memory: "4Gi" } }, + config: { JVM_OPTS: "-Dexample=true", MAX_MEMORY: "2G" }, + }, + status: { ...server.status, templateRevision: { running: variant === "jvm-adopted" ? "startup" : "previous" } }, + }); + } if (variant === "idle") { return HttpResponse.json({ ...server, diff --git a/web/src/test/screenshotData.ts b/web/src/test/screenshotData.ts index 8e7698fd9..ea9878284 100644 --- a/web/src/test/screenshotData.ts +++ b/web/src/test/screenshotData.ts @@ -1407,6 +1407,24 @@ export const screenshotModrinthProjects: RegistryProject[] = [ // Helper for tests/e2e to conditionally swap handler sets // ============================================================================ +export const screenshotJvmTemplate = makeTemplate({ + metadata: { name: "minecraft-java" }, + spec: { + displayName: "Minecraft Java", + game: "minecraft-java", + version: "2.12.0", + image: "itzg/minecraft-server:java21", + resources: { limits: { memory: "4Gi" } }, + configSchema: [ + { name: "MAX_MEMORY", displayName: "JVM max heap", type: "string", applyOnRestart: true, autoFromMemoryLimit: { percent: 75 } }, + { name: "INIT_MEMORY", displayName: "JVM initial heap", type: "string", applyOnRestart: true, autoFromMemoryLimit: { percent: 75 } }, + { name: "JVM_OPTS", displayName: "Additional JVM options", type: "string", applyOnRestart: true }, + { name: "JVM_XX_OPTS", displayName: "Additional JVM tuning options", type: "string", applyOnRestart: true }, + { name: "USE_AIKAR_FLAGS", displayName: "Use Aikar’s flags", type: "bool", default: "false", applyOnRestart: true }, + ], + }, +}); + // configSchema served ONLY by the single-template GET (see handlers.ts), so // Settings > Game configuration renders (design frames OC804/QFEg9) while the // Create wizard's template list — whose captures have no "Template diff --git a/web/src/types.ts b/web/src/types.ts index 2091c1f42..22f330249 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -250,10 +250,10 @@ export type ProbeKind = "readiness" | "liveness" | "startup"; // One entry in a template's version catalog (spec.versions[]). Selecting it // (GameServer.spec.version = id) pins the image and, when `loader` keys into // capabilities.mods.loaders, that loader's per-(version+loader) mod volume. -// `env` is operator-side only and intentionally not surfaced here. export interface GameVersion { id: string; displayName: string; + env?: EnvVar[]; image?: string; loader?: string; default?: boolean; @@ -311,7 +311,10 @@ export interface GameTemplate { icon?: string; accentColor?: string; image: string; + command?: string[]; versions?: GameVersion[]; + env?: EnvVar[]; + resources?: ResourceRequirements; // Ports the game listens on. wakeProtocol picks the wake sentinel's parser // for the port while the server sleeps; the CRD defaults it to "generic", // and "none" means the port never wakes the server. @@ -339,6 +342,7 @@ export interface GameTemplate { required?: boolean; target?: "env" | "file"; autoFromMemoryLimit?: { percent: number }; + applyOnRestart?: boolean; // Bounds mirror the CRD ConfigField: min/max apply to int fields, // minLength/maxLength to string and password fields (bytes). min?: number;