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

feat(panel): backup management

parent 15c58d98
Loading
Loading
Loading
Loading
+14 −0
Changes for internal/api/api_test.go: 14 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -517,6 +517,20 @@ func (f *fakeRepo) LatestBackup(_ context.Context, serverName string) (*BackupRe
	}, nil
}

func (f *fakeRepo) BackupByID(_ context.Context, id string) (*BackupRecord, error) {
	for i := range f.backups {
		b := &f.backups[i]
		if b.view.Status == "present" && b.view.ID == id {
			return &BackupRecord{
				ID: b.view.ID, ServerName: b.view.ServerName,
				FormerOwner: b.view.FormerOwner, BackupRef: b.ref,
				SizeBytes: b.view.SizeBytes,
			}, nil
		}
	}
	return nil, ErrNotFound
}

// ---- local-password auth fakes (spec §B) ----
// Each method mirrors the PGRepo contract: a returned StaffUser is copied so a
// test cannot mutate the stored row by reference, SessionUser re-reads the
+52 −16
Changes for internal/api/handlers_backups.go: 52 added lines, 16 removed lines.
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@ package api
import (
	"errors"
	"net/http"
	"strings"

	"felis.lolicon.best/internal/apis/felis/v1alpha1"
	"felis.lolicon.best/internal/naming"
@@ -37,9 +38,10 @@ func (a *API) handleListBackups(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]any{"backups": backups})
}

// handleRestoreBackup starts restoring a server's world from its most recent
// backup (spec §7 POST /servers/{name}/restore-backup; spec §466: former_owner
// 3mo 内重新 claim → restore PVC). The authorization is deliberately stricter than
// handleRestoreBackup starts restoring a server's world from a backup (spec §7
// POST /servers/{name}/restore-backup; spec §466). It accepts an optional JSON
// body with a backup_id; when absent it restores the latest backup for the server
// (backward-compatible default). The authorization is deliberately stricter than
// ordinary owner-or-admin, in this order:
//
//	① name validation
@@ -47,20 +49,22 @@ func (a *API) handleListBackups(w http.ResponseWriter, r *http.Request) {
//	③ owner-or-admin, else 403. A released world's server row is unowned
//	   (owner_id NULL → OwnerID ""), so this also enforces "重新 claim": a former
//	   owner must re-claim the server before they can restore into it.
//	④ the latest present backup, else 404 no_backup
//	⑤ former-owner match: a non-admin may restore ONLY a world they formerly owned.
//	   The current-owner gate in ③ is not enough — user B who re-claims a released
//	   server could otherwise resurrect user A's world (the backup still carries
//	   former_owner=A), a data leak. Admin skips this check.
//	⑥ stopped gate: the world PVC must be free, so restore is refused unless the
//	   server is fully stopped. A running OR starting server still holds the RWO
//	   world volume, which a restore Job could not mount — a clean 409 beats a Job
//	   that fails to schedule.
//	⑦ hand off to the Restorer. Restore is asynchronous (a restore Job, like an
//	④ if the optional backup_id is supplied the handler resolves the specific
//	   backup; otherwise it picks the most recent present backup, else
//	   404 no_backup
//	⑤ cross-server guard: a backup requested by id must belong to the server in
//	   the path — restoring server A's backup onto server B would be a data leak
//	⑥ former-owner match: a non-admin may restore ONLY a world they formerly
//	   owned. The current-owner gate in ③ is not enough — user B who
//	   re-claims a released server could otherwise resurrect user A's world (the
//	   backup still carries former_owner=A), a data leak. Admin skips this check.
//	⑦ stopped gate: the world PVC must be free, so restore is refused unless the
//	   server is fully stopped.
//	⑧ hand off to the Restorer. Restore is asynchronous (a restore Job, like an
//	   image build Job), so success means "enqueued" and the handler answers 202.
//
// The opaque backup_ref is resolved server-side from the latest backup and handed
// to the Restorer directly; the client never names a backup by handle (spec §286
// The opaque backup_ref is resolved server-side from the backup and handed to the
// Restorer directly; the client never names a backup by handle (spec §286
// principle).
func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
	p := principalFromContext(r.Context())
@@ -83,7 +87,38 @@ func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
		return
	}

	backup, err := a.Repo.LatestBackup(r.Context(), name)
	// Optional backup_id in the JSON body; absent → LatestBackup (backward compat).
	var body struct {
		BackupID string `json:"backup_id"`
	}
	if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
		if err := decodeJSON(w, r, &body); err != nil {
			writeError(w, r, err)
			return
		}
	}

	// Resolve the backup record. When backup_id is specified the handler resolves
	// that exact backup; otherwise it picks the most recent present one.
	var backup *BackupRecord
	if body.BackupID != "" {
		backup, err = a.Repo.BackupByID(r.Context(), body.BackupID)
		if err != nil {
			if errors.Is(err, ErrNotFound) {
				writeError(w, r, newError(http.StatusNotFound, "no_backup",
					"no matching backup exists"))
				return
			}
			writeError(w, r, err)
			return
		}
		// Cross-server guard: the backup must belong to the server named in the path.
		if backup.ServerName != name {
			writeError(w, r, errForbidden)
			return
		}
	} else {
		backup, err = a.Repo.LatestBackup(r.Context(), name)
		if err != nil {
			if errors.Is(err, ErrNotFound) {
				writeError(w, r, newError(http.StatusNotFound, "no_backup",
@@ -93,6 +128,7 @@ func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
			writeError(w, r, err)
			return
		}
	}

	// A non-admin may restore only a world they formerly owned (spec §466). Without
	// this a fresh claimant of a released server could resurrect the previous
+123 −0
Changes for internal/api/handlers_backups_test.go: 123 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -290,4 +290,127 @@ func TestRestoreBackup(t *testing.T) {
			t.Fatalf("code = %d, want 400", w.Code)
		}
	})

	// ---- restore by backup_id ----

	// mkTwo supplements the base mk with two present backups for the same server
	// so tests can exercise restoring the older one by id. bk2 is older than bk1,
	// so LatestBackup still returns bk1 — restoring by "bk2" proves it reached the
	// correct record.
	mkTwo := func() (*API, *fakeRepo, *fakeCluster, *fakeRestorer) {
		repo := newFakeRepo()
		repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
		repo.backups = []fakeBackup{
			{view: BackupView{ID: "bk1", ServerName: "survival", FormerOwner: "owner1",
				Status: "present", Reason: "inactive_15d", SizeBytes: 1024,
				CreatedAt: time.Unix(1_699_000_000, 0), ExpiresAt: time.Unix(1_706_000_000, 0)}, ref: "ref-bk1"},
			{view: BackupView{ID: "bk2", ServerName: "survival", FormerOwner: "owner1",
				Status: "present", Reason: "manual", SizeBytes: 2048,
				CreatedAt: time.Unix(1_698_000_000, 0), ExpiresAt: time.Unix(1_706_000_000, 0)}, ref: "ref-bk2"},
		}
		cl := newFakeCluster()
		cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Stopped",
			Ready: false, DesiredState: string(v1alpha1.DesiredStopped)}
		restorer := &fakeRestorer{}
		api := newTestAPI(repo, cl)
		api.Restorer = restorer
		return api, repo, cl, restorer
	}

	jsonHeaders := map[string]string{"Content-Type": "application/json"}

	t.Run("restore by backup_id -> 202, correct BackupRef sent", func(t *testing.T) {
		api, _, _, restorer := mkTwo()
		api.External = staticExternal{p: owner}
		body := `{"backup_id":"bk2"}`
		w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders)
		if w.Code != http.StatusAccepted {
			t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String())
		}
		var resp struct {
			Name     string `json:"name"`
			Status   string `json:"status"`
			BackupID string `json:"backup_id"`
		}
		if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
			t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
		}
		if resp.BackupID != "bk2" {
			t.Fatalf("backup_id = %q, want bk2", resp.BackupID)
		}
		if restorer.gotRef != "ref-bk2" {
			t.Fatalf("restorer ref = %q, want ref-bk2 (proves BackupByID, not LatestBackup)", restorer.gotRef)
		}
	})

	t.Run("restore by backup_id not found -> 404 no_backup", func(t *testing.T) {
		api, _, _, restorer := mkTwo()
		api.External = staticExternal{p: owner}
		body := `{"backup_id":"nonexistent"}`
		w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders)
		if w.Code != http.StatusNotFound || decodeErr(t, w) != "no_backup" {
			t.Fatalf("code = %d body %s", w.Code, w.Body.String())
		}
		if restorer.calls != 0 {
			t.Fatal("non-existent backup must not reach the restorer")
		}
	})

	t.Run("restore by backup_id that is deleted -> 404 no_backup", func(t *testing.T) {
		api, repo, _, _ := mkTwo()
		repo.backups[1].view.Status = "deleted"
		api.External = staticExternal{p: owner}
		body := `{"backup_id":"bk2"}`
		w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders)
		if w.Code != http.StatusNotFound || decodeErr(t, w) != "no_backup" {
			t.Fatalf("code = %d body %s", w.Code, w.Body.String())
		}
	})

	t.Run("restore by backup_id cross-server -> 403", func(t *testing.T) {
		api, repo, _, _ := mkTwo()
		// bk2 belongs to a different server; restoring it onto survival is forbidden.
		repo.backups[1].view.ServerName = "creative"
		api.External = staticExternal{p: owner}
		body := `{"backup_id":"bk2"}`
		w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders)
		if w.Code != http.StatusForbidden {
			t.Fatalf("code = %d, want 403 (cross-server guard)", w.Code)
		}
	})

	t.Run("no body -> falls back to LatestBackup (backward compat)", func(t *testing.T) {
		api, _, _, restorer := mkTwo()
		api.External = staticExternal{p: owner}
		w := do(api.ExternalHandler(), "POST", path, "", nil)
		if w.Code != http.StatusAccepted {
			t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String())
		}
		if restorer.gotRef != "ref-bk1" {
			t.Fatalf("restorer ref = %q, want ref-bk1 (LatestBackup)", restorer.gotRef)
		}
	})

	t.Run("empty JSON body -> falls back to LatestBackup", func(t *testing.T) {
		api, _, _, restorer := mkTwo()
		api.External = staticExternal{p: owner}
		body := `{}`
		w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders)
		if w.Code != http.StatusAccepted {
			t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String())
		}
		if restorer.gotRef != "ref-bk1" {
			t.Fatalf("restorer ref = %q, want ref-bk1 (LatestBackup)", restorer.gotRef)
		}
	})

	t.Run("malformed JSON body -> 400", func(t *testing.T) {
		api, _, _, _ := mkTwo()
		api.External = staticExternal{p: owner}
		body := `not json`
		w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders)
		if w.Code != http.StatusBadRequest {
			t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String())
		}
	})
}
+15 −0
Changes for internal/api/pgrepo.go: 15 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -462,6 +462,21 @@ func (p *PGRepo) LatestBackup(ctx context.Context, serverName string) (*BackupRe
	return &b, nil
}

// BackupByID returns a single present backup by its id, or ErrNotFound.
func (p *PGRepo) BackupByID(ctx context.Context, id string) (*BackupRecord, error) {
	const q = `SELECT id, server_name, COALESCE(former_owner, ''), backup_ref, COALESCE(size_bytes, 0)
		FROM world_backups WHERE id = $1 AND status = 'present'`
	var b BackupRecord
	switch err := p.db.QueryRowContext(ctx, q, id).Scan(
		&b.ID, &b.ServerName, &b.FormerOwner, &b.BackupRef, &b.SizeBytes); {
	case errors.Is(err, sql.ErrNoRows):
		return nil, ErrNotFound
	case err != nil:
		return nil, err
	}
	return &b, nil
}

func (p *PGRepo) Audit(ctx context.Context, e AuditEntry) error {
	// A nil Payload must land as SQL NULL, not the text "null"; a non-nil Payload is
	// passed as a JSON text the jsonb column parses (same idiom as reaper.PGStore).
+4 −0
Changes for internal/api/repo.go: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -221,6 +221,10 @@ type Repo interface {
	// carries the server-side backup_ref + former_owner the restore path needs; the
	// client never sees them.
	LatestBackup(ctx context.Context, serverName string) (*BackupRecord, error)
	// BackupByID returns a single present backup by its id, or ErrNotFound when
	// none matches. Like LatestBackup the returned BackupRecord carries the
	// server-side backup_ref the restore path needs; the client never sees it.
	BackupByID(ctx context.Context, id string) (*BackupRecord, error)
	// SeedServer inserts the business-layer rows for a newly created server (spec
	// §15): a servers row (owner_id NULL — claimed later, spec §9.3) and its
	// subdomain alias, both idempotent. It returns ErrConflict if the subdomain is
Loading