feat(api): 单次上传上限改为 context_max_bytes 可配,Cloudflare 边缘后默认 95Mi,新增 GET /me/submissions/limits 供面板预检
This commit is contained in:
10 files changed
+158
-2
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user