Unverified Commit cb3ed940 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(panel): streamline system settings and server creation

Use shared action buttons and default to the login space. Allow staff to save startup-only experience settings while running and request a durable restart through the existing operator flow.

Grant the API PVC list permission needed to detect retained worlds before server creation, and show internal failures with a request ID. Cover permission, maintenance, restart and UI behavior with regression checks.
parent e6789697
Loading
Loading
Loading
Loading
+49 −6
Changes for docs/openapi.yaml: 49 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -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: [] }]
+1 −0
Changes for internal/api/api.go: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 —
+30 −0
Changes for internal/api/api_test.go: 30 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
+3 −1
Changes for internal/api/cluster.go: 3 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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
+19 −10
Changes for internal/api/handlers_files.go: 19 added lines, 10 removed lines.
Original line number Diff line number Diff line
@@ -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
Loading