Unverified Commit 87dee3e5 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent e7326e63
Loading
Loading
Loading
Loading
+29 −0
Changes for cmd/felis/api.go: 29 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for cmd/felis/context_max_test.go: 37 added lines, 0 removed lines.
Original line number Diff line number Diff line
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
Changes for deploy/bootstrap.sh: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -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
Changes for docs/openapi.yaml: 30 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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 −0
Changes for docs/troubleshooting.md: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
Loading