feat(api): 单次上传上限改为 context_max_bytes 可配,Cloudflare 边缘后默认 95Mi,新增 GET /me/submissions/limits 供面板预检

This commit is contained in:
Lemon-miaow committed 2026-09-25 18:31:10 +08:00
1 parent e7326e6315
commit 87dee3e5a5
10 files changed
+158 -2

No files matched your search

+29
View File
@@ -235,6 +235,11 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
submissions.MaxStoredBytesTotal = n
}
}
if n, err := contextMaxBytes(cfg); err != nil {
fmt.Fprintf(stderr, "felis api: [registry] context_max_bytes %q is not a positive size such as 95Mi; keeping the default\n", cfg.Registry.ContextMaxBytes)
} else {
submissions.MaxContextBytes = n
}
// Restore subsystem (spec §7): the weak-SA restore Job mounts the target
// world PVC + the backup PVC and runs `felis restore`. It needs deployment-
@@ -876,3 +881,27 @@ func startServerCache(ctx context.Context, cfg *rest.Config, scheme *runtime.Sch
}()
return c, inf.HasSynced, nil
}
// cloudflareContextMaxBytes is the per-upload cap behind the Cloudflare edge,
// which refuses request bodies over 100 MB (the Free and Pro plan limit) with
// its own 413 page before they reach the API. 95Mi leaves headroom under it, so
// an oversized context meets the API's own JSON refusal instead.
const cloudflareContextMaxBytes = "95Mi"
// contextMaxBytes resolves [registry] context_max_bytes, defaulting to
// cloudflareContextMaxBytes behind the Cloudflare edge. 0 keeps the submit
// package's own default (1 GiB).
func contextMaxBytes(cfg *config.Config) (int64, error) {
v := cfg.Registry.ContextMaxBytes
if v == "" && cfg.Auth.BehindCloudflare() {
v = cloudflareContextMaxBytes
}
if v == "" {
return 0, nil
}
n, err := parseByteSize(v)
if err != nil || n <= 0 {
return 0, fmt.Errorf("not a positive size: %q", v)
}
return n, nil
}
+37
View File
@@ -0,0 +1,37 @@
package main
import (
"testing"
"felis.lolicon.best/internal/config"
)
func TestContextMaxBytesFollowsTheEdge(t *testing.T) {
cases := []struct {
name string
reg config.RegistryConfig
auth config.AuthConfig
want int64
wantErr bool
}{
{name: "direct install keeps the package default", want: 0},
{name: "access audience means the Cloudflare edge", auth: config.AuthConfig{AccessJWTAud: "aud-1"}, want: 99614720},
{name: "CF-Connecting-IP header means the Cloudflare edge", auth: config.AuthConfig{ClientIPHeader: "cf-connecting-ip"}, want: 99614720},
{name: "an operator proxy keeps the package default", auth: config.AuthConfig{ClientIPHeader: "X-Forwarded-For"}, want: 0},
{name: "explicit value wins over the edge default", reg: config.RegistryConfig{ContextMaxBytes: "50Mi"}, auth: config.AuthConfig{AccessJWTAud: "aud-1"}, want: 52428800},
{name: "explicit value on a direct install", reg: config.RegistryConfig{ContextMaxBytes: "2Gi"}, want: 2147483648},
{name: "garbage is refused", reg: config.RegistryConfig{ContextMaxBytes: "lots"}, wantErr: true},
{name: "zero is refused", reg: config.RegistryConfig{ContextMaxBytes: "0"}, wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := contextMaxBytes(&config.Config{Registry: tc.reg, Auth: tc.auth})
if (err != nil) != tc.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
}
if got != tc.want {
t.Fatalf("contextMaxBytes = %d, want %d", got, tc.want)
}
})
}
}
+1 -1
View File
@@ -3002,7 +3002,7 @@ persisted_registry_block() {
out="$(awk '
/^[[:space:]]*\[/ { sect = $0; next }
sect ~ /^[[:space:]]*\[registry\][[:space:]]*$/ &&
/^[[:space:]]*(kaniko_image|trivy_image|trivy_db_repository|trivy_java_db_repository|build_cpu_limit|build_mem_limit|build_disk_limit|build_user_namespaces|build_runtime_class|max_concurrent_builds|user_uploads_context|user_uploads_max_bytes)[[:space:]]*=/ { print }
/^[[:space:]]*(kaniko_image|trivy_image|trivy_db_repository|trivy_java_db_repository|build_cpu_limit|build_mem_limit|build_disk_limit|build_user_namespaces|build_runtime_class|max_concurrent_builds|user_uploads_context|user_uploads_max_bytes|context_max_bytes)[[:space:]]*=/ { print }
sect ~ /^[[:space:]]*\[registry\.s3\][[:space:]]*$/ && /^[[:space:]]*[A-Za-z_]+[[:space:]]*=/ {
if (!s3hdr) { printf "[registry.s3]\n"; s3hdr = 1 }
print
+30 -1
View File
@@ -5278,6 +5278,31 @@ paths:
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/me/submissions/limits:
get:
tags: [submissions]
operationId: submissionLimits
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
summary: The per-upload build-context cap
description: >-
The effective [registry] context_max_bytes: 1 GiB by default, 95 MiB
behind the Cloudflare edge (its proxy refuses bodies over 100 MB before
they reach the API). The panel checks a file against it before upload.
responses:
'200':
description: The cap.
content:
application/json:
schema:
type: object
additionalProperties: false
required: [max_context_bytes]
properties:
max_context_bytes: {type: integer, format: int64}
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/me/submissions/{id}/context:
post:
tags: [submissions]
@@ -5290,7 +5315,11 @@ paths:
principal; a submission the caller does not own is reported as 404, so
this endpoint cannot upload to or probe another user's submission. Only a
pending_review submission accepts a context (409 otherwise); a wrong-format
or oversize body is rejected with 400, and an upload that would push the
or oversize body is rejected with 400 (the per-upload cap is [registry]
context_max_bytes: 1 GiB by default and 95 MiB behind the Cloudflare
edge, whose proxy refuses bodies over 100 MB with its own HTML 413
before they reach the API; GET /api/v1/me/submissions/limits reports
the effective cap so a client can check a file before sending it), and an upload that would push the
caller past their per-user stored-context budget is refused with 403
before the excess is persisted. Returns 503 when the deployment's context
store has no implemented upload transport.
+4
View File
@@ -552,6 +552,7 @@ build_user_namespaces = "auto" # §8f: auto | on | off
build_runtime_class = "" # §8f: e.g. "gvisor"
max_concurrent_builds = 2 # §8f: 1-6; later builds queue
user_uploads_max_bytes = "4Gi" # every user's uploaded contexts together; 507 uploads_full past it
context_max_bytes = "95Mi" # one uploaded context; empty = 1Gi, or 95Mi behind Cloudflare (edge caps bodies at 100 MB)
```
Put them in **both** `/etc/felis/felis.host.toml` (host-side CLI) and
@@ -693,6 +694,9 @@ control namespace (or `--registry-namespace`):
(`FELIS_UPLOADS_STORAGE`, 5Gi) and the world-archive PVC
(`FELIS_BACKUP_STORAGE`, 10Gi) work the same way; re-running the installer
keeps an existing claim's size and warns when the variable asks for another.
One uploaded context is capped by `context_max_bytes` (1Gi, or 95Mi behind
the Cloudflare edge, whose proxy answers its own 413 page for bodies over
100 MB; the panel checks the file against it before uploading).
Uploaded build contexts are bounded by `user_uploads_max_bytes` (4Gi for all
users together, §8e), 2 GiB per user, and 10% free space on the volume; past
any of them an upload answers `507 uploads_full` or `403 submission_quota_exceeded`. A
+1
View File
@@ -610,6 +610,7 @@ func (a *API) externalAPIRoutes() []apiRoute {
// session is the correct gate (the admin verdict lives below, behind adminOnly).
{Method: "POST", Pattern: "/api/v1/me/submissions", h: a.handleCreateSubmission},
{Method: "GET", Pattern: "/api/v1/me/submissions", h: a.handleMySubmissions},
{Method: "GET", Pattern: "/api/v1/me/submissions/limits", h: a.handleSubmissionLimits},
// The blob upload for a submission the caller owns: the request body is the
// raw gzip build context, streamed to the derived, id-namespaced location.
// App-tier and owner-scoped (the id must belong to the principal), exactly
+17
View File
@@ -202,6 +202,23 @@ func (a *API) handleUploadSubmissionContext(w http.ResponseWriter, r *http.Reque
// query to another user's uploads. Each row is enriched with its linked build's
// outcome — this list is the only player-visible outlet for a build result, so
// a failed build is not invisible to the person who submitted it.
// SubmissionLimits is what one upload may carry, read before sending it.
type SubmissionLimits struct {
MaxContextBytes int64 `json:"max_context_bytes"`
}
// handleSubmissionLimits reports the effective per-upload context cap
// ([registry] context_max_bytes), so the panel can refuse an oversized file
// before streaming it into the edge's own body limit.
func (a *API) handleSubmissionLimits(w http.ResponseWriter, r *http.Request) {
l, ok := a.Submissions.(interface{ ContextLimit() int64 })
if !ok {
writeError(w, r, errSubmissionsUnavailable)
return
}
writeJSON(w, http.StatusOK, SubmissionLimits{MaxContextBytes: l.ContextLimit()})
}
func (a *API) handleMySubmissions(w http.ResponseWriter, r *http.Request) {
if a.Submissions == nil {
writeError(w, r, errSubmissionsUnavailable)
+24
View File
@@ -934,3 +934,27 @@ func TestUploadSubmissionContextRateLimited(t *testing.T) {
t.Fatalf("retry at the same instant after failure: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
}
type limitedSubmissions struct {
*fakeSubmissions
limit int64
}
func (l limitedSubmissions) ContextLimit() int64 { return l.limit }
// The panel reads the per-upload cap before sending a file; a lane that cannot
// report one answers like any unwired submissions endpoint.
func TestSubmissionLimitsReportsTheContextCap(t *testing.T) {
api := appSubAPI(&fakeSubmissions{})
api.Submissions = limitedSubmissions{&fakeSubmissions{}, 99614720}
w := do(api.ExternalHandler(), "GET", "/api/v1/me/submissions/limits", "", nil)
if w.Code != 200 || strings.TrimSpace(w.Body.String()) != `{"max_context_bytes":99614720}` {
t.Fatalf("limits = %d %s", w.Code, w.Body.String())
}
api.Submissions = nil
w = do(api.ExternalHandler(), "GET", "/api/v1/me/submissions/limits", "", nil)
if w.Code != 503 {
t.Fatalf("unwired limits = %d %s", w.Code, w.Body.String())
}
}
+12
View File
@@ -166,6 +166,13 @@ func (a AuthConfig) EffectiveClientIPHeader() string {
return ""
}
// BehindCloudflare reports whether requests reach the API through the
// Cloudflare edge: an Access audience (set only by the edge setup) or
// CF-Connecting-IP as the client address header.
func (a AuthConfig) BehindCloudflare() bool {
return strings.EqualFold(a.EffectiveClientIPHeader(), "CF-Connecting-IP")
}
// K8sConfig is the [k8s] table.
type K8sConfig struct {
Namespace string `toml:"namespace"`
@@ -232,6 +239,11 @@ type RegistryConfig struct {
// own; this bounds the sum, which on k3s local-path is the only bound, since
// the uploads PVC's size is not enforced there. Empty keeps 4Gi.
UserUploadsMaxBytes string `toml:"user_uploads_max_bytes"`
// ContextMaxBytes caps one uploaded build context, as a quantity ("95Mi").
// Empty keeps 1Gi, except behind the Cloudflare edge (see BehindCloudflare),
// whose proxy refuses request bodies over 100 MB before they reach the API;
// there it keeps 95Mi, so the API's own 413 is what the uploader sees.
ContextMaxBytes string `toml:"context_max_bytes"`
// S3 configures the object-store backend for user_uploads_context when it is an
// s3:// base (the alternative to a local uploads path). It mirrors
// ArchiveS3Config: Endpoint + Region locate the store and the *Ref fields NAME
+3
View File
@@ -384,6 +384,9 @@ type Manager struct {
IDGen func() string
}
// ContextLimit is the effective cap on one uploaded context.
func (m *Manager) ContextLimit() int64 { return m.maxContextBytes() }
func (m *Manager) maxContextBytes() int64 {
if m.MaxContextBytes > 0 {
return m.MaxContextBytes