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

feat(api): 集群列表读改走 informer 缓存,审核队列、我的提交与备份列表改为服务端分页筛选并一次批量查构建,面板三页跟进

parent 0a66385d
Loading
Loading
Loading
Loading
+41 −1
Changes for cmd/felis/api.go: 41 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -36,7 +36,9 @@ import (
	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
	"k8s.io/client-go/kubernetes"
	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
	"k8s.io/client-go/rest"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/cache"
	"sigs.k8s.io/controller-runtime/pkg/client"
)

@@ -300,7 +302,12 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
		rcfg = reaper.DefaultConfig()
	}

	cluster := api.NewK8sCluster(cl, cfg.K8s.Namespace)
	serverCache, serversSynced, err := startServerCache(ctx, restCfg, scheme, cfg.K8s.Namespace, stderr)
	if err != nil {
		fmt.Fprintf(stderr, "felis api: MinecraftServer cache: %v\n", err)
		return 1
	}
	cluster := api.NewK8sCluster(cl, cfg.K8s.Namespace).WithServerCache(serverCache, serversSynced)
	jobStatus := api.NewK8sJobStatus(cl, cfg.K8s.Namespace)
	a := &api.API{
		Repo:    repo,
@@ -836,3 +843,36 @@ func smtpRelay(c config.SMTPConfig, password string) *mail.SMTP {
		RequireTLS: c.TLSRequired(),
	}
}

// startServerCache starts the informer that serves the api's fleet-wide
// MinecraftServer reads (api.K8sCluster.WithServerCache): one watch on the
// namespace instead of a full List per velocity pull, fleet page and wake. It
// caches MinecraftServers only — ReaderFailOnMissingInformer turns any other read
// through it into an error rather than a new informer the api's Role cannot back —
// indexes spec.subdomain for GetBySubdomain, and drops managedFields to keep the
// copy small. It returns without waiting: the reads block until the first list
// lands and /readyz reports not-ready until then.
func startServerCache(ctx context.Context, cfg *rest.Config, scheme *runtime.Scheme, namespace string, stderr io.Writer) (cache.Cache, func() bool, error) {
	c, err := cache.New(cfg, cache.Options{
		Scheme:                      scheme,
		DefaultNamespaces:           map[string]cache.Config{namespace: {}},
		DefaultTransform:            cache.TransformStripManagedFields(),
		ReaderFailOnMissingInformer: true,
	})
	if err != nil {
		return nil, nil, err
	}
	if err := c.IndexField(ctx, &v1alpha1.MinecraftServer{}, api.SubdomainIndex, api.SubdomainOf); err != nil {
		return nil, nil, fmt.Errorf("index %s: %w", api.SubdomainIndex, err)
	}
	inf, err := c.GetInformer(ctx, &v1alpha1.MinecraftServer{})
	if err != nil {
		return nil, nil, err
	}
	go func() {
		if err := c.Start(ctx); err != nil {
			fmt.Fprintf(stderr, "felis api: MinecraftServer cache stopped: %v\n", err)
		}
	}()
	return c, inf.HasSynced, nil
}
+59 −8
Changes for docs/openapi.yaml: 59 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -3150,21 +3150,32 @@ paths:
      tags: [backups]
      operationId: listBackups
      summary: List world backups (admin sees all; a user sees only worlds they formerly owned).
      description: >-
        One page of the present backups in the caller's scope, newest first.
        server narrows the page to one server's backups inside that scope; it
        never widens it.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: server, in: query, required: false, schema: { type: string }, description: 'Only this server''s backups' }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: offset, in: query, required: false, schema: { type: integer, default: 0 } }
      responses:
        '200':
          description: Visible backups.
          description: A page of visible backups plus how many match.
          content:
            application/json:
              schema:
                type: object
                required: [backups]
                required: [backups, total]
                properties:
                  backups:
                    type: array
                    items: { $ref: '#/components/schemas/BackupView' }
                  total: { type: integer }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

@@ -5069,21 +5080,41 @@ paths:
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: status, in: query, required: false, schema: { type: string, enum: [pending_review, approved, rejected] } }
        - { name: query, in: query, required: false, schema: { type: string }, description: 'Part of the id, the submitter or the display name; case-insensitive' }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: offset, in: query, required: false, schema: { type: integer, default: 0 } }
      responses:
        '200':
          description: >-
            The caller's submissions, newest first; rows with a linked build
            additionally carry build_status/build_error so the submitter can see
            whether their build succeeded or failed (and why).
            One page of the caller's submissions, newest first; rows with a
            linked build additionally carry build_status/build_error so the
            submitter can see whether their build succeeded or failed (and why).
          content:
            application/json:
              schema:
                type: object
                required: [submissions]
                required: [submissions, total, counts]
                properties:
                  submissions:
                    type: array
                    items: { $ref: '#/components/schemas/Submission' }
                  total:
                    type: integer
                    description: How many submissions match status and query in all.
                  counts:
                    type: object
                    description: >-
                      How many of the scope's submissions sit in each status,
                      whatever status and query say.
                    required: [pending_review, approved, rejected]
                    properties:
                      pending_review: { type: integer }
                      approved: { type: integer }
                      rejected: { type: integer }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
@@ -5507,18 +5538,38 @@ paths:
      x-felis-face: [external]
      x-felis-tier: admin
      security: [{ sessionCookie: [] }]
      parameters:
        - { name: status, in: query, required: false, schema: { type: string, enum: [pending_review, approved, rejected] } }
        - { name: query, in: query, required: false, schema: { type: string }, description: 'Part of the id, the submitter or the display name; case-insensitive' }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: offset, in: query, required: false, schema: { type: integer, default: 0 } }
      responses:
        '200':
          description: All submissions, newest first.
          description: One page of every user's submissions, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [submissions]
                required: [submissions, total, counts]
                properties:
                  submissions:
                    type: array
                    items: { $ref: '#/components/schemas/Submission' }
                  total:
                    type: integer
                    description: How many submissions match status and query in all.
                  counts:
                    type: object
                    description: >-
                      How many of the scope's submissions sit in each status,
                      whatever status and query say.
                    required: [pending_review, approved, rejected]
                    properties:
                      pending_review: { type: integer }
                      approved: { type: integer }
                      rejected: { type: integer }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
+20 −11
Changes for internal/api/api_test.go: 20 added lines, 11 removed lines.
Original line number Diff line number Diff line
@@ -64,6 +64,7 @@ type fakeRepo struct {
	linkAuthSource map[string]string
	// world backups (spec §7, §22). A nil slice lists empty.
	backups        []fakeBackup
	backupListOpts BackupListOpts // the last AllBackups or BackupsForUser options
	// session auth (spec §B, passwordless). staff is keyed by username (the login key);
	// sessions by token_hash; settings by key. They mirror the PG contract so the
	// hermetic tests exercise the same fail-closed semantics the integration impl
@@ -912,23 +913,31 @@ func (f *fakeRepo) BackupStoreBytes(context.Context) (int64, error) {
// the hermetic tests can't pass against a too-lenient fake: only status='present'
// rows are visible, the user scope is the former_owner column, and LatestBackup
// is the newest present row for a server (or ErrNotFound).
func (f *fakeRepo) AllBackups(_ context.Context) ([]BackupView, error) {
	var out []BackupView
	for _, b := range f.backups {
		if b.view.Status == "present" {
			out = append(out, b.view)
func (f *fakeRepo) AllBackups(_ context.Context, opts BackupListOpts) ([]BackupView, int, error) {
	return f.pageBackups("", opts)
}
func (f *fakeRepo) BackupsForUser(_ context.Context, userID string, opts BackupListOpts) ([]BackupView, int, error) {
	if userID == "" { // an empty id is no former owner; "" below means every owner
		return nil, 0, nil
	}
	return out, nil
	return f.pageBackups(userID, opts)
}
func (f *fakeRepo) BackupsForUser(_ context.Context, userID string) ([]BackupView, error) {
	var out []BackupView

// pageBackups filters like the PG query (present, in scope, on the server) and
// pages newest first, recording the options it was asked for.
func (f *fakeRepo) pageBackups(owner string, opts BackupListOpts) ([]BackupView, int, error) {
	f.backupListOpts = opts
	var match []BackupView
	for _, b := range f.backups {
		if b.view.Status == "present" && b.view.FormerOwner == userID {
			out = append(out, b.view)
		if b.view.Status == "present" && (owner == "" || b.view.FormerOwner == owner) &&
			(opts.Server == "" || b.view.ServerName == opts.Server) {
			match = append(match, b.view)
		}
	}
	return out, nil
	sort.SliceStable(match, func(i, j int) bool { return match[i].CreatedAt.After(match[j].CreatedAt) })
	lo := min(opts.Offset, len(match))
	hi := min(lo+opts.Limit, len(match))
	return match[lo:hi], len(match), nil
}
func (f *fakeRepo) LatestBackup(_ context.Context, serverName string) (*BackupRecord, error) {
	var latest *fakeBackup
+3 −2
Changes for internal/api/cluster.go: 3 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -84,8 +84,9 @@ type ServerSpecPatch struct {
// so handlers are tested against a fake; the controller-runtime implementation
// (k8sCluster) is integration-tested only — it requires a live cluster.
type Cluster interface {
	// Ping verifies the K8s API and CRD informer are healthy — used by /readyz
	// (spec §7) to confirm the lifecycle store is reachable and synced.
	// Ping verifies the K8s API is reachable and the MinecraftServer cache has
	// synced — used by /readyz (spec §7), so a replica serves the fleet reads only
	// once it holds the whole fleet.
	Ping(ctx context.Context) error

	// GetServer reads one MinecraftServer's lifecycle view, or ErrNotFound.
+26 −3
Changes for internal/api/handlers_backups.go: 26 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -4,6 +4,7 @@ import (
	"context"
	"errors"
	"net/http"
	"strconv"
	"strings"
	"time"

@@ -29,17 +30,39 @@ func errNoWorldVolume() error {
// query runs (AllBackups vs BackupsForUser) — there is no client-supplied filter
// a user could widen, so "a user cannot see another's backups" is a property of
// the query, not of request parsing.
//
// What the request may choose is the page: ?server= narrows the list to one
// server (inside the caller's scope, never beyond it), ?limit= and ?offset= page
// it, and the answer carries how many match in all.
func (a *API) handleListBackups(w http.ResponseWriter, r *http.Request) {
	p := principalFromContext(r.Context())
	q := r.URL.Query()
	opts := BackupListOpts{Server: q.Get("server")}
	// Format only: a system server's reserved name is still a server whose
	// backups an admin may list.
	if opts.Server != "" {
		if err := naming.ValidateSystemServerName(opts.Server); err != nil {
			writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
			return
		}
	}
	opts.Limit, _ = strconv.Atoi(q.Get("limit"))
	opts.Offset, _ = strconv.Atoi(q.Get("offset"))
	if opts.Limit <= 0 {
		opts.Limit = DefaultBackupListLimit
	}
	opts.Limit = min(opts.Limit, MaxBackupListLimit)
	opts.Offset = max(opts.Offset, 0)

	var (
		backups []BackupView
		total   int
		err     error
	)
	if p.IsAdmin() {
		backups, err = a.Repo.AllBackups(r.Context())
		backups, total, err = a.Repo.AllBackups(r.Context(), opts)
	} else {
		backups, err = a.Repo.BackupsForUser(r.Context(), p.UserID)
		backups, total, err = a.Repo.BackupsForUser(r.Context(), p.UserID, opts)
	}
	if err != nil {
		writeError(w, r, err)
@@ -48,7 +71,7 @@ func (a *API) handleListBackups(w http.ResponseWriter, r *http.Request) {
	if backups == nil {
		backups = []BackupView{}
	}
	writeJSON(w, http.StatusOK, map[string]any{"backups": backups})
	writeJSON(w, http.StatusOK, map[string]any{"backups": backups, "total": total})
}

// handleRestoreBackup starts restoring a server's world from a backup (spec §7
Loading