diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ea90527..746e29d 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -66,7 +66,8 @@ info: services through existing management routes, without player ownership rows. Creating and claiming these reserved names remain prohibited. System patches keep public autostart and idle stop disabled. Customization is persisted as - felis-experience.json using the existing stopped-server file API. + felis-experience.json using the existing file API. Staff may read and save + this startup-only config while system services run; restart to apply it. Control plane for the Felis Minecraft orchestration platform. The same binary exposes an internal face (per-caller service tokens, for velocity / backend @@ -2452,6 +2453,45 @@ paths: '404': $ref: '#/components/responses/NotFound' + /api/v1/servers/{name}/restart: + post: + tags: [servers] + operationId: restart + summary: Restart your own running server, or a system service as staff. + description: >- + Records a durable request for the operator to gracefully recreate the + game pod. Desired state remains Running. A concurrent stop supersedes + the request; maintenance blocks admission. + x-felis-face: [external] + x-felis-tier: app + security: [{ sessionCookie: [] }] + parameters: + - { name: name, in: path, required: true, schema: { type: string } } + responses: + '202': + description: Restart accepted. + content: + application/json: + schema: + type: object + required: [name, desiredState] + properties: + name: { type: string } + desiredState: { type: string, const: Running } + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + description: Server is not running, is retiring, or maintenance is in progress. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + /api/v1/servers/{name}/claim: post: tags: [servers] @@ -4830,10 +4870,11 @@ paths: get: tags: [files] operationId: readServerFile - summary: Read a file from a server's world volume (owner-or-admin; server must be stopped). + summary: Read a file from a server's world volume (owner-or-admin). description: >- Returns one file's bytes, base64-encoded, from inside the server's world - volume. Same stopped-gate and os.Root containment as the directory listing. + volume. Same stopped-gate and os.Root containment as the directory listing, + except staff may read login/lobby's felis-experience.json while running. Reads are capped at 1 MiB; a larger file is 413 rather than a truncated read, because a config editor that silently returned half a file would let a subsequent save destroy the other half. @@ -4886,7 +4927,7 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } '409': - description: Server is not stopped (its world PVC is still mounted). + description: Server is not stopped (except startup-only system experience config). content: application/json: schema: { $ref: '#/components/schemas/Error' } @@ -4914,7 +4955,7 @@ paths: put: tags: [files] operationId: writeServerFile - summary: Write a file in a server's world volume (owner-or-admin; server must be stopped). + summary: Write a file in a server's world volume (owner-or-admin). description: >- Replaces a file's contents, creating the file if absent but never creating its parent directories. Content is base64 so arbitrary bytes (CRLF endings, a BOM) @@ -4927,7 +4968,9 @@ paths: otherwise 409 file_changed. content_sha256 is the SHA-256 of the content: content that hashes otherwise changed on the way and is refused (400 digest_mismatch) before a Job starts, and the Job checks the bytes it received - the same way before writing. Audited as file.write. + the same way before writing. Staff may save login/lobby's startup-only + felis-experience.json while running; restart to apply. That config cannot + be a symlink. Audited as file.write. x-felis-face: [external] x-felis-tier: app security: [{ sessionCookie: [] }] diff --git a/internal/api/api.go b/internal/api/api.go index b2e6455..d683304 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -566,6 +566,7 @@ func (a *API) externalAPIRoutes() []apiRoute { // (handlers_retire.go); owner/admin-gated inside the handlers. {Method: "PUT", Pattern: "/api/v1/servers/{name}/retirement", h: a.handleRetire}, {Method: "DELETE", Pattern: "/api/v1/servers/{name}/retirement", h: a.handleCancelRetire}, + {Method: "POST", Pattern: "/api/v1/servers/{name}/restart", h: a.handleRestart}, {Method: "GET", Pattern: "/api/v1/servers/{name}/status", h: a.handleStatus}, // Identity self-read (spec §14 tiering): the panel reads this once at boot to // learn its own tier and decide which navigation surfaces to render. App-tier — diff --git a/internal/api/api_test.go b/internal/api/api_test.go index d59c2b8..1a749d2 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1935,6 +1935,36 @@ func (c *fakeCluster) RetryStart(ctx context.Context, n string) error { c.retried = append(c.retried, n) return nil } +func (c *fakeCluster) RestartServer(ctx context.Context, n string) error { + return c.RetryStart(ctx, n) +} + +func TestRestartAuthorization(t *testing.T) { + for _, tc := range []struct { + name, server, role, user string + staff bool + status int + }{ + {"owner of player server", "survival", "user", "owner1", false, 202}, + {"other player", "survival", "user", "stranger", false, 403}, + {"Owner console system service", "lobby", "owner", "owner1", true, 202}, + {"player system service", "lobby", "user", "owner1", false, 400}, + } { + t.Run(tc.name, func(t *testing.T) { + a, _, cl, _ := mkFiles(t) + cl.byName[tc.server] = &ServerInfo{Name: tc.server, Phase: "Running", DesiredState: "Running", Ready: true} + a.External = staticExternal{p: &Principal{UserID: tc.user, Role: tc.role, ViaAdminAccess: tc.staff}} + w := do(a.ExternalHandler(), "POST", "/api/v1/servers/"+tc.server+"/restart", "", nil) + if w.Code != tc.status { + t.Fatalf("status=%d want=%d body=%s", w.Code, tc.status, w.Body.String()) + } + if (len(cl.retried) == 1) != (tc.status == 202) { + t.Fatalf("unauthorized restart or missing request: %v", cl.retried) + } + }) + } +} + func (c *fakeCluster) AcquireMaintenance(_ context.Context, n, kind string) error { if err := c.maintErr[n]; err != nil { return err diff --git a/internal/api/cluster.go b/internal/api/cluster.go index 4f57852..083cdd0 100644 --- a/internal/api/cluster.go +++ b/internal/api/cluster.go @@ -137,8 +137,10 @@ type Cluster interface { // also asks the operator to start it over with a fresh auto-restart budget // (v1alpha1.AnnotationStartRetry). Maintenance refuses it the same way. RetryStart(ctx context.Context, name string) error + // RestartServer requests a pod restart without changing desired state. + RestartServer(ctx context.Context, name string) error // AcquireMaintenance admits one world-volume operation (internal/maintenance - // kind): ErrNotStopped unless the server is fully stopped, a + // kind): ErrNotStopped unless fully stopped (except system config writes), a // *MaintenanceBusyError while another operation holds the volume. The check // and the lock are one atomic write against a concurrent wake. AcquireMaintenance(ctx context.Context, name, kind string) error diff --git a/internal/api/handlers_files.go b/internal/api/handlers_files.go index 9396520..69604b2 100644 --- a/internal/api/handlers_files.go +++ b/internal/api/handlers_files.go @@ -15,6 +15,7 @@ import ( "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/fileedit" "felis.lolicon.best/internal/maintenance" + "felis.lolicon.best/internal/naming" ) // FileEditor is the server-file-editor surface the API depends on: list a @@ -233,7 +234,11 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) { // beside it, and a read that overlaps a restore or another change can at worst // show a file mid-change: the sha256 it returned then no longer matches, so a // save built on it is refused with file_changed. - release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files") + kind := maintenance.KindFileWrite + if liveExperienceFile(r, name) { + kind = maintenance.KindConfigWrite + } + release, ok := a.acquireWorld(w, r, name, kind, "stop the server before editing its files") if !ok { return } @@ -582,16 +587,12 @@ var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`) // ② ServerByName — an unknown server is 404 // ③ owner-or-admin, else 403. An unowned (released) server fails for everyone // but admin, which is the same "must re-claim first" rule restore enforces -// ④ stopped gate: the world PVC is RWO and held by a running server, so a file -// Job cannot mount it — refuse unless the server is fully stopped. Ready means -// it is up; any desiredState other than Stopped means it is up or coming up -// and still owns the volume. This yields a specific 409 instead of a Job that -// silently fails to mount +// ④ stopped gate: world files must not change under a live game process. +// Only the system plugin's startup-only config may be read and saved live; +// its Job mounts the RWO volume on the same node as the game pod. // ⑤ the FileEditor must be wired, else 503 // -// Single-sourcing it is what keeps the handlers from drifting: a read path that -// forgot the stopped gate would not merely fail, it would hang waiting for a Pod -// that can never be scheduled. +// All file routes share this gate so the live-config exception stays narrow. // // It returns the validated server name and false if it has already written a // response. @@ -606,7 +607,7 @@ func (a *API) authorizeFileOp(w http.ResponseWriter, r *http.Request) (string, b a.writeLookupError(w, r, err) return "", false } - if info.Ready || info.DesiredState != string(v1alpha1.DesiredStopped) { + if !liveExperienceFile(r, name) && (info.Ready || info.DesiredState != string(v1alpha1.DesiredStopped)) { writeError(w, r, newError(http.StatusConflict, "not_stopped", "stop the server before working with its files")) return "", false @@ -636,6 +637,14 @@ func (a *API) authorizeFileOp(w http.ResponseWriter, r *http.Request) (string, b return name, true } +// Only the startup-only system plugin settings may be edited while running. +// World files, uploads, deletes and plugin binaries keep the stopped gate. +func liveExperienceFile(r *http.Request, name string) bool { + return naming.IsSystemServer(name) && strings.HasSuffix(r.URL.Path, "/file") && + (r.Method == http.MethodGet || r.Method == http.MethodPut) && + r.URL.Query().Get("path") == naming.ExperienceConfigFile +} + // writeFileEditError maps executor errors onto HTTP status codes. The // sentinels are caller-fault and get precise answers; a timeout is reported as 504 // so the caller knows to retry rather than believing the edit was rejected; and diff --git a/internal/api/handlers_files_test.go b/internal/api/handlers_files_test.go index a485dec..605265b 100644 --- a/internal/api/handlers_files_test.go +++ b/internal/api/handlers_files_test.go @@ -163,6 +163,49 @@ func mkFiles(t *testing.T) (*API, *fakeRepo, *fakeCluster, *fakeFileEditor) { return api, repo, cl, files } +func TestRunningSystemExperienceFile(t *testing.T) { + for _, name := range []string{"login", "lobby"} { + for _, tc := range []struct { + method, suffix, body string + allowed bool + }{ + {"GET", "/file?path=felis-experience.json", "", true}, + {"PUT", "/file?path=felis-experience.json", `{"content":"aGk=","content_sha256":"` + hiSum + `"}`, true}, + {"PUT", "/file?path=felis-experience.json", `{"content":"aGk=","content_sha256":"` + hiSum + `","create_only":true}`, true}, + {"PUT", "/file?path=world/level.dat", `{"content":"aGk=","content_sha256":"` + hiSum + `"}`, false}, + {"GET", "/file?path=./felis-experience.json", "", false}, + {"DELETE", "/file?path=felis-experience.json", "", false}, + {"GET", "/files", "", false}, + } { + t.Run(name+" "+tc.method+tc.suffix, func(t *testing.T) { + a, _, cl, files := mkFiles(t) + cl.byName[name] = &ServerInfo{Name: name, Phase: "Running", Ready: true, DesiredState: "Running"} + a.External = staticExternal{p: &Principal{UserID: "owner1", Role: "owner", ViaAdminAccess: true}} + w := do(a.ExternalHandler(), tc.method, "/api/v1/servers/"+name+tc.suffix, tc.body, jsonHeader) + if tc.allowed { + if w.Code != http.StatusOK || files.calls != 1 { + t.Fatalf("live config: status=%d calls=%d body=%s", w.Code, files.calls, w.Body.String()) + } + if tc.method == "PUT" && (len(cl.acquired) != 1 || cl.acquired[0] != name+":"+maintenance.KindConfigWrite) { + t.Fatalf("wrong lock: %v", cl.acquired) + } + } else if w.Code != http.StatusConflict || decodeErr(t, w) != "not_stopped" || files.calls != 0 { + t.Fatalf("world gate: status=%d calls=%d body=%s", w.Code, files.calls, w.Body.String()) + } + }) + } + } + t.Run("player cannot edit system config", func(t *testing.T) { + a, _, cl, files := mkFiles(t) + cl.byName["lobby"] = &ServerInfo{Name: "lobby", Phase: "Running", Ready: true, DesiredState: "Running"} + a.External = staticExternal{p: &Principal{UserID: "owner1", Role: "user"}} + w := do(a.ExternalHandler(), "GET", "/api/v1/servers/lobby/file?path=felis-experience.json", "", nil) + if w.Code < 400 || files.calls != 0 { + t.Fatalf("player reached config: status=%d calls=%d", w.Code, files.calls) + } + }) +} + // TestFileEditorStoppedGate is the gate this whole subsystem hinges on. The world // PVC is ReadWriteOnce, but RWO is per node: on a single node a file Job mounts it // right beside a running server, and a write lands under a live world that the diff --git a/internal/api/handlers_user.go b/internal/api/handlers_user.go index 889415b..9adafdb 100644 --- a/internal/api/handlers_user.go +++ b/internal/api/handlers_user.go @@ -92,6 +92,29 @@ func (a *API) handleWake(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusAccepted, map[string]any{"name": name, "desiredState": "Running"}) } +// handleRestart records a restart request for the operator to consume once. +func (a *API) handleRestart(w http.ResponseWriter, r *http.Request) { + name, ok := a.authorizeServerFiles(w, r) + if !ok { + return + } + rec, err := a.managedServerRecord(r.Context(), name) + if err != nil { + a.writeLookupError(w, r, err) + return + } + if rec.Retire != nil { + writeError(w, r, errServerRetiring) + return + } + if err := a.Cluster.RestartServer(r.Context(), rec.Name); err != nil { + a.writeLookupError(w, r, maintenanceError(err, "wait for maintenance to finish before restarting")) + return + } + a.audit(r, "server.restart", rec.Name) + writeJSON(w, http.StatusAccepted, map[string]string{"name": rec.Name, "desiredState": "Running"}) +} + // handleStop flips desiredState to Stopped. Only the owner or an admin may stop a // server (spec §14: operating someone else's server is admin-tier). func (a *API) handleStop(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/k8scluster.go b/internal/api/k8scluster.go index c1dc071..035d17a 100644 --- a/internal/api/k8scluster.go +++ b/internal/api/k8scluster.go @@ -3,6 +3,7 @@ package api import ( "context" "errors" + "net/http" "strings" "time" @@ -264,22 +265,29 @@ func (k *K8sCluster) SetDesiredState(ctx context.Context, name string, state v1a // against, the same as AcquireMaintenance's: whichever of a racing wake and // admission writes second gets a conflict, re-reads, and sees the other. func (k *K8sCluster) start(ctx context.Context, name string) error { - return k.startWith(ctx, name, false) + return k.startWith(ctx, name, "") } // RetryStart starts a Failed server over: the same guarded write as start, plus // v1alpha1.AnnotationStartRetry so the operator resets the restart budget and // recreates the pod. func (k *K8sCluster) RetryStart(ctx context.Context, name string) error { - return k.startWith(ctx, name, true) + return k.startWith(ctx, name, v1alpha1.AnnotationStartRetry) } -func (k *K8sCluster) startWith(ctx context.Context, name string, retryFailed bool) error { +func (k *K8sCluster) RestartServer(ctx context.Context, name string) error { + return k.startWith(ctx, name, v1alpha1.AnnotationRestart) +} + +func (k *K8sCluster) startWith(ctx context.Context, name, annotation string) error { return retry.RetryOnConflict(retry.DefaultRetry, func() error { var ms v1alpha1.MinecraftServer if err := k.getServer(ctx, name, &ms); err != nil { return err } + if annotation == v1alpha1.AnnotationRestart && (ms.Spec.DesiredState != v1alpha1.DesiredRunning || ms.Status.Phase != v1alpha1.PhaseRunning) { + return newError(http.StatusConflict, "not_running", "start the server before restarting it") + } node := ms.Spec.NodeName if node == "" { node = ms.Status.NodeName @@ -308,11 +316,11 @@ func (k *K8sCluster) startWith(ctx context.Context, name string, retryFailed boo // A lock still on the object here no longer holds anything (Holder said // so): drop it in the same write. delete(ms.Annotations, maintenance.Annotation) - if retryFailed { + if annotation != "" { if ms.Annotations == nil { ms.Annotations = map[string]string{} } - ms.Annotations[v1alpha1.AnnotationStartRetry] = k.clock().UTC().Format(time.RFC3339) + ms.Annotations[annotation] = k.clock().UTC().Format(time.RFC3339Nano) } return k.c.Patch(ctx, &ms, patch) }) @@ -320,8 +328,9 @@ func (k *K8sCluster) startWith(ctx context.Context, name string, retryFailed boo // AcquireMaintenance admits one world-volume operation of the given kind: the // server must be fully stopped (desiredState Stopped, phase Stopped, and no game -// pod left, so a pod still saving on its way down is waited out) and nothing else -// may hold the volume. Admission writes the maintenance lock under the resourceVersion it +// pod left, so a pod still saving on its way down is waited out). System config +// writes may run beside the game pod, but not during a pending restart. Nothing +// else may hold the volume. Admission writes the lock under the resourceVersion it // checked; the caller creates its Job and then calls ReleaseMaintenance, after // which the Job itself is the lock. func (k *K8sCluster) AcquireMaintenance(ctx context.Context, name, kind string) error { @@ -334,13 +343,18 @@ func (k *K8sCluster) AcquireMaintenance(ctx context.Context, name, kind string) if desired == "" { desired = v1alpha1.DesiredStopped } - if desired != v1alpha1.DesiredStopped || ms.Status.Ready || ms.Status.Phase != v1alpha1.PhaseStopped { - return ErrNotStopped + if kind != maintenance.KindConfigWrite || !naming.IsSystemServer(name) { + if desired != v1alpha1.DesiredStopped || ms.Status.Ready || ms.Status.Phase != v1alpha1.PhaseStopped { + return ErrNotStopped + } + if up, err := k.gamePodExists(ctx, name); err != nil { + return err + } else if up { + return ErrNotStopped + } } - if up, err := k.gamePodExists(ctx, name); err != nil { - return err - } else if up { - return ErrNotStopped + if ms.Annotations[v1alpha1.AnnotationRestart] != "" { + return ErrMaintenanceInProgress } holder, held, err := k.maintenanceHolder(ctx, &ms) if err != nil { diff --git a/internal/api/k8scluster_maintenance_test.go b/internal/api/k8scluster_maintenance_test.go index efe734a..1986f57 100644 --- a/internal/api/k8scluster_maintenance_test.go +++ b/internal/api/k8scluster_maintenance_test.go @@ -35,6 +35,54 @@ func stoppedServer() *v1alpha1.MinecraftServer { } } +func TestSystemConfigSaveAndRestartAdmission(t *testing.T) { + ctx := context.Background() + ms := stoppedServer() + ms.Name = "lobby" + ms.Spec.DesiredState = v1alpha1.DesiredRunning + ms.Status.Phase, ms.Status.Ready = v1alpha1.PhaseRunning, true + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "lobby-0", Namespace: "minecraft", + Labels: map[string]string{v1alpha1.LabelServer: "lobby", v1alpha1.LabelComponent: gamePodComponent}}} + k, c := lockCluster(t, ms, pod) + if err := k.AcquireMaintenance(ctx, "lobby", maintenance.KindConfigWrite); err != nil { + t.Fatalf("save beside running pod: %v", err) + } + if err := k.RestartServer(ctx, "lobby"); !errors.Is(err, ErrMaintenanceInProgress) { + t.Fatalf("restart during save: %v", err) + } + if err := k.ReleaseMaintenance(ctx, "lobby"); err != nil { + t.Fatal(err) + } + if err := k.RestartServer(ctx, "lobby"); err != nil { + t.Fatalf("restart after save: %v", err) + } + var got v1alpha1.MinecraftServer + if err := c.Get(ctx, client.ObjectKeyFromObject(ms), &got); err != nil { + t.Fatal(err) + } + if got.Annotations[v1alpha1.AnnotationRestart] == "" || got.Spec.DesiredState != v1alpha1.DesiredRunning { + t.Fatalf("restart not recorded durably: %+v", got) + } + if err := c.Get(ctx, client.ObjectKeyFromObject(pod), &corev1.Pod{}); err != nil { + t.Fatalf("API must leave pod deletion to operator: %v", err) + } + if err := k.AcquireMaintenance(ctx, "lobby", maintenance.KindConfigWrite); !errors.Is(err, ErrMaintenanceInProgress) { + t.Fatalf("save racing a pending restart: %v", err) + } + + userServer := stoppedServer() + userServer.Spec.DesiredState = v1alpha1.DesiredRunning + userServer.Status.Phase = v1alpha1.PhaseRunning + k, _ = lockCluster(t, userServer) + if err := k.AcquireMaintenance(ctx, "survival", maintenance.KindConfigWrite); !errors.Is(err, ErrNotStopped) { + t.Fatalf("live config exception reached a player world: %v", err) + } + k, _ = lockCluster(t, stoppedServer()) + if err := k.RestartServer(ctx, "survival"); err == nil { + t.Fatal("restart admitted a stopped server") + } +} + func lockCluster(t *testing.T, objs ...client.Object) (*K8sCluster, client.Client) { t.Helper() scheme := runtime.NewScheme() diff --git a/internal/api/maintenance.go b/internal/api/maintenance.go index 6e2cf2b..3d9cdd3 100644 --- a/internal/api/maintenance.go +++ b/internal/api/maintenance.go @@ -37,6 +37,8 @@ func maintenanceLabel(kind string) string { return "a backup" case maintenance.KindFileWrite: return "a file write" + case maintenance.KindConfigWrite: + return "a settings save" case maintenance.KindExport: return "a world export or file download" case maintenance.KindReap: diff --git a/internal/apis/felis/v1alpha1/minecraftserver_types.go b/internal/apis/felis/v1alpha1/minecraftserver_types.go index 3046eb0..07f3080 100644 --- a/internal/apis/felis/v1alpha1/minecraftserver_types.go +++ b/internal/apis/felis/v1alpha1/minecraftserver_types.go @@ -39,6 +39,8 @@ const ( // the automatic restarts were spent. The operator takes the request once — // fresh restart budget, new start anchor, pod recreated — and removes it. AnnotationStartRetry = GroupName + "/start-retry" + // AnnotationRestart requests one pod recreation while keeping desiredState Running. + AnnotationRestart = GroupName + "/restart" ) // ForwardingLegacy is the LabelForwarding value that selects legacy forwarding. diff --git a/internal/fileedit/exec.go b/internal/fileedit/exec.go index d3fcdd2..98893ac 100644 --- a/internal/fileedit/exec.go +++ b/internal/fileedit/exec.go @@ -529,6 +529,9 @@ func write(r *os.Root, name string, content []byte, sum, expect string, createOn if res.Code != "" { return res } + if name == naming.ExperienceConfigFile && target != name { + return Result{Code: CodeBadPath, Error: "the experience config must be a regular file, not a symlink"} + } if expect != "" { if res := checkUnchanged(r, name, target, expect); res.Code != "" { return res diff --git a/internal/fileedit/exec_test.go b/internal/fileedit/exec_test.go index 153a8d4..b1b4169 100644 --- a/internal/fileedit/exec_test.go +++ b/internal/fileedit/exec_test.go @@ -45,6 +45,21 @@ func run(root, op, path string, content []byte, expect string) (Result, error) { return Execute(root, Request{Op: op, Path: path, Content: content, Expect: expect}) } +func TestExperienceConfigCannotWriteThroughSymlink(t *testing.T) { + root, _ := worldRoot(t) + if err := os.Symlink("server.properties", filepath.Join(root, "felis-experience.json")); err != nil { + t.Fatal(err) + } + res, err := run(root, OpWrite, "felis-experience.json", []byte("{}"), "") + if err != nil || res.Code != CodeBadPath { + t.Fatalf("symlink write: result=%+v err=%v", res, err) + } + content, err := os.ReadFile(filepath.Join(root, "server.properties")) + if err != nil || string(content) != "motd=hello\n" { + t.Fatalf("live world file was changed: content=%q err=%v", content, err) + } +} + // TestExecuteContainment is the security test of this package. The world directory // holds attacker-influenced content (players and plugins create files in it), so // each vector below is a path a caller could genuinely supply to try to leave the diff --git a/internal/maintenance/maintenance.go b/internal/maintenance/maintenance.go index 7d01ab1..a8c41a4 100644 --- a/internal/maintenance/maintenance.go +++ b/internal/maintenance/maintenance.go @@ -90,11 +90,12 @@ const ( // Kinds of holder. const ( - KindMigration = "migration" - KindRestore = "restore" - KindBackup = "backup" - KindFileWrite = "file-write" - KindExport = "export" + KindMigration = "migration" + KindRestore = "restore" + KindBackup = "backup" + KindFileWrite = "file-write" + KindConfigWrite = "config-write" + KindExport = "export" // KindReap is the reaper archiving an idle world and reclaiming its volume. // It runs no Job: the reaper holds the Annotation itself and rewrites it // well inside Grace for as long as it works on the world. diff --git a/internal/naming/naming.go b/internal/naming/naming.go index 164d323..cb3e945 100644 --- a/internal/naming/naming.go +++ b/internal/naming/naming.go @@ -46,6 +46,8 @@ const ( // reachable only after the login gate passes a player through, so it must // never be used as a fallback target (that would bypass the gate). SystemLobbyServer = "lobby" + // ExperienceConfigFile is read by the system plugins only at startup. + ExperienceConfigFile = "felis-experience.json" ) // IsSystemServer identifies platform services whose reserved names may be managed diff --git a/internal/operator/reconciler.go b/internal/operator/reconciler.go index 046eb65..260d1bb 100644 --- a/internal/operator/reconciler.go +++ b/internal/operator/reconciler.go @@ -218,9 +218,11 @@ func (r *Reconciler) reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu desired = v1alpha1.DesiredStopped } prevPhase, prevRestarts := server.Status.Phase, server.Status.AutoRestarts - if _, ok := server.Annotations[v1alpha1.AnnotationStartRetry]; ok { - if err := r.takeStartRetry(ctx, &server, desired); err != nil { - return ctrl.Result{}, err + for _, annotation := range []string{v1alpha1.AnnotationStartRetry, v1alpha1.AnnotationRestart} { + if _, ok := server.Annotations[annotation]; ok { + if err := r.takeRestartRequest(ctx, &server, desired, annotation); err != nil { + return ctrl.Result{}, err + } } } var res ctrl.Result @@ -263,30 +265,34 @@ func (r *Reconciler) recordTransition(ctx context.Context, server *v1alpha1.Mine r.event(server, eventType, reason, fmt.Sprintf("%s → %s: %s", from, phase, msg)) } -// takeStartRetry answers felis-api's AnnotationStartRetry: a server still Failed -// and meant to run starts over as if freshly woken — pod recreated, restart -// budget back to zero, a new start anchor — and in any other state the request -// is stale and only removed. The fresh status is written before the request is +// takeRestartRequest recreates a Failed pod for a start retry, or a Running pod +// for an explicit restart. A request overtaken by a stop is only removed. +// The fresh status is written before the request is // removed, so a pass that fails in between leaves the request to be taken again // (at the cost of one more pod recreate), never a removed request with the old // spent budget still in place. -func (r *Reconciler) takeStartRetry(ctx context.Context, server *v1alpha1.MinecraftServer, desired v1alpha1.DesiredState) error { - if desired == v1alpha1.DesiredRunning && server.Status.Phase == v1alpha1.PhaseFailed { +func (r *Reconciler) takeRestartRequest(ctx context.Context, server *v1alpha1.MinecraftServer, desired v1alpha1.DesiredState, annotation string) error { + explicit := annotation == v1alpha1.AnnotationRestart + if desired == v1alpha1.DesiredRunning && (server.Status.Phase == v1alpha1.PhaseFailed || (explicit && server.Status.Phase == v1alpha1.PhaseRunning)) { pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: server.Name + "-0", Namespace: server.Namespace}} if err := r.Delete(ctx, pod); client.IgnoreNotFound(err) != nil { return err } server.Status.AutoRestarts = 0 server.Status.StartRequestedAt = nil - r.markStarting(server, "StartRetried", "start requested again after it failed; recreated the pod") + reason, message := "StartRetried", "start requested again after it failed; recreated the pod" + if explicit { + reason, message = "RestartRequested", "restart requested; recreated the pod" + } + r.markStarting(server, reason, message) if err := r.patchStatus(ctx, server); err != nil { return err } - log.FromContext(ctx).Info("start retried") - r.event(server, corev1.EventTypeNormal, "StartRetried", "start requested again after it failed; recreated the pod") + log.FromContext(ctx).Info(message) + r.event(server, corev1.EventTypeNormal, reason, message) } patch := client.MergeFrom(server.DeepCopy()) - delete(server.Annotations, v1alpha1.AnnotationStartRetry) + delete(server.Annotations, annotation) return r.Patch(ctx, server, patch) } diff --git a/internal/operator/startretry_test.go b/internal/operator/startretry_test.go index 89f8956..376d5b1 100644 --- a/internal/operator/startretry_test.go +++ b/internal/operator/startretry_test.go @@ -37,6 +37,38 @@ func podPresent(t *testing.T, c client.Client) bool { return err == nil } +func TestExplicitRestartIsConsumedOnce(t *testing.T) { + srv := runningServer() + srv.Status.Phase = v1alpha1.PhaseRunning + srv.Annotations = map[string]string{v1alpha1.AnnotationRestart: "2026-07-01T12:00:00Z"} + r, c := newReconciler(t, fakeProber{}, srv, rconSecret(), gamePod()) + reconcile(t, r, "survival") + s := getServer(t, c, "survival") + if s.Annotations[v1alpha1.AnnotationRestart] != "" || s.Spec.DesiredState != v1alpha1.DesiredRunning || s.Status.Phase != v1alpha1.PhaseStarting || podPresent(t, c) { + t.Fatalf("restart: desired=%s phase=%s request=%s pod=%v", s.Spec.DesiredState, s.Status.Phase, s.Annotations[v1alpha1.AnnotationRestart], podPresent(t, c)) + } + if err := c.Create(context.Background(), gamePod()); err != nil { + t.Fatal(err) + } + reconcile(t, r, "survival") + if !podPresent(t, c) { + t.Fatal("consumed request deleted the replacement pod") + } +} + +func TestStopSupersedesExplicitRestart(t *testing.T) { + srv := runningServer() + srv.Spec.DesiredState = v1alpha1.DesiredStopped + srv.Status.Phase = v1alpha1.PhaseRunning + srv.Annotations = map[string]string{v1alpha1.AnnotationRestart: "2026-07-01T12:00:00Z"} + r, c := newReconciler(t, fakeProber{}, srv, rconSecret(), gamePod()) + reconcile(t, r, "survival") + s := getServer(t, c, "survival") + if s.Annotations[v1alpha1.AnnotationRestart] != "" || s.Spec.DesiredState != v1alpha1.DesiredStopped || s.Status.Phase == v1alpha1.PhaseStarting { + t.Fatalf("restart overrode stop: desired=%s phase=%s annotations=%v", s.Spec.DesiredState, s.Status.Phase, s.Annotations) + } +} + // Once the automatic restarts are spent, a retry request starts the server over // with the whole budget back: the pod is recreated, the start re-anchored, and // the next timeout is retried automatically again. diff --git a/internal/platform/rbac.go b/internal/platform/rbac.go index ab0ca82..bcc8039 100644 --- a/internal/platform/rbac.go +++ b/internal/platform/rbac.go @@ -90,8 +90,8 @@ func ControlPlaneRBAC(p Params) RBAC { // claim. felis-api reads the fleet (velocity's pull, the fleet page, the wake // cap) from an informer cache of minecraftservers, hence watch on that one // resource; everything else goes through a DIRECT client, so it needs no -// list/watch beyond the explicit List calls — and the PVC grant is get-only, -// mirroring that. +// list/watch beyond the explicit List calls. PVC list finds retained world +// volumes before creating a server of the same name. // // The read-side grant is deliberately minimal: pods:list + pods/log:get, NOT // pods:get — the streamer lists pods by the server label then reads the chosen @@ -103,9 +103,7 @@ func APIMinecraftRole(p Params) *rbacv1.Role { rules := []rbacv1.PolicyRule{ rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "list", "watch", "create", "patch"}), rule([]string{groupCore}, []string{"secrets"}, []string{"get"}), - // get-only: WorldVolumeExists does a single direct Get of the world PVC; - // nothing in felis-api lists or deletes PVCs. - rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"get"}), + rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"get", "list"}), // list backs GET /servers/{name}/jobs — the async status outlet reads the // backup/restore Jobs back by the server label — and finds the pending // restore chains; patch settles a chain by relabelling its safety-snapshot diff --git a/internal/platform/rbac_test.go b/internal/platform/rbac_test.go index 1756f94..5f14ec0 100644 --- a/internal/platform/rbac_test.go +++ b/internal/platform/rbac_test.go @@ -127,13 +127,15 @@ func TestAPIRole_MinecraftPowersExact(t *testing.T) { if hasRule(mc, groupCore, "secrets", "list") || hasRule(mc, groupCore, "secrets", "watch") { t.Error("felis-api must NOT list or watch secrets (RCON reads are a direct Get by name)") } - // WorldVolumeExists (backup/restore pre-gate) does a single direct PVC Get; - // nothing in felis-api lists or deletes claims. + // WorldVolumeExists reads a claim and lists retained claims on server creation. if !hasRule(mc, groupCore, "persistentvolumeclaims", "get") { t.Error("felis-api must get the world PVC (persistentvolumeclaims:get) for the backup/restore world-volume gate") } - if hasRule(mc, groupCore, "persistentvolumeclaims", "list") || hasRule(mc, groupCore, "persistentvolumeclaims", "delete") { - t.Error("felis-api must NOT list or delete PVCs (the gate is a single direct Get)") + if !hasRule(mc, groupCore, "persistentvolumeclaims", "list") { + t.Error("felis-api must list retained PVCs before creating a server") + } + if hasRule(mc, groupCore, "persistentvolumeclaims", "watch") || hasRule(mc, groupCore, "persistentvolumeclaims", "delete") { + t.Error("felis-api must NOT watch or delete PVCs") } // Read-side console (spec §8 读=pods/log follow): list pods to find the // running pod, then read its log subresource — and nothing wider. diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index c6d62fb..96e4907 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -2267,7 +2267,7 @@ async function handleServerRoute(ctx: SessionContext): Promise { streamConsole(ctx.req, ctx.res, serverInfo); return true; } - if (is("POST", ctx) && ctx.parts[4] === "wake") { + if (is("POST", ctx) && (ctx.parts[4] === "wake" || ctx.parts[4] === "restart")) { if (!canManage(ctx.account, serverInfo)) { sendError(ctx.res, 403, "forbidden", "server is not owned by this account"); return true; @@ -2276,8 +2276,12 @@ async function handleServerRoute(ctx: SessionContext): Promise { sendError(ctx.res, 409, "server_retiring", "this server is being given up or deleted; cancel that first"); return true; } + if (ctx.parts[4] === "restart" && serverInfo.phase !== "Running") { + sendError(ctx.res, 409, "not_running", "start the server before restarting it"); + return true; + } setPhase(serverInfo, "Starting"); - sendJSON(ctx.res, 200, { name: serverInfo.name, desiredState: "Running" }); + sendJSON(ctx.res, 202, { name: serverInfo.name, desiredState: "Running" }); return true; } if (is("POST", ctx) && ctx.parts[4] === "stop") { @@ -2454,11 +2458,13 @@ async function handleFilesMock(ctx: SessionContext, serverInfo: MockServer): Pro sendError(ctx.res, 403, "forbidden", "server is not owned by this account"); return true; } - if (serverInfo.phase !== "Stopped") { + const url = new URL(ctx.req.url ?? "/", "http://localhost"); + const liveConfig = ["login", "lobby"].includes(serverInfo.name) && + ["GET file", "PUT file"].includes(key) && url.searchParams.get("path") === "felis-experience.json"; + if (!liveConfig && serverInfo.phase !== "Stopped") { sendError(ctx.res, 409, "not_stopped", "stop the server before editing its files"); return true; } - const url = new URL(ctx.req.url ?? "/", "http://localhost"); const tree = filesOf(ctx.state, serverInfo.name); const p = cleanFilePath(ctx, url.searchParams.get("path"), key === "GET files"); if (p === null) return true; diff --git a/panel/e2e/smoke.spec.ts b/panel/e2e/smoke.spec.ts index a23dba8..73dfe93 100644 --- a/panel/e2e/smoke.spec.ts +++ b/panel/e2e/smoke.spec.ts @@ -1,5 +1,24 @@ import { test, expect, t, expectFitsScreen } from "./fixtures"; +test("login is the default space; live lobby settings save before a confirmed restart", async ({ page, signIn }) => { + await signIn("owner"); + await page.goto("/admin/lobby"); + await expect(page.getByRole("button", { name: t("lobby:login"), exact: true })).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByLabel(t("lobby:bookTitle"))).toBeVisible(); + await page.getByRole("button", { name: t("lobby:lobby"), exact: true }).click(); + await page.getByLabel(t("lobby:menuTitleEn")).fill("Our Network"); + await expect(page.getByRole("button", { name: t("lobby:restart"), exact: true })).toBeDisabled(); + await page.getByRole("button", { name: t("lobby:save"), exact: true }).click(); + await expect(page.getByText(t("lobby:saved"), { exact: true })).toBeVisible(); + await expectFitsScreen(page); + await page.getByRole("button", { name: t("lobby:restart"), exact: true }).click(); + const dialog = page.getByRole("dialog", { name: t("lobby:restart_title") }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: t("lobby:restart"), exact: true }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.getByText(t("lobby:restart_requested"), { exact: true })).toBeVisible(); +}); + test("a signed-out visit signs in by email code and returns to the page it asked for", async ({ page }) => { await page.goto("/servers"); await expect(page).toHaveURL(/\/login\?next=%2Fservers$/); diff --git a/panel/src/components/players/WakeListSection.test.tsx b/panel/src/components/players/WakeListSection.test.tsx index 43bc253..1d13f3f 100644 --- a/panel/src/components/players/WakeListSection.test.tsx +++ b/panel/src/components/players/WakeListSection.test.tsx @@ -104,7 +104,7 @@ describe("WakeListSection", () => { calls.serverAllowlist.mockRejectedValue({ status: 500, code: "internal", message: "db down" }); render(); expect((await screen.findByRole("alert")).textContent).toBe( - "Couldn't load the wake list. The service is unavailable right now (it may be restarting or upgrading). Try again shortly.", + "Couldn't load the wake list. The operation failed because of an internal server error. Ask an admin to check the logs.", ); }); diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index 2289e1a..7472dd4 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -121,7 +121,9 @@ "auth_unavailable": "The sign-in service isn't available right now.", "setup_token_invalid": "That setup token is invalid or expired.", "network_error": "Can't reach Felis: the network is down, or your Cloudflare Access sign-in expired. Reload the page to sign in again.", - "upstream_unavailable": "The service is unavailable right now (it may be restarting or upgrading). Try again shortly.", + "upstream_unavailable": "The service is unavailable right now. Try again shortly.", + "internal_error": "The operation failed because of an internal server error. Ask an admin to check the logs.", + "request_id": "Request ID: {{id}}", "bad_path_param": "The name in this link is not valid. Open it again from the list.", "payload_too_large": "That is larger than the entry proxy accepts, so it was not sent.", "passkey_no_credential": "The browser returned no passkey. Try again.", diff --git a/panel/src/i18n/resources/en-US/lobby.json b/panel/src/i18n/resources/en-US/lobby.json index 65ff7c2..d97dbdd 100644 --- a/panel/src/i18n/resources/en-US/lobby.json +++ b/panel/src/i18n/resources/en-US/lobby.json @@ -13,7 +13,13 @@ "stop_login": "Stop the login space to read and save settings. New players cannot join during maintenance; start it again when finished.", "stop_lobby": "Stop the lobby to read and save settings. Back up its map before maintenance, then start it again when finished.", "read_failed": "Could not read settings: {{reason}}. Check felis-experience.json in the file manager.", - "saved": "Saved. Start this space to apply the settings.", + "saved": "Saved. Settings apply on the next start or restart.", + "restart": "Restart & apply", + "restarting": "Restarting…", + "restart_requested": "Restart requested. Follow the startup status above.", + "restart_title": "Restart this space?", + "restart_hint": "Players here will be disconnected briefly. Saved settings take effect when this space starts again.", + "restart_required": "Settings saved; restart to apply", "save": "Save settings", "saving": "Saving…", "invalid_values": "Check numeric ranges and selected options. Text is limited to 512 characters.", @@ -88,5 +94,5 @@ "loginBook_description": "Edit the guidance players see in the login book.", "content_tools": "Content & maintenance", "unsaved": "You have unsaved changes", - "apply_on_start": "Saved settings apply when this space starts" + "apply_on_start": "Save while running; settings take effect on the next start or restart" } diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index 524a966..50f42df 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -121,7 +121,9 @@ "auth_unavailable": "登录服务当前不可用。", "setup_token_invalid": "初始化令牌无效或已过期。", "network_error": "连不上 Felis:网络断开了,或者 Cloudflare Access 的登录已过期。刷新页面可以重新登录。", - "upstream_unavailable": "服务暂时不可用(可能正在重启或升级),请稍后重试。", + "upstream_unavailable": "服务暂时不可用,请稍后重试。", + "internal_error": "操作失败,服务器出现内部错误。请联系管理员查看日志。", + "request_id": "请求编号:{{id}}", "bad_path_param": "链接里的名称无效,请从列表重新打开。", "payload_too_large": "内容超过了入口允许的大小,没有发送成功。", "passkey_no_credential": "浏览器没有返回 Passkey,请重试。", diff --git a/panel/src/i18n/resources/zh-CN/lobby.json b/panel/src/i18n/resources/zh-CN/lobby.json index dad0de1..6954a88 100644 --- a/panel/src/i18n/resources/zh-CN/lobby.json +++ b/panel/src/i18n/resources/zh-CN/lobby.json @@ -13,7 +13,13 @@ "stop_login": "停止登录空间后可读取和保存配置。维护期间新玩家无法进入;完成后请启动登录空间。", "stop_lobby": "停止大厅后可读取和保存配置。维护前可先备份地图,完成后请启动大厅。", "read_failed": "读取设置失败:{{reason}}。可在文件管理中检查 felis-experience.json。", - "saved": "已保存。启动该空间后生效。", + "saved": "已保存,下次启动或重启后生效。", + "restart": "重启并应用", + "restarting": "正在重启…", + "restart_requested": "已发送重启请求,可在上方查看启动状态。", + "restart_title": "重启该空间?", + "restart_hint": "重启会暂时断开这里的玩家,重新启动后应用已保存的设置。", + "restart_required": "设置已保存,等待重启生效", "save": "保存设置", "saving": "正在保存…", "invalid_values": "请检查数字范围和选项,文案最多 512 个字符。", @@ -88,5 +94,5 @@ "loginBook_description": "编辑玩家打开登录书时看到的引导内容。", "content_tools": "内容与维护", "unsaved": "有未保存的更改", - "apply_on_start": "保存的设置将在启动后生效" + "apply_on_start": "可以在运行中保存;设置将在下次启动或重启后生效" } diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index 351af1e..2b25f54 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -1102,9 +1102,21 @@ describe("responses that are not the API's JSON", () => { }); it("maps an unknown 5xx code to the unavailable line", () => { - expect(humanizeError({ status: 500, code: "internal", message: "internal error" })).toMatch(/unavailable/i); + expect(humanizeError({ status: 500, code: "unknown", message: "internal error" })).toMatch(/unavailable/i); expect(humanizeError({ status: 413, code: "error", message: "Payload Too Large" })).toMatch(/larger/i); }); + + it("keeps an internal error's request ID without exposing backend details", async () => { + vi.stubGlobal("fetch", vi.fn(async () => ({ + ok: false, status: 500, + text: async () => JSON.stringify({ error: { code: "internal", message: "database credentials rejected", request_id: "req-123" } }), + }))); + const err = await api.status("lobby").catch((e) => e); + expect(err.request_id).toBe("req-123"); + expect(humanizeError(err)).toMatch(/internal server error/); + expect(humanizeError(err)).toContain("req-123"); + expect(humanizeError(err)).not.toContain("credentials"); + }); }); describe("session and connection signals", () => { diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index e888326..9dff7fe 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -56,7 +56,7 @@ import i18next from "i18next"; // the upstream Zero-Trust / Access cookie rides along; the panel never holds a // service token, and the RCON password is never requested (spec §8). -function isApiError(x: unknown): x is { error: { code: string; message: string } } { +function isApiError(x: unknown): x is { error: { code: string; message: string; request_id?: string } } { if (typeof x !== "object" || x === null || !("error" in x)) return false; const e = (x as { error: unknown }).error; return typeof e === "object" && e !== null && typeof (e as { code?: unknown }).code === "string"; @@ -193,7 +193,7 @@ function failed(path: string, status: number, statusText: string, text: string): /* no body, or not JSON: an ingress or tunnel answered */ } const err: ApiError = isApiError(parsed) - ? { status, code: parsed.error.code, message: parsed.error.message } + ? { status, code: parsed.error.code, message: parsed.error.message, ...(typeof parsed.error.request_id === "string" && { request_id: parsed.error.request_id }) } : { status, code: status >= 500 ? "upstream_unavailable" : "error", @@ -447,6 +447,9 @@ export const api = rejectingSync({ stop: (name: string) => request<{ name: string; desiredState: string }>("POST", urlPath`/servers/${name}/stop`), + restart: (name: string) => + request<{ name: string; desiredState: string }>("POST", urlPath`/servers/${name}/restart`), + claim: (name: string) => request<{ name: string; claimed: boolean }>("POST", urlPath`/servers/${name}/claim`), @@ -1567,6 +1570,8 @@ export function humanizeError(e: unknown): string { return err.message ? t("bad_request", { detail: err.message }) : t("generic"); case "bad_schedule": return err.message ? t("bad_schedule", { detail: err.message }) : t("generic"); + case "internal": + return t("internal_error") + (err.request_id ? " " + t("request_id", { id: err.request_id }) : ""); default: if (err.status === 401) return t("session_expired"); if (err.status === 403) return t("forbidden"); diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index 6cc10d9..89a593b 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -580,6 +580,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/servers/{name}/restart": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Restart your own running server, or a system service as staff. + * @description Records a durable request for the operator to gracefully recreate the game pod. Desired state remains Running. A concurrent stop supersedes the request; maintenance blocks admission. + */ + post: operations["restart"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/servers/{name}/claim": { parameters: { query?: never; @@ -1495,13 +1515,13 @@ export interface paths { cookie?: never; }; /** - * Read a file from a server's world volume (owner-or-admin; server must be stopped). - * @description Returns one file's bytes, base64-encoded, from inside the server's world volume. Same stopped-gate and os.Root containment as the directory listing. Reads are capped at 1 MiB; a larger file is 413 rather than a truncated read, because a config editor that silently returned half a file would let a subsequent save destroy the other half. + * Read a file from a server's world volume (owner-or-admin). + * @description Returns one file's bytes, base64-encoded, from inside the server's world volume. Same stopped-gate and os.Root containment as the directory listing, except staff may read login/lobby's felis-experience.json while running. Reads are capped at 1 MiB; a larger file is 413 rather than a truncated read, because a config editor that silently returned half a file would let a subsequent save destroy the other half. */ get: operations["readServerFile"]; /** - * Write a file in a server's world volume (owner-or-admin; server must be stopped). - * @description Replaces a file's contents, creating the file if absent but never creating its parent directories. Content is base64 so arbitrary bytes (CRLF endings, a BOM) survive intact. Writes are capped at 256 KiB — the Job spec carries the content, and etcd bounds the object — so a larger body is 413. Same stopped-gate and os.Root containment as the read; a write through a symlink leaving the world root is refused. The replacement is atomic (a synced temporary sibling renamed over the file, keeping its mode), so a failed write leaves the old file whole. With expect_sha256 the write lands only if the file still has that hash; otherwise 409 file_changed. content_sha256 is the SHA-256 of the content: content that hashes otherwise changed on the way and is refused (400 digest_mismatch) before a Job starts, and the Job checks the bytes it received the same way before writing. Audited as file.write. + * Write a file in a server's world volume (owner-or-admin). + * @description Replaces a file's contents, creating the file if absent but never creating its parent directories. Content is base64 so arbitrary bytes (CRLF endings, a BOM) survive intact. Writes are capped at 256 KiB — the Job spec carries the content, and etcd bounds the object — so a larger body is 413. Same stopped-gate and os.Root containment as the read; a write through a symlink leaving the world root is refused. The replacement is atomic (a synced temporary sibling renamed over the file, keeping its mode), so a failed write leaves the old file whole. With expect_sha256 the write lands only if the file still has that hash; otherwise 409 file_changed. content_sha256 is the SHA-256 of the content: content that hashes otherwise changed on the way and is refused (400 digest_mismatch) before a Job starts, and the Job checks the bytes it received the same way before writing. Staff may save login/lobby's startup-only felis-experience.json while running; restart to apply. That config cannot be a symlink. Audited as file.write. */ put: operations["writeServerFile"]; post?: never; @@ -4927,6 +4947,45 @@ export interface operations { 404: components["responses"]["NotFound"]; }; }; + restart: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Restart accepted. */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + name: string; + /** @constant */ + desiredState: "Running"; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + /** @description Server is not running, is retiring, or maintenance is in progress. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; claim: { parameters: { query?: never; @@ -7286,7 +7345,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Server is not stopped (its world PVC is still mounted). */ + /** @description Server is not stopped (except startup-only system experience config). */ 409: { headers: { [name: string]: unknown; diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index 17d2a28..0ff0d8b 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -391,6 +391,7 @@ export interface ApiError { status: number; code: string; message: string; + request_id?: string; } /** Identity mirrors GET /api/v1/me (app-tier — every authenticated principal may diff --git a/panel/src/pages/admin/BuildScanPanel.test.tsx b/panel/src/pages/admin/BuildScanPanel.test.tsx index b52b2b9..875138a 100644 --- a/panel/src/pages/admin/BuildScanPanel.test.tsx +++ b/panel/src/pages/admin/BuildScanPanel.test.tsx @@ -156,7 +156,7 @@ describe("BuildScanPanel", () => { calls.getBuildScan.mockResolvedValueOnce(BLOCKED); render(); expect((await screen.findByRole("alert")).textContent).toBe( - "Couldn't load the security scan: The service is unavailable right now (it may be restarting or upgrading). Try again shortly.", + "Couldn't load the security scan: The operation failed because of an internal server error. Ask an admin to check the logs.", ); await userEvent.click(screen.getByRole("button", { name: "Try again" })); expect(await screen.findByRole("region", { name: "Security scan" })).toBeTruthy(); diff --git a/panel/src/pages/admin/LobbyPage.test.tsx b/panel/src/pages/admin/LobbyPage.test.tsx index 2fc0711..d793cd4 100644 --- a/panel/src/pages/admin/LobbyPage.test.tsx +++ b/panel/src/pages/admin/LobbyPage.test.tsx @@ -1,12 +1,12 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import { LobbyPage } from "./LobbyPage"; const calls = vi.hoisted(() => ({ - status: vi.fn(), listImages: vi.fn(), readServerFile: vi.fn(), writeServerFile: vi.fn(), createServerFile: vi.fn(), accessPermission: vi.fn(), + status: vi.fn(), restart: vi.fn(), listImages: vi.fn(), readServerFile: vi.fn(), writeServerFile: vi.fn(), createServerFile: vi.fn(), accessPermission: vi.fn(), })); vi.mock("@/lib/api", async (original) => ({ ...await original(), api: calls })); vi.mock("@/lib/config", async (original) => ({ ...await original(), loadConfig: () => Promise.resolve({ apiBase: "/api/v1", rootDomain: "example.test", gameVersion: "26.3" }) })); @@ -18,10 +18,11 @@ beforeEach(() => { calls.readServerFile.mockResolvedValue({ content: btoa(JSON.stringify({ menuTitleEn: "Old title", customPlugin: { enabled: true } })), sha256: "read-hash" }); calls.writeServerFile.mockResolvedValue({ sha256: "saved-hash" }); calls.createServerFile.mockResolvedValue({ sha256: "saved-hash" }); + calls.restart.mockResolvedValue({ name: "lobby", desiredState: "Running" }); }); function page(space = "lobby") { - render(); + render(); } describe("LobbyPage", () => { @@ -29,7 +30,7 @@ describe("LobbyPage", () => { page(); fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } }); await userEvent.click(screen.getByRole("button", { name: "Save settings" })); - await screen.findByText("Saved. Start this space to apply the settings."); + await screen.findByText("Saved. Settings apply on the next start or restart."); const [name, path, content, hash] = calls.writeServerFile.mock.calls[0]; expect([name, path, hash]).toEqual(["lobby", "felis-experience.json", "read-hash"]); expect(JSON.parse(atob(content))).toEqual({ menuTitleEn: "My Network", customPlugin: { enabled: true } }); @@ -45,12 +46,37 @@ describe("LobbyPage", () => { expect(calls.writeServerFile).not.toHaveBeenCalled(); }); - it("requires a stop before reading or writing a running space", async () => { - calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true }); + it("opens the leftmost login space by default", async () => { + page(""); + await screen.findByLabelText("Login book title"); + expect(calls.status).toHaveBeenCalledWith("login"); + expect(screen.getByRole("button", { name: "Login space" }).getAttribute("aria-pressed")).toBe("true"); + }); + + it("saves while running and restarts only when requested", async () => { + calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true, playersOnline: 0 }); page(); - await screen.findByText(/Stop the lobby to read and save settings/); - expect(calls.readServerFile).not.toHaveBeenCalled(); - expect(screen.queryByRole("button", { name: "Save settings" })).toBeNull(); + fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } }); + expect((screen.getByRole("button", { name: "Restart & apply" }) as HTMLButtonElement).disabled).toBe(true); + await userEvent.click(screen.getByRole("button", { name: "Save settings" })); + await screen.findByText("Saved. Settings apply on the next start or restart."); + expect(calls.restart).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole("button", { name: "Restart & apply" })); + await waitFor(() => expect(calls.restart).toHaveBeenCalledWith("lobby")); + }); + + it("confirms a restart when players are online and keeps failures visible", async () => { + calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true, playersOnline: 2 }); + calls.restart.mockRejectedValue({ status: 409, code: "maintenance_in_progress", message: "busy" }); + page(); + await screen.findByLabelText("English menu title"); + await userEvent.click(screen.getByRole("button", { name: "Restart & apply" })); + const dialog = await screen.findByRole("dialog", { name: "Restart this space?" }); + expect(calls.restart).not.toHaveBeenCalled(); + await userEvent.click(within(dialog).getByRole("button", { name: "Restart & apply" })); + await waitFor(() => expect(calls.restart).toHaveBeenCalledWith("lobby")); + await screen.findByRole("alert"); + expect(screen.getByRole("dialog", { name: "Restart this space?" })).toBeTruthy(); }); it("keeps the draft when another editor changed the file", async () => { diff --git a/panel/src/pages/admin/LobbyPage.tsx b/panel/src/pages/admin/LobbyPage.tsx index e2a1976..86be388 100644 --- a/panel/src/pages/admin/LobbyPage.tsx +++ b/panel/src/pages/admin/LobbyPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { Link, useSearchParams } from "react-router-dom"; -import { BookOpen, ChevronRight, DoorOpen, FolderOpen, Globe, Map, MessageSquare, Package, Save, Shield, Sun, Terminal, type LucideIcon } from "lucide-react"; +import { BookOpen, ChevronRight, DoorOpen, FolderOpen, Globe, Map, MessageSquare, Package, RotateCw, Save, Shield, Sun, Terminal, type LucideIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { PageHeader } from "@/components/PageHeader"; @@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { PhaseBadge, shownPhase, startFailure } from "@/components/PhaseBadge"; -import { PowerButton } from "@/components/PowerButton"; +import { PowerButton, SUBMITTED_HOLD_MS } from "@/components/PowerButton"; import { CopyAddress } from "@/components/CopyAddress"; import { EditServerDialog } from "@/components/EditServerDialog"; import { ErrorState, Loading } from "@/components/States"; @@ -17,6 +17,7 @@ import { InlineError, MessageLine } from "@/components/MessageLine"; import { api, humanizeError } from "@/lib/api"; import { joinAddress } from "@/lib/config"; import { cn } from "@/lib/utils"; +import type { ServerStatus } from "@/lib/types"; import { STATUS_POLL_FAST_MS, useAsync, useConfig, usePolling, useUnsavedGuard } from "@/lib/hooks"; import { experienceValid, LOGIN_GROUPS, LOBBY_GROUPS, readExperience, writeExperience, type Experience } from "@/lib/experience"; @@ -27,12 +28,22 @@ const GROUP_ICONS: Record = { loginBook: BookOpen, }; -function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; stopped: boolean; onDirtyChange: (dirty: boolean) => void }) { +function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name: string; server: ServerStatus; onDirtyChange: (dirty: boolean) => void; onChanged: () => void }) { const { t } = useTranslation("lobby"); const groups = name === "login" ? LOGIN_GROUPS : LOBBY_GROUPS; - const query = useAsync(() => stopped ? readExperience(name, groups) : Promise.resolve(null), [name, stopped]); + const query = useAsync(() => readExperience(name, groups), [name]); const [draft, setDraft] = useState<{ values: Experience; original: string; sha256: string } | null>(null); const [saving, setSaving] = useState(false); + const [needsRestart, setNeedsRestart] = useState(false); + const [restarting, setRestarting] = useState(false); + const [confirmRestart, setConfirmRestart] = useState(false); + const running = server.phase === "Running" && server.desiredState === "Running"; + useEffect(() => { + if (!restarting) return; + if (!running) { setRestarting(false); return; } + const hold = window.setTimeout(() => setRestarting(false), SUBMITTED_HOLD_MS); + return () => window.clearTimeout(hold); + }, [running, restarting]); const [message, setMessage] = useState<{ kind: "error" | "success"; text: string } | null>(null); useEffect(() => { if (query.data && !draft) { @@ -44,12 +55,13 @@ function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; st useEffect(() => onDirtyChange(dirty), [dirty, onDirtyChange]); async function save() { - if (!draft || !stopped || saving || !experienceValid(draft.values, groups)) return; + if (!draft || saving || !experienceValid(draft.values, groups)) return; setSaving(true); setMessage(null); try { const sha256 = await writeExperience(name, draft.values, draft.sha256); setDraft({ ...draft, original: JSON.stringify(draft.values), sha256 }); + setNeedsRestart(true); setMessage({ kind: "success", text: t("saved") }); } catch (error) { setMessage({ kind: "error", text: humanizeError(error) }); @@ -58,10 +70,23 @@ function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; st } } + async function restart() { + setRestarting(true); + setMessage(null); + try { + await api.restart(name); + setNeedsRestart(false); + setMessage({ kind: "success", text: t("restart_requested") }); + onChanged(); + } catch (error) { + setRestarting(false); + throw error; + } + } + return (
- {!stopped &&

{t(name === "login" ? "stop_login" : "stop_lobby")}

} - {stopped && query.loading && !draft && } + {query.loading && !draft && } {query.error != null && } {draft && groups.map((group) => { const Icon = GROUP_ICONS[group.key]; @@ -86,13 +111,13 @@ function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; st
{field.choices ? ( - set(e.target.value)} disabled={saving || restarting} className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"> {field.choices.map((choice) => )} ) : field.multiline ? ( -