A logged-in user could file submissions without bound and stream a 1 GiB context per submission. The only limits were the single-blob size cap and the 5 GiB uploads PVC (platform/workloads.go); nothing counted a user's rows or bytes, so one account could fill the volume and every other user's upload would start failing. - Create: per-user pending_review cap (default 5) — the review queue cannot be parked full of one account's rows. Check-then-insert, documented soft. - UploadContext: per-user stored-context budget (default 2 GiB) charged against the blob store's REAL sizes (new Blobs.Size on local/S3 stores), so the sum cannot drift from the volume; the write is capped at the remaining budget, so the excess is refused before it is persisted, and a re-upload is charged only for its new bytes. - API: per-user create/upload throttles (30s/15s, cmd/felis-wired) on a dedicated cooldown keyspace, reserve→release so a failed attempt never burns the window and a burst collapses to one winner; ErrQuotaExceeded → 403 submission_quota_exceeded (distinct from the 400 an oversize blob gets), 429 submission_cooldown for the throttles. - Panel: zh/en copy for both codes; openapi documents 403/429 on the two user routes; pgint covers the pending-queue count. Unit tests: submit package (cap, budget boundary/exact-fit/replacement, oversize-vs-quota split) and api handlers (quota 403 both paths, throttle 429 + recovery + failure-release). go vet/go test/gofmt clean; panel vitest 118 + typecheck green.
154 lines
5.9 KiB
Go
154 lines
5.9 KiB
Go
package submit
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
)
|
|
|
|
// contextBlobName is the fixed object name of a submission's build context under
|
|
// its id-namespaced prefix. It is the single source of truth for both the
|
|
// derived context ref (deriveContextRef) and the on-disk write target
|
|
// (LocalContextStore), so the blob always lands exactly where Kaniko's
|
|
// --context points (build/jobspec.go).
|
|
const contextBlobName = "context.tar.gz"
|
|
|
|
// idRE re-validates a submission id at the storage boundary. The Manager only
|
|
// ever passes ids it loaded from the Store (already the crypto-hex ids newID
|
|
// mints), but LocalContextStore is a standalone component that treats the id as
|
|
// untrusted path input: a lowercase-alphanumeric-with-dashes id can contain no
|
|
// path separator and no "..", so it can never escape Base. This is the same
|
|
// defense-in-depth stance as internal/backup's zip-slip guard.
|
|
var idRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,127}$`)
|
|
|
|
// LocalContextStore is the filesystem-backed build-context blob store: it writes
|
|
// each submission's uploaded modpack to {Base}/{id}/context.tar.gz on a mounted
|
|
// PVC. It is one of two implemented Blobs backends — cmd/felis selects it when
|
|
// user_uploads_context is a local path and S3ContextStore when it is an s3:// base;
|
|
// a base that is neither (or an s3:// base with no credentials configured) leaves
|
|
// Manager.Blobs nil so the upload endpoint returns 503 rather than pretending to
|
|
// accept a file it cannot persist.
|
|
//
|
|
// Base MUST equal the Manager's ContextStore so the blob lands exactly where
|
|
// deriveContextRef points Kaniko's --context; cmd/felis wires both from the one
|
|
// config field (registry.user_uploads_context).
|
|
//
|
|
// INTEGRATION-ONLY seam (out of scope of the upload transport): persisting the
|
|
// blob is end-to-end only once the same uploads PVC is mounted into the Kaniko
|
|
// build Pod and Kaniko is told to read a local context (build/jobspec.go passes
|
|
// the ref straight into --context). The transport here makes the file durable at
|
|
// the derived location; wiring that path into the sandboxed build Job is a
|
|
// separate deployment integration, exactly like the restore executor's PVC mount.
|
|
type LocalContextStore struct {
|
|
// Base is the directory (uploads PVC mount) submission contexts are written
|
|
// under. Each submission gets its own {Base}/{id}/ subdirectory.
|
|
Base string
|
|
}
|
|
|
|
// dir returns the per-submission directory, rejecting an id that could escape
|
|
// Base. Every path the store touches is rooted here.
|
|
func (s *LocalContextStore) dir(id string) (string, error) {
|
|
if !idRE.MatchString(id) {
|
|
return "", fmt.Errorf("submit: invalid submission id %q", id)
|
|
}
|
|
return filepath.Join(s.Base, id), nil
|
|
}
|
|
|
|
// Put writes r to {Base}/{id}/context.tar.gz atomically: it streams into a temp
|
|
// file in the same directory and renames it over any previous upload only on a
|
|
// fully successful copy. So a failed, truncated, or oversize upload never
|
|
// replaces a good context and never leaves a half-written blob for Kaniko to
|
|
// read; a re-upload while the submission is still pending simply supersedes the
|
|
// previous one. It returns the number of bytes stored.
|
|
func (s *LocalContextStore) Put(_ context.Context, id string, r io.Reader) (int64, error) {
|
|
dir, err := s.dir(id)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
|
return 0, fmt.Errorf("submit: mkdir context dir: %w", err)
|
|
}
|
|
tmp, err := os.CreateTemp(dir, contextBlobName+".*.tmp")
|
|
if err != nil {
|
|
return 0, fmt.Errorf("submit: create temp context: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
n, err := io.Copy(tmp, r)
|
|
if err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmpName)
|
|
return 0, fmt.Errorf("submit: write context blob: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
os.Remove(tmpName)
|
|
return 0, fmt.Errorf("submit: close context blob: %w", err)
|
|
}
|
|
if err := os.Rename(tmpName, filepath.Join(dir, contextBlobName)); err != nil {
|
|
os.Remove(tmpName)
|
|
return 0, fmt.Errorf("submit: commit context blob: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// Exists reports whether a context blob has been stored for id. Approve consults
|
|
// it so a submission whose context was never uploaded is refused BEFORE the CAS,
|
|
// instead of being approved into a build Kaniko cannot pull.
|
|
func (s *LocalContextStore) Exists(_ context.Context, id string) (bool, error) {
|
|
dir, err := s.dir(id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
switch _, err := os.Stat(filepath.Join(dir, contextBlobName)); {
|
|
case err == nil:
|
|
return true, nil
|
|
case os.IsNotExist(err):
|
|
return false, nil
|
|
default:
|
|
return false, fmt.Errorf("submit: stat context blob: %w", err)
|
|
}
|
|
}
|
|
|
|
// Open returns the stored context blob for id — the read side of the transport the
|
|
// build Pod's fetch initContainer uses. A missing blob is ErrBlobNotFound (404 on
|
|
// the route), never a bare os error, so the API keeps its status mapping.
|
|
func (s *LocalContextStore) Open(_ context.Context, id string) (io.ReadCloser, error) {
|
|
dir, err := s.dir(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f, err := os.Open(filepath.Join(dir, contextBlobName))
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("%w: %v", ErrBlobNotFound, err)
|
|
}
|
|
return nil, fmt.Errorf("submit: open context blob: %w", err)
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// Size reports the stored blob's size — the accounting read behind the per-user
|
|
// storage budget. A missing blob is (0, false, nil): absence is not an error
|
|
// here, it is simply no bytes to count (the same distinction Exists draws for
|
|
// Approve).
|
|
func (s *LocalContextStore) Size(_ context.Context, id string) (int64, bool, error) {
|
|
dir, err := s.dir(id)
|
|
if err != nil {
|
|
return 0, false, err
|
|
}
|
|
fi, err := os.Stat(filepath.Join(dir, contextBlobName))
|
|
switch {
|
|
case err == nil:
|
|
return fi.Size(), true, nil
|
|
case os.IsNotExist(err):
|
|
return 0, false, nil
|
|
default:
|
|
return 0, false, fmt.Errorf("submit: stat context blob: %w", err)
|
|
}
|
|
}
|
|
|
|
// Compile-time proof that the filesystem store satisfies the Blobs transport.
|
|
var _ Blobs = (*LocalContextStore)(nil)
|