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

fix(servers): 主人可放弃服务器、管理员可删除服务器,由 reaper 归档世界后释放或移除

parent 4a26bf4c
Loading
Loading
Loading
Loading
+2 −2
Changes for cmd/felis/reaper.go: 2 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -144,8 +144,8 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int {
// FelisWorldJobFailed rule) reach the operator: a world that cannot be archived
// is kept, and without this nobody would learn that it is never reaped.
func reportReaperRun(sum reaper.Summary, stdout, stderr io.Writer) int {
	fmt.Fprintf(stdout, "felis reaper: evaluated=%d reaped=%d awaiting_offsite=%d awaiting_stop=%d warned=%d skipped=%d store_full=%d evicted=%d expired=%d expire_failed=%d verified=%d corrupt=%d verify_failed=%d swept=%d orphan_archives=%d\n",
		sum.Evaluated, sum.WorldsReaped, sum.AwaitingOffsite, sum.AwaitingStop, sum.Warned, sum.Skipped, sum.StoreFull,
	fmt.Fprintf(stdout, "felis reaper: evaluated=%d reaped=%d released=%d deleted=%d awaiting_offsite=%d awaiting_stop=%d warned=%d skipped=%d store_full=%d evicted=%d expired=%d expire_failed=%d verified=%d corrupt=%d verify_failed=%d swept=%d orphan_archives=%d\n",
		sum.Evaluated, sum.WorldsReaped, sum.Released, sum.ServersDeleted, sum.AwaitingOffsite, sum.AwaitingStop, sum.Warned, sum.Skipped, sum.StoreFull,
		sum.EvictedEarly, sum.BackupsExpired, sum.ExpireFailed,
		sum.Verified, sum.Corrupt, sum.VerifyFailed, sum.Swept, sum.OrphanArchives)
	if !sum.Failed() {
+4 −1
Changes for cmd/felis/reaper_test.go: 4 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -4,6 +4,7 @@ import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
@@ -27,6 +28,7 @@ func TestReportReaperRunFailsTheJob(t *testing.T) {
		want int
	}{
		{"clean", reaper.Summary{Evaluated: 3, WorldsReaped: 1, AwaitingOffsite: 1}, 0},
		{"retirements", reaper.Summary{Evaluated: 5, WorldsReaped: 3, Released: 2, ServersDeleted: 1}, 0},
		{"waiting for a stop", reaper.Summary{Evaluated: 3, AwaitingStop: 1}, 0},
		{"server failed", reaper.Summary{Evaluated: 3, Skipped: 1}, 1},
		{"store full", reaper.Summary{Evaluated: 3, Skipped: 1, StoreFull: 1}, 1},
@@ -40,7 +42,8 @@ func TestReportReaperRunFailsTheJob(t *testing.T) {
		if got := reportReaperRun(tc.sum, &out, &errb); got != tc.want {
			t.Errorf("%s: exit %d, want %d", tc.name, got, tc.want)
		}
		if !strings.Contains(out.String(), "skipped=") || !strings.Contains(out.String(), "expire_failed=") {
		if !strings.Contains(out.String(), "skipped=") || !strings.Contains(out.String(), "expire_failed=") ||
			!strings.Contains(out.String(), fmt.Sprintf(" released=%d deleted=%d ", tc.sum.Released, tc.sum.ServersDeleted)) {
			t.Errorf("%s: summary line = %q", tc.name, out.String())
		}
		if (tc.want == 1) != (errb.Len() > 0) {
+128 −5
Changes for docs/openapi.yaml: 128 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -503,6 +503,14 @@ components:
            Present and true for a Failed server no automatic retry will bring up: its
            start timed out with the retries spent, or its spec is invalid. A Failed
            server without it is still in its restart backoff and may come up on its own.
        reaperExempt:
          type: boolean
          description: Present and true for a system server the reaper never touches; it cannot be given up or deleted.
        retiring:
          allOf: [{ $ref: '#/components/schemas/RetireState' }]
          description: >-
            Owner and staff only. Present while the owner has given the server up or an
            admin is deleting it; the reaper carries that out on its next run.

    FleetServer:
      description: One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView).
@@ -525,8 +533,9 @@ components:
            claimable:
              type: boolean
              description: >-
                True for a live, unclaimed, non-system server, the same rule the claim
                route enforces. False whenever ownership is unknown.
                True for a live, unclaimed, non-system server with no pending deletion,
                the same rule the claim route enforces. False whenever ownership is
                unknown.
            ownerUnknown:
              type: boolean
              description: >-
@@ -540,6 +549,19 @@ components:
                so the cockpit renders them read-only instead of offering actions
                that would 400.

    RetireState:
      type: object
      description: >-
        A pending retirement (internal/api/repo.go RetireState): the owner gave the
        server up, or with delete an admin is deleting it. The reaper carries it out
        on its next daily run: it archives the world as a released backup, deletes
        the world volume and releases the server, and for a deletion also removes
        it. Until then the server stays stopped and cannot be woken or claimed.
      required: [requested_at, delete]
      properties:
        requested_at: { type: string, format: date-time }
        delete: { type: boolean }

    AllowlistEntry:
      type: object
      description: >-
@@ -596,6 +618,9 @@ components:
        startGaveUp:
          type: boolean
          description: Owned rows only. Present and true for a Failed server no automatic retry will bring up; waking it from the panel starts it over.
        retiring:
          allOf: [{ $ref: '#/components/schemas/RetireState' }]
          description: Owned rows only. Present while the server is given up or being deleted.

    BackupView:
      type: object
@@ -610,7 +635,7 @@ components:
        size_bytes: { type: integer, format: int64 }
        reason:
          type: string
          description: inactive_15d (idle reclaim), manual (on demand), pre_restore (the safety snapshot in front of a restore) or scheduled (the daily restore point felis-api takes of a world played since its last one, once the server stops).
          description: inactive_15d (idle reclaim), released (the world of a server its owner gave up or an admin deleted), manual (on demand), pre_restore (the safety snapshot in front of a restore) or scheduled (the daily restore point felis-api takes of a world played since its last one, once the server stops).
        status: { type: string }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
@@ -1240,6 +1265,8 @@ paths:
            file write holds the server's world volume. start_failed: the last
            start failed and its automatic retries are spent (ServerInfo.startGaveUp);
            the server stays down until a person starts it from the panel.
            server_retiring: the owner gave the server up or an admin is deleting it;
            it stays down until the reaper archives it.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
@@ -1830,7 +1857,10 @@ paths:
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: A restore, backup or file write holds the server's world volume (maintenance_in_progress); nothing was started.
          description: >-
            Nothing was started. maintenance_in_progress: a restore, backup or file
            write holds the server's world volume. server_retiring: the server is
            given up or being deleted (ServerInfo.retiring).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
@@ -1904,7 +1934,9 @@ paths:
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Already claimed.
          description: >-
            already_claimed: someone else owns it. server_retiring: the server is
            being deleted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
@@ -2498,6 +2530,97 @@ paths:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/servers/{name}/retirement:
    put:
      tags: [servers]
      operationId: retireServer
      summary: Give the server up (owner) or delete it (admin). The reaper carries it out.
      description: >-
        The server is stopped and the request recorded; the reaper, the one component
        that deletes a world, carries it out on its next daily run. It archives the
        world as a released backup (kept for the reaper's retention, recorded against
        the owner), deletes the world volume and releases the server for anyone to
        claim; with delete it also removes the server, which frees its name and
        subdomain. Until then the server cannot be woken or claimed and still counts
        against the owner's quota, and the request can be cancelled. Repeating it
        keeps the first request time, and a deletion stays a deletion. confirm must
        repeat the server's name. Audited as server.release / server.delete.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirm]
              properties:
                confirm: { type: string, description: The server's name, typed back. }
                delete: { type: boolean, description: Delete the server (admin only). Default false gives it up. }
      responses:
        '202':
          description: Recorded; the server is stopped.
          content:
            application/json:
              schema:
                type: object
                required: [name, retiring]
                properties:
                  name: { type: string }
                  retiring: { $ref: '#/components/schemas/RetireState' }
        '400':
          description: A malformed body or name, or confirm does not match the name (confirm_mismatch).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Neither the owner nor an admin, or an owner asking to delete.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            system_server: a system server is never given up or deleted.
            world_volume_orphaned: the server is gone from the cluster but its world
            volume remains, which an operator archives and removes by hand.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      tags: [servers]
      operationId: cancelRetire
      summary: Cancel a pending retirement. Only an admin cancels a deletion.
      description: >-
        The server stays stopped; its owner starts it again when they want it.
        Cancelling when nothing is pending changes nothing. Audited as
        server.retire_cancel.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
      responses:
        '204':
          description: Nothing is pending any more.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Neither the owner nor an admin, or an owner cancelling an admin's deletion.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/servers/{name}/status:
    get:
      tags: [servers]
+7 −4
Changes for docs/troubleshooting.md: 7 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -963,12 +963,15 @@ failing: `sudo felis offsite status` (§16).
Each run ends with one line:

```
felis reaper: evaluated=12 reaped=1 awaiting_offsite=0 awaiting_stop=0 warned=2 skipped=0 store_full=0 evicted=0 expired=3 expire_failed=0 verified=6 corrupt=0 verify_failed=0 swept=0 orphan_archives=0
felis reaper: evaluated=12 reaped=1 released=0 deleted=0 awaiting_offsite=0 awaiting_stop=0 warned=2 skipped=0 store_full=0 evicted=0 expired=3 expire_failed=0 verified=6 corrupt=0 verify_failed=0 swept=0 orphan_archives=0
```

`skipped` counts servers the run failed on (steps 1–3 above, or the cluster or
the database answering with an error; exempt servers and rows whose CRD is gone
are not counted), `store_full` the subset kept because the backup store is full,
`released` and `deleted` count retirements carried out: servers their owner gave
up (or an admin released) and servers an admin deleted, each world archived
first as a `released` backup. `skipped` counts servers the run failed on (steps
1–3 above, or the cluster or the database answering with an error; exempt
servers and rows whose CRD is gone are not counted, except a deletion whose
MinecraftServer is gone while its world volume remains), `store_full` the subset kept because the backup store is full,
and `expire_failed` expired backups it could not remove. `awaiting_stop` counts
idle servers left for the next run because they were not yet down (step 0); it
does not fail the run, but a server that stays there for days is being started
+5 −0
Changes for internal/api/api.go: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -516,6 +516,11 @@ func (a *API) externalAPIRoutes() []apiRoute {
		// access routes above (handlers_allowlist.go).
		{Method: "GET", Pattern: "/api/v1/servers/{name}/allowlist", h: a.handleAllowlistList},
		{Method: "PUT", Pattern: "/api/v1/servers/{name}/allowlist/{uuid}", h: a.handleAllowlistSetWake},
		// Retirement: the owner gives the server up, or an admin deletes it. The
		// request is recorded and the reaper carries it out on its next run
		// (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: "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 —
Loading