Loading cmd/felis/api.go +41 −1 Changes for cmd/felis/api.go: 41 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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" ) Loading Loading @@ -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, Loading Loading @@ -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 } docs/openapi.yaml +59 −8 Changes for docs/openapi.yaml: 59 added lines, 8 removed lines. Original line number Diff line number Diff line Loading @@ -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' Loading Loading @@ -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': Loading Loading @@ -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': Loading internal/api/api_test.go +20 −11 Changes for internal/api/api_test.go: 20 added lines, 11 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading internal/api/cluster.go +3 −2 Changes for internal/api/cluster.go: 3 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -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. Loading internal/api/handlers_backups.go +26 −3 Changes for internal/api/handlers_backups.go: 26 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" "strconv" "strings" "time" Loading @@ -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) Loading @@ -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 Loading
cmd/felis/api.go +41 −1 Changes for cmd/felis/api.go: 41 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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" ) Loading Loading @@ -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, Loading Loading @@ -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 }
docs/openapi.yaml +59 −8 Changes for docs/openapi.yaml: 59 added lines, 8 removed lines. Original line number Diff line number Diff line Loading @@ -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' Loading Loading @@ -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': Loading Loading @@ -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': Loading
internal/api/api_test.go +20 −11 Changes for internal/api/api_test.go: 20 added lines, 11 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading
internal/api/cluster.go +3 −2 Changes for internal/api/cluster.go: 3 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -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. Loading
internal/api/handlers_backups.go +26 −3 Changes for internal/api/handlers_backups.go: 26 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" "strconv" "strings" "time" Loading @@ -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) Loading @@ -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