Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions epoch-server.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ spec:
# (tmpfs in most clusters) and OOMs on multi-GiB pushes.
- name: EPOCH_UPLOAD_DIR
value: /var/cache/epoch/uploads
# 307-redirect blob GETs to presigned URLs so multi-GiB bytes
# bypass this pod.
- name: EPOCH_BLOB_REDIRECT
value: "true"
# Optional redirect URL lifetime, Go duration (default 1h, max 7d).
# - name: EPOCH_BLOB_REDIRECT_TTL
# value: "1h"
# Optional: absolute base URL clients reach the server at. Used
# to anchor the OCI WWW-Authenticate realm + /v2/token. Required
# when fronting epoch with a proxy that does NOT set
Expand Down Expand Up @@ -131,11 +138,11 @@ spec:
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
memory: 1Gi
limits:
cpu: "3"
memory: 4Gi
volumeMounts:
- name: upload-spool
mountPath: /var/cache/epoch/uploads
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.25.6

require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/cocoonstack/cocoon-common v0.2.1
github.com/cocoonstack/cocoon-common v0.2.2-0.20260628160944-6e06987c3211
github.com/go-sql-driver/mysql v1.9.3
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZe
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
github.com/cockroachdb/redact v1.1.3 h1:AKZds10rFSIj7qADf0g46UixK8NNLwWTNdCIGS5wfSQ=
github.com/cockroachdb/redact v1.1.3/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cocoonstack/cocoon-common v0.2.1 h1:phf3UehIzTMxT/lIu8+RVAtaheH8KH3dBlA+uVYbp44=
github.com/cocoonstack/cocoon-common v0.2.1/go.mod h1:xIXbJ83vngQ2mrLC6q0Tw7h21M9BYBBqqYTcHaUrm1Y=
github.com/cocoonstack/cocoon-common v0.2.2-0.20260628160944-6e06987c3211 h1:1PpD7GG3juMZ9Do08bAQ3dq+qmba4zxJTak7psj90SA=
github.com/cocoonstack/cocoon-common v0.2.2-0.20260628160944-6e06987c3211/go.mod h1:xIXbJ83vngQ2mrLC6q0Tw7h21M9BYBBqqYTcHaUrm1Y=
github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
Expand Down
9 changes: 9 additions & 0 deletions objectstore/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ func (c *Client) Put(ctx context.Context, key string, body io.Reader, size int64
return nil
}

// PresignGet returns a time-limited URL for a direct GET, bypassing this process.
func (c *Client) PresignGet(ctx context.Context, key string, ttl time.Duration) (string, error) {
u, err := c.client.PresignedGetObject(ctx, c.cfg.Bucket, c.fullKey(key), ttl, nil)
if err != nil {
return "", fmt.Errorf("presign get %s: %w", key, err)
}
return u.String(), nil
}

// Get returns a streaming reader and size for the given key.
func (c *Client) Get(ctx context.Context, key string) (io.ReadCloser, int64, error) {
obj, err := c.client.GetObject(ctx, c.cfg.Bucket, c.fullKey(key), minio.GetObjectOptions{})
Expand Down
5 changes: 5 additions & 0 deletions registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ func (r *Registry) StreamBlob(ctx context.Context, digest string) (io.ReadCloser
return r.client.Get(ctx, blobKey(digest))
}

// PresignBlobGet returns a time-limited URL for a direct blob GET.
func (r *Registry) PresignBlobGet(ctx context.Context, digest string, ttl time.Duration) (string, error) {
return r.client.PresignGet(ctx, blobKey(digest), ttl)
}

// BlobSize returns the size of a blob in bytes.
func (r *Registry) BlobSize(ctx context.Context, digest string) (int64, error) {
return r.client.Head(ctx, blobKey(digest))
Expand Down
46 changes: 46 additions & 0 deletions server/registry_v2_blobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,26 @@ import (
"io"
"net/http"
"strconv"
"time"

"github.com/projecteru2/core/log"

"github.com/cocoonstack/epoch/manifest"
)

const (
defaultBlobRedirectTTL = time.Hour
// maxBlobRedirectTTL is the presign expiry cap; over it every presign fails.
maxBlobRedirectTTL = 7 * 24 * time.Hour
)

func (s *Server) v2GetBlob(w http.ResponseWriter, r *http.Request) {
dgst := stripSHA256Prefix(urlVar(r, "digest"))

if s.blobRedirect && s.redirectBlob(w, r, dgst) {
return
}

body, size, err := s.reg.StreamBlob(r.Context(), dgst)
if err != nil {
if isNotFound(err) {
Expand All @@ -31,6 +44,31 @@ func (s *Server) v2GetBlob(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(w, body)
}

// redirectBlob 307s the client to a presigned URL. Returns false without
// writing a response when it can't, so v2GetBlob falls back to streaming.
func (s *Server) redirectBlob(w http.ResponseWriter, r *http.Request, dgst string) bool {
logger := log.WithFunc("server.redirectBlob")
// presign succeeds even for a missing object, so HEAD first to return an
// OCI BLOB_UNKNOWN rather than 307 to a backend 404.
exists, err := s.reg.BlobExists(r.Context(), dgst)
if err != nil {
logger.Warnf(r.Context(), "blob exists check for %s failed, falling back to proxy: %v", dgst, err)
return false
}
if !exists {
v2Error(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob not found")
return true
}
url, err := s.reg.PresignBlobGet(r.Context(), dgst, s.blobRedirectTTL)
if err != nil {
logger.Warnf(r.Context(), "presign blob %s failed, falling back to proxy: %v", dgst, err)
return false
}
w.Header().Set("Docker-Content-Digest", "sha256:"+dgst)
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
return true
}

func (s *Server) v2HeadBlob(w http.ResponseWriter, r *http.Request) {
dgst := stripSHA256Prefix(urlVar(r, "digest"))

Expand Down Expand Up @@ -59,3 +97,11 @@ func (s *Server) v2PutBlob(w http.ResponseWriter, r *http.Request) {
}
s.persistMonolithicUpload(w, r, name, "sha256:"+dgst)
}

// clampBlobRedirectTTL keeps a TTL in the presign-valid range: sub-1s → default, over-7d → cap.
func clampBlobRedirectTTL(d time.Duration) time.Duration {
if d < time.Second {
return defaultBlobRedirectTTL
}
return min(d, maxBlobRedirectTTL)
}
25 changes: 25 additions & 0 deletions server/registry_v2_blobs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package server

import (
"testing"
"time"
)

func TestClampBlobRedirectTTL(t *testing.T) {
cases := []struct {
in time.Duration
want time.Duration
}{
{30 * time.Minute, 30 * time.Minute},
{2 * time.Hour, 2 * time.Hour},
{0, defaultBlobRedirectTTL},
{-5 * time.Minute, defaultBlobRedirectTTL},
{500 * time.Millisecond, defaultBlobRedirectTTL}, // under the 1s presign floor
{200 * time.Hour, maxBlobRedirectTTL}, // over the 7-day presign cap
}
for _, tc := range cases {
if got := clampBlobRedirectTTL(tc.in); got != tc.want {
t.Errorf("clampBlobRedirectTTL(%s) = %s, want %s", tc.in, got, tc.want)
}
}
}
30 changes: 20 additions & 10 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"

commonhttpx "github.com/cocoonstack/cocoon-common/httpx"
commonk8s "github.com/cocoonstack/cocoon-common/k8s"
"github.com/gorilla/mux"
"github.com/projecteru2/core/log"

Expand All @@ -29,9 +30,11 @@ var _ http.ResponseWriter = (*responseWriter)(nil)

// Server is the Epoch HTTP server providing OCI Distribution and control plane APIs.
type Server struct {
addr string // config
registryToken string // config — Bearer token for /v2/ (empty = no token required)
sso *SSOConfig // config — nil = UI auth disabled
addr string // config
registryToken string // config — Bearer token for /v2/ (empty = no token required)
sso *SSOConfig // config — nil = UI auth disabled
blobRedirect bool // config — redirect blob GETs to presigned object-store URLs
blobRedirectTTL time.Duration // config — presigned redirect URL lifetime

reg *registry.Registry // resources
store *store.Store // resources
Expand All @@ -54,14 +57,21 @@ func New(ctx context.Context, reg *registry.Registry, st *store.Store, addr stri
if regToken != "" {
logger.Info(ctx, "registry token auth enabled")
}
blobRedirect := commonk8s.EnvBool("EPOCH_BLOB_REDIRECT", false)
blobRedirectTTL := clampBlobRedirectTTL(commonk8s.EnvDuration("EPOCH_BLOB_REDIRECT_TTL", defaultBlobRedirectTTL))
if blobRedirect {
logger.Infof(ctx, "blob redirect enabled, ttl=%s", blobRedirectTTL)
}
s := &Server{
addr: addr,
registryToken: regToken,
sso: sso,
reg: reg,
store: st,
router: mux.NewRouter(),
uploads: newUploadSessions(resolveUploadDir(ctx)),
addr: addr,
registryToken: regToken,
sso: sso,
blobRedirect: blobRedirect,
blobRedirectTTL: blobRedirectTTL,
reg: reg,
store: st,
router: mux.NewRouter(),
uploads: newUploadSessions(resolveUploadDir(ctx)),
}
s.setupRoutes(ctx)
return s
Expand Down