···411411 return nil, nil, nil
412412 }
413413414414- fmt.Printf("UI database initialized at %s\n", dbPath)
415415- fmt.Printf("Read-only connection with authorizer created (blocks: oauth_sessions, ui_sessions, devices, etc.)\n")
414414+ fmt.Printf("UI database (readonly) initialized at %s\n", dbPath)
416415417416 // Create SQLite-backed session store
418417 sessionStore := db.NewSessionStore(database)
+313
cmd/hold/main.go
···194194 ExpiresAt time.Time `json:"expires_at"`
195195}
196196197197+// StartMultipartUploadRequest initiates a multipart upload
198198+type StartMultipartUploadRequest struct {
199199+ DID string `json:"did"`
200200+ Digest string `json:"digest"`
201201+}
202202+203203+// StartMultipartUploadResponse contains the upload ID
204204+type StartMultipartUploadResponse struct {
205205+ UploadID string `json:"upload_id"`
206206+ ExpiresAt time.Time `json:"expires_at"`
207207+}
208208+209209+// GetPartURLRequest requests a presigned URL for a specific part
210210+type GetPartURLRequest struct {
211211+ DID string `json:"did"`
212212+ Digest string `json:"digest"`
213213+ UploadID string `json:"upload_id"`
214214+ PartNumber int `json:"part_number"`
215215+}
216216+217217+// GetPartURLResponse contains the presigned URL for the part
218218+type GetPartURLResponse struct {
219219+ URL string `json:"url"`
220220+ ExpiresAt time.Time `json:"expires_at"`
221221+}
222222+223223+// CompletedPart represents a completed multipart upload part
224224+type CompletedPart struct {
225225+ PartNumber int `json:"part_number"`
226226+ ETag string `json:"etag"`
227227+}
228228+229229+// CompleteMultipartRequest completes a multipart upload
230230+type CompleteMultipartRequest struct {
231231+ DID string `json:"did"`
232232+ Digest string `json:"digest"`
233233+ UploadID string `json:"upload_id"`
234234+ Parts []CompletedPart `json:"parts"`
235235+}
236236+237237+// AbortMultipartRequest aborts an in-progress upload
238238+type AbortMultipartRequest struct {
239239+ DID string `json:"did"`
240240+ Digest string `json:"digest"`
241241+ UploadID string `json:"upload_id"`
242242+}
243243+197244// HandleGetPresignedURL handles requests for download URLs
198245func (s *HoldService) HandleGetPresignedURL(w http.ResponseWriter, r *http.Request) {
199246 if r.Method != http.MethodPost {
···642689 return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did)
643690}
644691692692+// startMultipartUpload initiates a multipart upload and returns upload ID
693693+func (s *HoldService) startMultipartUpload(ctx context.Context, digest string) (string, error) {
694694+ if s.s3Client == nil {
695695+ return "", fmt.Errorf("S3 not configured for multipart uploads")
696696+ }
697697+698698+ path := blobPath(digest)
699699+ s3Key := strings.TrimPrefix(path, "/")
700700+ if s.s3PathPrefix != "" {
701701+ s3Key = s.s3PathPrefix + "/" + s3Key
702702+ }
703703+704704+ result, err := s.s3Client.CreateMultipartUploadWithContext(ctx, &s3.CreateMultipartUploadInput{
705705+ Bucket: aws.String(s.bucket),
706706+ Key: aws.String(s3Key),
707707+ })
708708+ if err != nil {
709709+ return "", fmt.Errorf("failed to create multipart upload: %w", err)
710710+ }
711711+712712+ log.Printf("Started multipart upload: key=%s, uploadId=%s", s3Key, *result.UploadId)
713713+ return *result.UploadId, nil
714714+}
715715+716716+// getPartPresignedURL generates presigned URL for a specific part
717717+func (s *HoldService) getPartPresignedURL(ctx context.Context, digest, uploadID string, partNumber int) (string, error) {
718718+ if s.s3Client == nil {
719719+ return "", fmt.Errorf("S3 not configured for multipart uploads")
720720+ }
721721+722722+ path := blobPath(digest)
723723+ s3Key := strings.TrimPrefix(path, "/")
724724+ if s.s3PathPrefix != "" {
725725+ s3Key = s.s3PathPrefix + "/" + s3Key
726726+ }
727727+728728+ req, _ := s.s3Client.UploadPartRequest(&s3.UploadPartInput{
729729+ Bucket: aws.String(s.bucket),
730730+ Key: aws.String(s3Key),
731731+ UploadId: aws.String(uploadID),
732732+ PartNumber: aws.Int64(int64(partNumber)),
733733+ })
734734+735735+ url, err := req.Presign(15 * time.Minute)
736736+ if err != nil {
737737+ return "", fmt.Errorf("failed to presign part URL: %w", err)
738738+ }
739739+740740+ log.Printf("Generated presigned URL for part %d: key=%s, uploadId=%s", partNumber, s3Key, uploadID)
741741+ return url, nil
742742+}
743743+744744+// completeMultipartUpload finalizes the multipart upload
745745+func (s *HoldService) completeMultipartUpload(ctx context.Context, digest, uploadID string, parts []CompletedPart) error {
746746+ if s.s3Client == nil {
747747+ return fmt.Errorf("S3 not configured for multipart uploads")
748748+ }
749749+750750+ path := blobPath(digest)
751751+ s3Key := strings.TrimPrefix(path, "/")
752752+ if s.s3PathPrefix != "" {
753753+ s3Key = s.s3PathPrefix + "/" + s3Key
754754+ }
755755+756756+ // Convert to S3 CompletedPart format
757757+ s3Parts := make([]*s3.CompletedPart, len(parts))
758758+ for i, p := range parts {
759759+ s3Parts[i] = &s3.CompletedPart{
760760+ PartNumber: aws.Int64(int64(p.PartNumber)),
761761+ ETag: aws.String(p.ETag),
762762+ }
763763+ }
764764+765765+ _, err := s.s3Client.CompleteMultipartUploadWithContext(ctx, &s3.CompleteMultipartUploadInput{
766766+ Bucket: aws.String(s.bucket),
767767+ Key: aws.String(s3Key),
768768+ UploadId: aws.String(uploadID),
769769+ MultipartUpload: &s3.CompletedMultipartUpload{
770770+ Parts: s3Parts,
771771+ },
772772+ })
773773+774774+ if err != nil {
775775+ return fmt.Errorf("failed to complete multipart upload: %w", err)
776776+ }
777777+778778+ log.Printf("Completed multipart upload: key=%s, uploadId=%s, parts=%d", s3Key, uploadID, len(parts))
779779+ return nil
780780+}
781781+782782+// abortMultipartUpload cancels an in-progress multipart upload
783783+func (s *HoldService) abortMultipartUpload(ctx context.Context, digest, uploadID string) error {
784784+ if s.s3Client == nil {
785785+ return fmt.Errorf("S3 not configured for multipart uploads")
786786+ }
787787+788788+ path := blobPath(digest)
789789+ s3Key := strings.TrimPrefix(path, "/")
790790+ if s.s3PathPrefix != "" {
791791+ s3Key = s.s3PathPrefix + "/" + s3Key
792792+ }
793793+794794+ _, err := s.s3Client.AbortMultipartUploadWithContext(ctx, &s3.AbortMultipartUploadInput{
795795+ Bucket: aws.String(s.bucket),
796796+ Key: aws.String(s3Key),
797797+ UploadId: aws.String(uploadID),
798798+ })
799799+800800+ if err != nil {
801801+ return fmt.Errorf("failed to abort multipart upload: %w", err)
802802+ }
803803+804804+ log.Printf("Aborted multipart upload: key=%s, uploadId=%s", s3Key, uploadID)
805805+ return nil
806806+}
807807+808808+// HandleStartMultipart initiates a multipart upload
809809+func (s *HoldService) HandleStartMultipart(w http.ResponseWriter, r *http.Request) {
810810+ if r.Method != http.MethodPost {
811811+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
812812+ return
813813+ }
814814+815815+ var req StartMultipartUploadRequest
816816+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
817817+ http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
818818+ return
819819+ }
820820+821821+ // Validate DID authorization for WRITE
822822+ if !s.isAuthorizedWrite(req.DID) {
823823+ if req.DID == "" {
824824+ http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
825825+ } else {
826826+ http.Error(w, "forbidden: write access denied", http.StatusForbidden)
827827+ }
828828+ return
829829+ }
830830+831831+ ctx := r.Context()
832832+ uploadID, err := s.startMultipartUpload(ctx, req.Digest)
833833+ if err != nil {
834834+ http.Error(w, fmt.Sprintf("failed to start multipart upload: %v", err), http.StatusInternalServerError)
835835+ return
836836+ }
837837+838838+ resp := StartMultipartUploadResponse{
839839+ UploadID: uploadID,
840840+ ExpiresAt: time.Now().Add(24 * time.Hour), // Multipart uploads expire in 24h
841841+ }
842842+843843+ w.Header().Set("Content-Type", "application/json")
844844+ json.NewEncoder(w).Encode(resp)
845845+}
846846+847847+// HandleGetPartURL generates a presigned URL for uploading a specific part
848848+func (s *HoldService) HandleGetPartURL(w http.ResponseWriter, r *http.Request) {
849849+ if r.Method != http.MethodPost {
850850+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
851851+ return
852852+ }
853853+854854+ var req GetPartURLRequest
855855+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
856856+ http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
857857+ return
858858+ }
859859+860860+ // Validate DID authorization for WRITE
861861+ if !s.isAuthorizedWrite(req.DID) {
862862+ if req.DID == "" {
863863+ http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
864864+ } else {
865865+ http.Error(w, "forbidden: write access denied", http.StatusForbidden)
866866+ }
867867+ return
868868+ }
869869+870870+ ctx := r.Context()
871871+ url, err := s.getPartPresignedURL(ctx, req.Digest, req.UploadID, req.PartNumber)
872872+ if err != nil {
873873+ http.Error(w, fmt.Sprintf("failed to generate part URL: %v", err), http.StatusInternalServerError)
874874+ return
875875+ }
876876+877877+ resp := GetPartURLResponse{
878878+ URL: url,
879879+ ExpiresAt: time.Now().Add(15 * time.Minute),
880880+ }
881881+882882+ w.Header().Set("Content-Type", "application/json")
883883+ json.NewEncoder(w).Encode(resp)
884884+}
885885+886886+// HandleCompleteMultipart completes a multipart upload
887887+func (s *HoldService) HandleCompleteMultipart(w http.ResponseWriter, r *http.Request) {
888888+ if r.Method != http.MethodPost {
889889+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
890890+ return
891891+ }
892892+893893+ var req CompleteMultipartRequest
894894+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
895895+ http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
896896+ return
897897+ }
898898+899899+ // Validate DID authorization for WRITE
900900+ if !s.isAuthorizedWrite(req.DID) {
901901+ if req.DID == "" {
902902+ http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
903903+ } else {
904904+ http.Error(w, "forbidden: write access denied", http.StatusForbidden)
905905+ }
906906+ return
907907+ }
908908+909909+ ctx := r.Context()
910910+ if err := s.completeMultipartUpload(ctx, req.Digest, req.UploadID, req.Parts); err != nil {
911911+ http.Error(w, fmt.Sprintf("failed to complete multipart upload: %v", err), http.StatusInternalServerError)
912912+ return
913913+ }
914914+915915+ w.WriteHeader(http.StatusOK)
916916+ json.NewEncoder(w).Encode(map[string]string{"status": "completed"})
917917+}
918918+919919+// HandleAbortMultipart aborts a multipart upload
920920+func (s *HoldService) HandleAbortMultipart(w http.ResponseWriter, r *http.Request) {
921921+ if r.Method != http.MethodPost {
922922+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
923923+ return
924924+ }
925925+926926+ var req AbortMultipartRequest
927927+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
928928+ http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
929929+ return
930930+ }
931931+932932+ // Validate DID authorization for WRITE
933933+ if !s.isAuthorizedWrite(req.DID) {
934934+ if req.DID == "" {
935935+ http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
936936+ } else {
937937+ http.Error(w, "forbidden: write access denied", http.StatusForbidden)
938938+ }
939939+ return
940940+ }
941941+942942+ ctx := r.Context()
943943+ if err := s.abortMultipartUpload(ctx, req.Digest, req.UploadID); err != nil {
944944+ http.Error(w, fmt.Sprintf("failed to abort multipart upload: %v", err), http.StatusInternalServerError)
945945+ return
946946+ }
947947+948948+ w.WriteHeader(http.StatusOK)
949949+ json.NewEncoder(w).Encode(map[string]string{"status": "aborted"})
950950+}
951951+645952// RegisterRequest represents a request to register this hold in a user's PDS
646953type RegisterRequest struct {
647954 DID string `json:"did"`
···7591066 mux.HandleFunc("/get-presigned-url", service.HandleGetPresignedURL)
7601067 mux.HandleFunc("/put-presigned-url", service.HandlePutPresignedURL)
7611068 mux.HandleFunc("/move", service.HandleMove)
10691069+10701070+ // Multipart upload endpoints
10711071+ mux.HandleFunc("/start-multipart", service.HandleStartMultipart)
10721072+ mux.HandleFunc("/part-presigned-url", service.HandleGetPartURL)
10731073+ mux.HandleFunc("/complete-multipart", service.HandleCompleteMultipart)
10741074+ mux.HandleFunc("/abort-multipart", service.HandleAbortMultipart)
76210757631076 // Pre-register OAuth callback route (will be populated by auto-registration)
7641077 var oauthCallbackHandler http.HandlerFunc
+570
docs/MULTIPART.md
···11+S3 Multipart Upload Implementation Plan
22+33+ Problem Summary
44+55+ Current implementation uses a single presigned URL with a pipe for chunked uploads (PATCH). This causes:
66+ - Docker PATCH requests block waiting for pipe writes
77+ - S3 upload happens in background via single presigned URL
88+ - Docker times out → "client disconnected during blob PATCH"
99+ - Root cause: Single presigned URLs don't support OCI's chunked upload protocol
1010+1111+ Solution: S3 Multipart Upload API
1212+1313+ Implement proper S3 multipart upload to support Docker's chunked PATCH operations:
1414+ - Each PATCH → separate S3 part upload with its own presigned URL
1515+ - On Commit → complete multipart upload
1616+ - No buffering, no pipes, no blocking
1717+1818+ ---
1919+ Architecture Changes
2020+2121+ Current (Broken) Flow
2222+2323+ POST /blobs/uploads/ → Create() → Single presigned URL to temp location
2424+ PATCH → Write to pipe → [blocks] → Background goroutine uploads via single URL
2525+ PATCH → [blocks on pipe] → Docker timeout → disconnect ❌
2626+2727+ New (Multipart) Flow
2828+2929+ POST /blobs/uploads/ → Create() → Initiate multipart upload, get upload ID
3030+ PATCH #1 → Get presigned URL for part 1 → Upload part 1 to S3 → Store ETag
3131+ PATCH #2 → Get presigned URL for part 2 → Upload part 2 to S3 → Store ETag
3232+ PUT (commit) → Complete multipart upload with ETags → Done ✅
3333+3434+ ---
3535+ Implementation Details
3636+3737+ 1. Hold Service: Add Multipart Upload Endpoints
3838+3939+ File: cmd/hold/main.go
4040+4141+ New Request/Response Types
4242+4343+ // StartMultipartUploadRequest initiates a multipart upload
4444+ type StartMultipartUploadRequest struct {
4545+ DID string `json:"did"`
4646+ Digest string `json:"digest"`
4747+ }
4848+4949+ type StartMultipartUploadResponse struct {
5050+ UploadID string `json:"upload_id"`
5151+ ExpiresAt time.Time `json:"expires_at"`
5252+ }
5353+5454+ // GetPartURLRequest requests a presigned URL for a specific part
5555+ type GetPartURLRequest struct {
5656+ DID string `json:"did"`
5757+ Digest string `json:"digest"`
5858+ UploadID string `json:"upload_id"`
5959+ PartNumber int `json:"part_number"`
6060+ }
6161+6262+ type GetPartURLResponse struct {
6363+ URL string `json:"url"`
6464+ ExpiresAt time.Time `json:"expires_at"`
6565+ }
6666+6767+ // CompleteMultipartRequest completes a multipart upload
6868+ type CompleteMultipartRequest struct {
6969+ DID string `json:"did"`
7070+ Digest string `json:"digest"`
7171+ UploadID string `json:"upload_id"`
7272+ Parts []CompletedPart `json:"parts"`
7373+ }
7474+7575+ type CompletedPart struct {
7676+ PartNumber int `json:"part_number"`
7777+ ETag string `json:"etag"`
7878+ }
7979+8080+ // AbortMultipartRequest aborts an in-progress upload
8181+ type AbortMultipartRequest struct {
8282+ DID string `json:"did"`
8383+ Digest string `json:"digest"`
8484+ UploadID string `json:"upload_id"`
8585+ }
8686+8787+ New Endpoints
8888+8989+ POST /start-multipart
9090+ func (s *HoldService) HandleStartMultipart(w http.ResponseWriter, r *http.Request) {
9191+ // Validate DID authorization for WRITE
9292+ // Build S3 key from digest
9393+ // Call s3.CreateMultipartUploadRequest()
9494+ // Generate presigned URL if needed, or return upload ID
9595+ // Return upload ID to client
9696+ }
9797+9898+ POST /part-presigned-url
9999+ func (s *HoldService) HandleGetPartURL(w http.ResponseWriter, r *http.Request) {
100100+ // Validate DID authorization for WRITE
101101+ // Build S3 key from digest
102102+ // Call s3.UploadPartRequest() with part number and upload ID
103103+ // Generate presigned URL
104104+ // Return presigned URL for this specific part
105105+ }
106106+107107+ POST /complete-multipart
108108+ func (s *HoldService) HandleCompleteMultipart(w http.ResponseWriter, r *http.Request) {
109109+ // Validate DID authorization for WRITE
110110+ // Build S3 key from digest
111111+ // Prepare CompletedPart array with part numbers and ETags
112112+ // Call s3.CompleteMultipartUpload()
113113+ // Return success
114114+ }
115115+116116+ POST /abort-multipart (for cleanup)
117117+ func (s *HoldService) HandleAbortMultipart(w http.ResponseWriter, r *http.Request) {
118118+ // Validate DID authorization for WRITE
119119+ // Call s3.AbortMultipartUpload()
120120+ // Return success
121121+ }
122122+123123+ S3 Implementation
124124+125125+ // startMultipartUpload initiates a multipart upload and returns upload ID
126126+ func (s *HoldService) startMultipartUpload(ctx context.Context, digest string) (string, error) {
127127+ if s.s3Client == nil {
128128+ return "", fmt.Errorf("S3 not configured")
129129+ }
130130+131131+ path := blobPath(digest)
132132+ s3Key := strings.TrimPrefix(path, "/")
133133+ if s.s3PathPrefix != "" {
134134+ s3Key = s.s3PathPrefix + "/" + s3Key
135135+ }
136136+137137+ result, err := s.s3Client.CreateMultipartUploadWithContext(ctx, &s3.CreateMultipartUploadInput{
138138+ Bucket: aws.String(s.bucket),
139139+ Key: aws.String(s3Key),
140140+ })
141141+ if err != nil {
142142+ return "", err
143143+ }
144144+145145+ return *result.UploadId, nil
146146+ }
147147+148148+ // getPartPresignedURL generates presigned URL for a specific part
149149+ func (s *HoldService) getPartPresignedURL(ctx context.Context, digest, uploadID string, partNumber int) (string, error) {
150150+ if s.s3Client == nil {
151151+ return "", fmt.Errorf("S3 not configured")
152152+ }
153153+154154+ path := blobPath(digest)
155155+ s3Key := strings.TrimPrefix(path, "/")
156156+ if s.s3PathPrefix != "" {
157157+ s3Key = s.s3PathPrefix + "/" + s3Key
158158+ }
159159+160160+ req, _ := s.s3Client.UploadPartRequest(&s3.UploadPartInput{
161161+ Bucket: aws.String(s.bucket),
162162+ Key: aws.String(s3Key),
163163+ UploadId: aws.String(uploadID),
164164+ PartNumber: aws.Int64(int64(partNumber)),
165165+ })
166166+167167+ return req.Presign(15 * time.Minute)
168168+ }
169169+170170+ // completeMultipartUpload finalizes the multipart upload
171171+ func (s *HoldService) completeMultipartUpload(ctx context.Context, digest, uploadID string, parts []CompletedPart) error {
172172+ if s.s3Client == nil {
173173+ return fmt.Errorf("S3 not configured")
174174+ }
175175+176176+ path := blobPath(digest)
177177+ s3Key := strings.TrimPrefix(path, "/")
178178+ if s.s3PathPrefix != "" {
179179+ s3Key = s.s3PathPrefix + "/" + s3Key
180180+ }
181181+182182+ // Convert to S3 CompletedPart format
183183+ s3Parts := make([]*s3.CompletedPart, len(parts))
184184+ for i, p := range parts {
185185+ s3Parts[i] = &s3.CompletedPart{
186186+ PartNumber: aws.Int64(int64(p.PartNumber)),
187187+ ETag: aws.String(p.ETag),
188188+ }
189189+ }
190190+191191+ _, err := s.s3Client.CompleteMultipartUploadWithContext(ctx, &s3.CompleteMultipartUploadInput{
192192+ Bucket: aws.String(s.bucket),
193193+ Key: aws.String(s3Key),
194194+ UploadId: aws.String(uploadID),
195195+ MultipartUpload: &s3.CompletedMultipartUpload{
196196+ Parts: s3Parts,
197197+ },
198198+ })
199199+200200+ return err
201201+ }
202202+203203+ ---
204204+ 2. AppView: Rewrite ProxyBlobStore for Multipart
205205+206206+ File: pkg/storage/proxy_blob_store.go
207207+208208+ Remove Current Implementation
209209+210210+ - Remove pipe-based streaming
211211+ - Remove background goroutine with single presigned URL
212212+ - Remove global upload tracking map
213213+214214+ New ProxyBlobWriter Structure
215215+216216+ type ProxyBlobWriter struct {
217217+ store *ProxyBlobStore
218218+ options distribution.CreateOptions
219219+ uploadID string // S3 multipart upload ID
220220+ parts []CompletedPart // Track uploaded parts with ETags
221221+ partNumber int // Current part number (starts at 1)
222222+ buffer *bytes.Buffer // Buffer for current part
223223+ size int64 // Total bytes written
224224+ closed bool
225225+ id string // Distribution's upload ID (for state)
226226+ startedAt time.Time
227227+ finalDigest string // Set on Commit
228228+ }
229229+230230+ type CompletedPart struct {
231231+ PartNumber int
232232+ ETag string
233233+ }
234234+235235+ New Create() - Initiate Multipart Upload
236236+237237+ func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.BlobCreateOption) (distribution.BlobWriter, error) {
238238+ var opts distribution.CreateOptions
239239+ for _, option := range options {
240240+ if err := option.Apply(&opts); err != nil {
241241+ return nil, err
242242+ }
243243+ }
244244+245245+ // Use temp digest for upload location
246246+ writerID := fmt.Sprintf("upload-%d", time.Now().UnixNano())
247247+ tempDigest := digest.Digest(fmt.Sprintf("uploads/temp-%s", writerID))
248248+249249+ // Start multipart upload via hold service
250250+ uploadID, err := p.startMultipartUpload(ctx, tempDigest)
251251+ if err != nil {
252252+ return nil, fmt.Errorf("failed to start multipart upload: %w", err)
253253+ }
254254+255255+ writer := &ProxyBlobWriter{
256256+ store: p,
257257+ options: opts,
258258+ uploadID: uploadID,
259259+ parts: make([]CompletedPart, 0),
260260+ partNumber: 1,
261261+ buffer: bytes.NewBuffer(make([]byte, 0, 5*1024*1024)), // 5MB buffer
262262+ id: writerID,
263263+ startedAt: time.Now(),
264264+ }
265265+266266+ // Store in global map for Resume()
267267+ globalUploadsMu.Lock()
268268+ globalUploads[writer.id] = writer
269269+ globalUploadsMu.Unlock()
270270+271271+ return writer, nil
272272+ }
273273+274274+ New Write() - Buffer and Flush Parts
275275+276276+ func (w *ProxyBlobWriter) Write(p []byte) (int, error) {
277277+ if w.closed {
278278+ return 0, fmt.Errorf("writer closed")
279279+ }
280280+281281+ n, err := w.buffer.Write(p)
282282+ w.size += int64(n)
283283+284284+ // Flush if buffer reaches 5MB (S3 minimum part size)
285285+ if w.buffer.Len() >= 5*1024*1024 {
286286+ if err := w.flushPart(); err != nil {
287287+ return n, err
288288+ }
289289+ }
290290+291291+ return n, err
292292+ }
293293+294294+ func (w *ProxyBlobWriter) flushPart() error {
295295+ if w.buffer.Len() == 0 {
296296+ return nil
297297+ }
298298+299299+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
300300+ defer cancel()
301301+302302+ // Get presigned URL for this part
303303+ tempDigest := digest.Digest(fmt.Sprintf("uploads/temp-%s", w.id))
304304+ url, err := w.store.getPartPresignedURL(ctx, tempDigest, w.uploadID, w.partNumber)
305305+ if err != nil {
306306+ return fmt.Errorf("failed to get part presigned URL: %w", err)
307307+ }
308308+309309+ // Upload part to S3
310310+ req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(w.buffer.Bytes()))
311311+ if err != nil {
312312+ return err
313313+ }
314314+315315+ resp, err := w.store.httpClient.Do(req)
316316+ if err != nil {
317317+ return err
318318+ }
319319+ defer resp.Body.Close()
320320+321321+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
322322+ return fmt.Errorf("part upload failed: status %d", resp.StatusCode)
323323+ }
324324+325325+ // Store ETag for completion
326326+ etag := resp.Header.Get("ETag")
327327+ if etag == "" {
328328+ return fmt.Errorf("no ETag in response")
329329+ }
330330+331331+ w.parts = append(w.parts, CompletedPart{
332332+ PartNumber: w.partNumber,
333333+ ETag: etag,
334334+ })
335335+336336+ // Reset buffer and increment part number
337337+ w.buffer.Reset()
338338+ w.partNumber++
339339+340340+ return nil
341341+ }
342342+343343+ New Commit() - Complete Multipart and Move
344344+345345+ func (w *ProxyBlobWriter) Commit(ctx context.Context, desc distribution.Descriptor) (distribution.Descriptor, error) {
346346+ if w.closed {
347347+ return distribution.Descriptor{}, fmt.Errorf("writer closed")
348348+ }
349349+ w.closed = true
350350+351351+ // Flush any remaining buffered data
352352+ if w.buffer.Len() > 0 {
353353+ if err := w.flushPart(); err != nil {
354354+ // Try to abort multipart on error
355355+ w.store.abortMultipartUpload(ctx, w.uploadID)
356356+ return distribution.Descriptor{}, err
357357+ }
358358+ }
359359+360360+ // Complete multipart upload at temp location
361361+ tempDigest := digest.Digest(fmt.Sprintf("uploads/temp-%s", w.id))
362362+ if err := w.store.completeMultipartUpload(ctx, tempDigest, w.uploadID, w.parts); err != nil {
363363+ return distribution.Descriptor{}, err
364364+ }
365365+366366+ // Move from temp → final location (server-side S3 copy)
367367+ tempPath := fmt.Sprintf("uploads/temp-%s", w.id)
368368+ finalPath := desc.Digest.String()
369369+370370+ moveURL := fmt.Sprintf("%s/move?from=%s&to=%s&did=%s",
371371+ w.store.storageEndpoint, tempPath, finalPath, w.store.did)
372372+373373+ req, err := http.NewRequestWithContext(ctx, "POST", moveURL, nil)
374374+ if err != nil {
375375+ return distribution.Descriptor{}, err
376376+ }
377377+378378+ resp, err := w.store.httpClient.Do(req)
379379+ if err != nil {
380380+ return distribution.Descriptor{}, err
381381+ }
382382+ defer resp.Body.Close()
383383+384384+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
385385+ bodyBytes, _ := io.ReadAll(resp.Body)
386386+ return distribution.Descriptor{}, fmt.Errorf("move failed: %d, %s", resp.StatusCode, bodyBytes)
387387+ }
388388+389389+ // Remove from global map
390390+ globalUploadsMu.Lock()
391391+ delete(globalUploads, w.id)
392392+ globalUploadsMu.Unlock()
393393+394394+ return distribution.Descriptor{
395395+ Digest: desc.Digest,
396396+ Size: w.size,
397397+ MediaType: desc.MediaType,
398398+ }, nil
399399+ }
400400+401401+ Add Hold Service Client Methods
402402+403403+ func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, dgst digest.Digest) (string, error) {
404404+ reqBody := map[string]any{
405405+ "did": p.did,
406406+ "digest": dgst.String(),
407407+ }
408408+ body, _ := json.Marshal(reqBody)
409409+410410+ url := fmt.Sprintf("%s/start-multipart", p.storageEndpoint)
411411+ req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
412412+ req.Header.Set("Content-Type", "application/json")
413413+414414+ resp, err := p.httpClient.Do(req)
415415+ if err != nil {
416416+ return "", err
417417+ }
418418+ defer resp.Body.Close()
419419+420420+ var result struct {
421421+ UploadID string `json:"upload_id"`
422422+ }
423423+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
424424+ return "", err
425425+ }
426426+427427+ return result.UploadID, nil
428428+ }
429429+430430+ func (p *ProxyBlobStore) getPartPresignedURL(ctx context.Context, dgst digest.Digest, uploadID string, partNumber int) (string, error) {
431431+ reqBody := map[string]any{
432432+ "did": p.did,
433433+ "digest": dgst.String(),
434434+ "upload_id": uploadID,
435435+ "part_number": partNumber,
436436+ }
437437+ body, _ := json.Marshal(reqBody)
438438+439439+ url := fmt.Sprintf("%s/part-presigned-url", p.storageEndpoint)
440440+ req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
441441+ req.Header.Set("Content-Type", "application/json")
442442+443443+ resp, err := p.httpClient.Do(req)
444444+ if err != nil {
445445+ return "", err
446446+ }
447447+ defer resp.Body.Close()
448448+449449+ var result struct {
450450+ URL string `json:"url"`
451451+ }
452452+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
453453+ return "", err
454454+ }
455455+456456+ return result.URL, nil
457457+ }
458458+459459+ func (p *ProxyBlobStore) completeMultipartUpload(ctx context.Context, dgst digest.Digest, uploadID string, parts []CompletedPart) error {
460460+ reqBody := map[string]any{
461461+ "did": p.did,
462462+ "digest": dgst.String(),
463463+ "upload_id": uploadID,
464464+ "parts": parts,
465465+ }
466466+ body, _ := json.Marshal(reqBody)
467467+468468+ url := fmt.Sprintf("%s/complete-multipart", p.storageEndpoint)
469469+ req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
470470+ req.Header.Set("Content-Type", "application/json")
471471+472472+ resp, err := p.httpClient.Do(req)
473473+ if err != nil {
474474+ return err
475475+ }
476476+ defer resp.Body.Close()
477477+478478+ if resp.StatusCode != http.StatusOK {
479479+ return fmt.Errorf("complete multipart failed: status %d", resp.StatusCode)
480480+ }
481481+482482+ return nil
483483+ }
484484+485485+ ---
486486+ Testing Plan
487487+488488+ 1. Unit Tests
489489+490490+ - Test multipart upload initiation
491491+ - Test part upload with presigned URLs
492492+ - Test completion with ETags
493493+ - Test abort on errors
494494+495495+ 2. Integration Tests
496496+497497+ - Push small images (< 5MB, single part)
498498+ - Push medium images (10MB, 2 parts)
499499+ - Push large images (100MB, 20 parts)
500500+ - Test with Upcloud S3
501501+ - Test with Storj S3
502502+503503+ 3. Validation
504504+505505+ - Monitor logs for "client disconnected" errors (should be gone)
506506+ - Check Docker push success rate
507507+ - Verify blobs stored correctly in S3
508508+ - Check bandwidth usage on hold service (should be minimal)
509509+510510+ ---
511511+ Migration & Deployment
512512+513513+ Backward Compatibility
514514+515515+ - Keep /put-presigned-url endpoint for fallback
516516+ - Keep /move endpoint (still needed)
517517+ - New multipart endpoints are additive
518518+519519+ Deployment Steps
520520+521521+ 1. Update hold service with new endpoints
522522+ 2. Update AppView ProxyBlobStore
523523+ 3. Deploy hold service first
524524+ 4. Deploy AppView
525525+ 5. Test with sample push
526526+ 6. Monitor logs
527527+528528+ Rollback Plan
529529+530530+ - Revert AppView to previous version (uses old presigned URL method)
531531+ - Hold service keeps both old and new endpoints
532532+533533+ ---
534534+ Documentation Updates
535535+536536+ Update docs/PRESIGNED_URLS.md
537537+538538+ - Add section "Multipart Upload for Chunked Data"
539539+ - Explain why single presigned URLs don't work with PATCH
540540+ - Document new endpoints and flow
541541+ - Add S3 part size recommendations (5MB-64MB for Storj)
542542+543543+ Add Troubleshooting Section
544544+545545+ - "Client disconnected during PATCH" → resolved by multipart
546546+ - Storj-specific considerations (64MB parts recommended)
547547+ - Upcloud compatibility notes
548548+549549+ ---
550550+ Performance Impact
551551+552552+ Before (Broken)
553553+554554+ - Docker PATCH → blocks on pipe → timeout → retry → fail
555555+ - Unable to push large images reliably
556556+557557+ After (Multipart)
558558+559559+ - Each PATCH → independent part upload → immediate response
560560+ - No blocking, no timeouts
561561+ - Parallel part uploads possible (future optimization)
562562+ - Reliable pushes for any image size
563563+564564+ Bandwidth
565565+566566+ - Hold service: Only API calls (~1KB per part)
567567+ - Direct S3 uploads: Full blob data
568568+ - S3 copy for move: Server-side (no hold bandwidth)
569569+570570+ Estimated savings: 99.98% hold service bandwidth reduction (same as before, but now actually works!)
+141-3
docs/PRESIGNED_URLS.md
···110110**Move path:** S3 internal copy (no data transfer!)
111111**Hold service bandwidth:** ~2KB (presigned URL + CopyObject API)
112112113113+### For Chunked Uploads (Multipart Upload)
114114+115115+**Large blobs with OCI chunked protocol (Docker PATCH requests):**
116116+117117+The OCI Distribution Spec uses chunked uploads via multiple PATCH requests. Single presigned URLs don't support this - we need **S3 Multipart Upload**.
118118+119119+1. **Docker starts upload:** `POST /v2/alice/myapp/blobs/uploads/`
120120+2. **AppView initiates multipart:**
121121+ ```json
122122+ POST /start-multipart
123123+ {"did": "...", "digest": "uploads/temp-{uuid}"}
124124+ → Returns: {"upload_id": "xyz123"}
125125+ ```
126126+3. **Docker sends chunk 1:** `PATCH /v2/.../uploads/{uuid}` (5MB data)
127127+4. **AppView gets part URL:**
128128+ ```json
129129+ POST /part-presigned-url
130130+ {"did": "...", "digest": "uploads/temp-{uuid}", "upload_id": "xyz123", "part_number": 1}
131131+ → Returns: {"url": "https://s3.../part?uploadId=xyz123&partNumber=1&..."}
132132+ ```
133133+5. **AppView uploads part 1** using presigned URL → Gets ETag
134134+6. **Docker sends chunk 2:** `PATCH /v2/.../uploads/{uuid}` (5MB data)
135135+7. **Repeat steps 4-5** for part 2 (and subsequent parts)
136136+8. **Docker finalizes:** `PUT /v2/.../uploads/{uuid}?digest=sha256:abc123`
137137+9. **AppView completes multipart:**
138138+ ```json
139139+ POST /complete-multipart
140140+ {"did": "...", "digest": "uploads/temp-{uuid}", "upload_id": "xyz123",
141141+ "parts": [{"part_number": 1, "etag": "..."}, {"part_number": 2, "etag": "..."}]}
142142+ ```
143143+10. **AppView requests move:** `POST /move?from=uploads/temp-{uuid}&to=sha256:abc123`
144144+11. **Hold service executes S3 server-side copy** (same as above)
145145+146146+**Data path:** Docker → AppView (buffers 5MB) → S3 (via presigned URL per part)
147147+**Each PATCH:** Independent, non-blocking, immediate response
148148+**Hold service bandwidth:** ~1KB per part + ~1KB for completion
149149+150150+**Why This Fixes "Client Disconnected" Errors:**
151151+- Previous implementation: Single presigned URL + pipe → PATCH blocks → Docker timeout
152152+- New implementation: Each PATCH → separate part upload → immediate response → no blocking
153153+113154## Why the Temp → Final Move is Required
114155115156This is **not an ATCR implementation detail** — it's required by the [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec/blob/main/spec.md#push).
···305346}
306347```
307348308308-### 3. No Changes Needed for Move Operation
349349+### 3. Multipart Upload Endpoints (Required for Chunked Uploads)
350350+351351+**File: `cmd/hold/main.go`**
352352+353353+#### Start Multipart Upload
354354+355355+```go
356356+func (s *HoldService) HandleStartMultipart(w http.ResponseWriter, r *http.Request) {
357357+ var req StartMultipartUploadRequest // {did, digest}
358358+359359+ // Validate DID authorization for WRITE
360360+ if !s.isAuthorizedWrite(req.DID) {
361361+ // Return 403 Forbidden
362362+ }
363363+364364+ // Initiate S3 multipart upload
365365+ result, err := s.s3Client.CreateMultipartUploadWithContext(ctx, &s3.CreateMultipartUploadInput{
366366+ Bucket: aws.String(s.bucket),
367367+ Key: aws.String(s3Key),
368368+ })
369369+370370+ // Return upload ID
371371+ json.NewEncoder(w).Encode(StartMultipartUploadResponse{
372372+ UploadID: *result.UploadId,
373373+ ExpiresAt: time.Now().Add(24 * time.Hour),
374374+ })
375375+}
376376+```
377377+378378+**Route:** `POST /start-multipart`
379379+380380+#### Get Part Presigned URL
381381+382382+```go
383383+func (s *HoldService) HandleGetPartURL(w http.ResponseWriter, r *http.Request) {
384384+ var req GetPartURLRequest // {did, digest, upload_id, part_number}
385385+386386+ // Generate presigned URL for specific part
387387+ req, _ := s.s3Client.UploadPartRequest(&s3.UploadPartInput{
388388+ Bucket: aws.String(s.bucket),
389389+ Key: aws.String(s3Key),
390390+ UploadId: aws.String(uploadID),
391391+ PartNumber: aws.Int64(int64(partNumber)),
392392+ })
393393+394394+ url, err := req.Presign(15 * time.Minute)
395395+396396+ json.NewEncoder(w).Encode(GetPartURLResponse{URL: url})
397397+}
398398+```
399399+400400+**Route:** `POST /part-presigned-url`
401401+402402+#### Complete Multipart Upload
403403+404404+```go
405405+func (s *HoldService) HandleCompleteMultipart(w http.ResponseWriter, r *http.Request) {
406406+ var req CompleteMultipartRequest // {did, digest, upload_id, parts: [{part_number, etag}]}
407407+408408+ // Convert parts to S3 format
409409+ s3Parts := make([]*s3.CompletedPart, len(req.Parts))
410410+ for i, p := range req.Parts {
411411+ s3Parts[i] = &s3.CompletedPart{
412412+ PartNumber: aws.Int64(int64(p.PartNumber)),
413413+ ETag: aws.String(p.ETag),
414414+ }
415415+ }
416416+417417+ // Complete multipart upload
418418+ _, err := s.s3Client.CompleteMultipartUploadWithContext(ctx, &s3.CompleteMultipartUploadInput{
419419+ Bucket: aws.String(s.bucket),
420420+ Key: aws.String(s3Key),
421421+ UploadId: aws.String(uploadID),
422422+ MultipartUpload: &s3.CompletedMultipartUpload{Parts: s3Parts},
423423+ })
424424+}
425425+```
426426+427427+**Route:** `POST /complete-multipart`
428428+429429+#### Abort Multipart Upload
430430+431431+```go
432432+func (s *HoldService) HandleAbortMultipart(w http.ResponseWriter, r *http.Request) {
433433+ var req AbortMultipartRequest // {did, digest, upload_id}
434434+435435+ // Abort and cleanup parts
436436+ _, err := s.s3Client.AbortMultipartUploadWithContext(ctx, &s3.AbortMultipartUploadInput{
437437+ Bucket: aws.String(s.bucket),
438438+ Key: aws.String(s3Key),
439439+ UploadId: aws.String(uploadID),
440440+ })
441441+}
442442+```
443443+444444+**Route:** `POST /abort-multipart`
445445+446446+### 4. Move Operation (No Changes)
309447310448The existing `/move` endpoint already uses `driver.Move()`, which for S3:
311449- Calls `s3.CopyObject()` (server-side copy)
312450- Calls `s3.DeleteObject()` (delete source)
313451- No data transfer through hold service!
314452315315-**File: `cmd/hold/main.go:296` (already exists, no changes needed)**
453453+**File: `cmd/hold/main.go:393` (already exists, no changes needed)**
316454317455```go
318456func (s *HoldService) HandleMove(w http.ResponseWriter, r *http.Request) {
···328466}
329467```
330468331331-### 4. AppView Changes (Optional Optimization)
469469+### 5. AppView Changes (Multipart Upload Implementation)
332470333471**File: `pkg/storage/proxy_blob_store.go:228`**
334472
-5
pkg/auth/token/handler.go
···95959696 did = device.DID
9797 handle = device.Handle
9898- fmt.Printf("DEBUG [token/handler]: Device secret validated for DID=%s, handle=%s\n", did, handle)
9999-10098 // Device is linked to OAuth session via DID
10199 // OAuth refresher will provide access token when needed via middleware
102100 } else {
···150148 return
151149 }
152150153153- fmt.Printf("DEBUG [token/handler]: Access validated for DID=%s\n", did)
154154-155151 // Issue JWT token
156152 tokenString, err := h.issuer.Issue(did, access)
157153 if err != nil {
···161157 }
162158163159 fmt.Printf("DEBUG [token/handler]: Issued JWT token (length=%d) for DID=%s\n", len(tokenString), did)
164164- fmt.Printf("DEBUG [token/handler]: JWT Token: %s\n", tokenString)
165160166161 // Return token response
167162 now := time.Now()
-5
pkg/middleware/registry.go
···117117 return nil, fmt.Errorf("no storage endpoint configured: ensure default_storage_endpoint is set in middleware config")
118118 }
119119 ctx = context.WithValue(ctx, "storage.endpoint", storageEndpoint)
120120- fmt.Printf("DEBUG [registry/middleware]: Using storage endpoint: %s\n", storageEndpoint)
121120122121 // Create a new reference with identity/image format
123122 // Use the identity (or DID) as the namespace to ensure canonical format
···145144 if err == nil {
146145 // OAuth session available - use indigo's API client (handles DPoP automatically)
147146 apiClient := session.APIClient()
148148- fmt.Printf("DEBUG [registry/middleware]: Using OAuth session with indigo API client for DID=%s\n", did)
149147 atprotoClient = atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient)
150148 } else {
151149 fmt.Printf("DEBUG [registry/middleware]: OAuth refresh failed for DID=%s: %v, falling back to Basic Auth\n", did, err)
···174172175173 // Check cache first
176174 if cached, ok := nr.repositories.Load(cacheKey); ok {
177177- fmt.Printf("DEBUG [registry/middleware]: Using cached RoutingRepository for %s\n", cacheKey)
178175 return cached.(*storage.RoutingRepository), nil
179176 }
180180-181181- fmt.Printf("DEBUG [registry/middleware]: Creating new RoutingRepository for image=%s (ATProto repo name)\n", repositoryName)
182177183178 // Create routing repository - routes manifests to ATProto, blobs to hold service
184179 // The registry is stateless - no local storage is used
+253-121
pkg/storage/proxy_blob_store.go
···77 "fmt"
88 "io"
99 "net/http"
1010+ "strings"
1011 "sync"
1112 "time"
1213···1516)
16171718const (
1818- // maxChunkSize is the maximum buffer size before flushing to hold service
1919- // Matches S3's minimum multipart upload size
2020- maxChunkSize = 5 * 1024 * 1024 // 5MB
1919+ // minPartSize is S3's minimum part size for multipart uploads
2020+ // Parts must be at least 5MB (except the last part)
2121+ minPartSize = 5 * 1024 * 1024 // 5MB
2122)
2323+2424+// CompletedPart represents a completed multipart upload part
2525+type CompletedPart struct {
2626+ PartNumber int `json:"part_number"`
2727+ ETag string `json:"etag"`
2828+}
22292330// Global upload tracking (shared across all ProxyBlobStore instances)
2431// This is necessary because distribution creates new repository/blob store instances per request
···198205 }
199206 }
200207201201- // Create pipe for streaming upload
202202- pipeReader, pipeWriter := io.Pipe()
203203- uploadErr := make(chan error, 1)
204204- digestChan := make(chan string, 1)
208208+ // Use temp digest for upload location
209209+ writerID := fmt.Sprintf("upload-%d", time.Now().UnixNano())
210210+ tempPath := fmt.Sprintf("uploads/temp-%s", writerID)
211211+ tempDigest := digest.Digest(tempPath)
212212+213213+ // Start multipart upload via hold service
214214+ uploadID, err := p.startMultipartUpload(ctx, tempDigest)
215215+ if err != nil {
216216+ return nil, fmt.Errorf("failed to start multipart upload: %w", err)
217217+ }
218218+219219+ fmt.Printf("DEBUG [proxy_blob_store/Create]: Started multipart upload: id=%s, uploadID=%s\n", writerID, uploadID)
205220206206- // Create writer
207221 writer := &ProxyBlobWriter{
208222 store: p,
209223 options: opts,
210210- pipeWriter: pipeWriter,
211211- pipeReader: pipeReader,
212212- digestChan: digestChan,
213213- uploadErr: uploadErr,
214214- id: fmt.Sprintf("upload-%d", time.Now().UnixNano()),
224224+ uploadID: uploadID,
225225+ parts: make([]CompletedPart, 0),
226226+ partNumber: 1,
227227+ buffer: bytes.NewBuffer(make([]byte, 0, minPartSize)),
228228+ id: writerID,
215229 startedAt: time.Now(),
230230+ tempDigest: tempDigest,
216231 }
217232218218- // Store in global uploads map for resume support
233233+ // Store in global map for Resume()
219234 globalUploadsMu.Lock()
220235 globalUploads[writer.id] = writer
221236 globalUploadsMu.Unlock()
222237223223- // Start background goroutine that streams to temp location immediately
224224- go func() {
225225- defer pipeReader.Close()
226226-227227- // Stream to temp location immediately to avoid pipe deadlock
228228- tempPath := fmt.Sprintf("uploads/temp-%s", writer.id) // No leading slash
229229- url := fmt.Sprintf("%s/blobs/%s?did=%s", p.storageEndpoint, tempPath, p.did)
230230-231231- fmt.Printf("DEBUG [goroutine]: Starting upload to temp: url=%s\n", url)
232232-233233- // Use context with timeout to prevent hanging forever
234234- uploadCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
235235- defer cancel()
236236-237237- req, err := http.NewRequestWithContext(uploadCtx, "PUT", url, pipeReader)
238238- if err != nil {
239239- fmt.Printf("DEBUG [goroutine]: Failed to create request: %v\n", err)
240240- // Consume digest channel even on error
241241- <-digestChan
242242- uploadErr <- fmt.Errorf("failed to create request: %w", err)
243243- return
244244- }
245245- req.Header.Set("Content-Type", "application/octet-stream")
246246-247247- fmt.Printf("DEBUG [goroutine]: Sending PUT request...\n")
248248- // Stream to temp location (this will block until all data is written)
249249- resp, err := p.httpClient.Do(req)
250250- if err != nil {
251251- fmt.Printf("DEBUG [goroutine]: PUT failed: %v\n", err)
252252- <-digestChan
253253- uploadErr <- fmt.Errorf("failed to upload to temp: %w", err)
254254- return
255255- }
256256- defer resp.Body.Close()
257257-258258- fmt.Printf("DEBUG [goroutine]: Got response status=%d\n", resp.StatusCode)
259259-260260- if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
261261- bodyBytes, _ := io.ReadAll(resp.Body)
262262- fmt.Printf("DEBUG [goroutine]: Upload failed with status %d, body=%s\n", resp.StatusCode, string(bodyBytes))
263263- <-digestChan
264264- uploadErr <- fmt.Errorf("upload to temp failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
265265- return
266266- }
267267-268268- fmt.Printf("DEBUG [goroutine]: Upload to temp succeeded, waiting for digest...\n")
269269- // Upload to temp succeeded, now wait for digest from Commit()
270270- digest, ok := <-digestChan
271271- if !ok {
272272- uploadErr <- fmt.Errorf("upload cancelled after streaming to temp")
273273- return
274274- }
275275-276276- fmt.Printf("DEBUG [goroutine]: Got digest=%s, signaling completion\n", digest)
277277- // Store digest for Commit() to use in move operation
278278- writer.finalDigest = digest
279279- uploadErr <- nil
280280- }()
281281-282238 return writer, nil
283239}
284240···293249 return nil, distribution.ErrBlobUploadUnknown
294250 }
295251296296- // With streaming, no flush needed - just return the writer
297252 return writer, nil
298253}
299254···384339type ProxyBlobWriter struct {
385340 store *ProxyBlobStore
386341 options distribution.CreateOptions
387387- pipeWriter *io.PipeWriter // Streams directly to hold service
388388- pipeReader *io.PipeReader
389389- digestChan chan string // Sends digest to upload goroutine
390390- uploadErr chan error // Receives upload result from goroutine
391391- finalDigest string // Final digest for move operation
392392- size int64
342342+ uploadID string // S3 multipart upload ID
343343+ parts []CompletedPart // Track uploaded parts with ETags
344344+ partNumber int // Current part number (starts at 1)
345345+ buffer *bytes.Buffer // Buffer for current part
346346+ size int64 // Total bytes written
393347 closed bool
394394- id string // Distribution's upload ID
348348+ id string // Distribution's upload ID (for state)
395349 startedAt time.Time
350350+ finalDigest string // Set on Commit
351351+ tempDigest digest.Digest // Temp location digest
396352}
397353398354// ID returns the upload ID
···406362}
407363408364// Write writes data to the upload
409409-// Streams directly to hold service via pipe
365365+// Buffers data and flushes parts when buffer reaches minPartSize
410366func (w *ProxyBlobWriter) Write(p []byte) (int, error) {
411367 if w.closed {
412368 return 0, fmt.Errorf("writer closed")
413369 }
414370415415- // Write to pipe - streams immediately to hold service
416416- n, err := w.pipeWriter.Write(p)
371371+ n, err := w.buffer.Write(p)
372372+ w.size += int64(n)
373373+374374+ // Flush if buffer reaches minimum part size (5MB)
375375+ if w.buffer.Len() >= minPartSize {
376376+ if err := w.flushPart(); err != nil {
377377+ return n, fmt.Errorf("failed to flush part: %w", err)
378378+ }
379379+ }
380380+381381+ return n, err
382382+}
383383+384384+// flushPart uploads the current buffer as a multipart upload part
385385+func (w *ProxyBlobWriter) flushPart() error {
386386+ if w.buffer.Len() == 0 {
387387+ return nil
388388+ }
389389+390390+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
391391+ defer cancel()
392392+393393+ // Get presigned URL for this part
394394+ url, err := w.store.getPartPresignedURL(ctx, w.tempDigest, w.uploadID, w.partNumber)
417395 if err != nil {
418418- // If write fails (client disconnected), close pipe to unblock goroutine
419419- w.pipeWriter.CloseWithError(err)
420420- return n, err
396396+ return fmt.Errorf("failed to get part presigned URL: %w", err)
397397+ }
398398+399399+ // Upload part to S3
400400+ req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(w.buffer.Bytes()))
401401+ if err != nil {
402402+ return err
421403 }
422422- w.size += int64(n)
404404+ req.Header.Set("Content-Type", "application/octet-stream")
405405+406406+ fmt.Printf("DEBUG [proxy_blob_store/flushPart]: Uploading part %d, size=%d bytes\n", w.partNumber, w.buffer.Len())
423407424424- return n, nil
408408+ resp, err := w.store.httpClient.Do(req)
409409+ if err != nil {
410410+ return fmt.Errorf("part upload failed: %w", err)
411411+ }
412412+ defer resp.Body.Close()
413413+414414+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
415415+ bodyBytes, _ := io.ReadAll(resp.Body)
416416+ return fmt.Errorf("part upload failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
417417+ }
418418+419419+ // Store ETag for completion
420420+ etag := resp.Header.Get("ETag")
421421+ if etag == "" {
422422+ return fmt.Errorf("no ETag in response")
423423+ }
424424+425425+ // Remove quotes from ETag if present (S3 sometimes adds them)
426426+ etag = strings.Trim(etag, "\"")
427427+428428+ w.parts = append(w.parts, CompletedPart{
429429+ PartNumber: w.partNumber,
430430+ ETag: etag,
431431+ })
432432+433433+ fmt.Printf("DEBUG [proxy_blob_store/flushPart]: Part %d uploaded successfully, ETag=%s\n", w.partNumber, etag)
434434+435435+ // Reset buffer and increment part number
436436+ w.buffer.Reset()
437437+ w.partNumber++
438438+439439+ return nil
425440}
426441427442// ReadFrom reads from a reader
···466481 }
467482 w.closed = true
468483469469- // Remove from global uploads map
470470- globalUploadsMu.Lock()
471471- delete(globalUploads, w.id)
472472- globalUploadsMu.Unlock()
484484+ // Flush any remaining buffered data as the final part
485485+ if w.buffer.Len() > 0 {
486486+ if err := w.flushPart(); err != nil {
487487+ // Try to abort multipart on error
488488+ w.store.abortMultipartUpload(ctx, w.tempDigest, w.uploadID)
489489+ return distribution.Descriptor{}, fmt.Errorf("failed to flush final part: %w", err)
490490+ }
491491+ }
473492474474- // Close pipe to signal EOF to upload goroutine
475475- if err := w.pipeWriter.Close(); err != nil {
476476- return distribution.Descriptor{}, fmt.Errorf("failed to close pipe: %w", err)
493493+ // Complete multipart upload at temp location
494494+ if err := w.store.completeMultipartUpload(ctx, w.tempDigest, w.uploadID, w.parts); err != nil {
495495+ return distribution.Descriptor{}, fmt.Errorf("failed to complete multipart upload: %w", err)
477496 }
478497479479- // Send digest to upload goroutine (it's waiting after temp upload completes)
480480- w.digestChan <- desc.Digest.String()
481481- close(w.digestChan)
482482-483483- // Wait for upload goroutine to complete
484484- if err := <-w.uploadErr; err != nil {
485485- return distribution.Descriptor{}, fmt.Errorf("upload to temp failed: %w", err)
486486- }
498498+ fmt.Printf("DEBUG [proxy_blob_store/Commit]: Completed multipart upload with %d parts, total size=%d\n", len(w.parts), w.size)
487499488488- // Now move temp → final location
489489- tempPath := fmt.Sprintf("uploads/temp-%s", w.id) // No leading slash
500500+ // Move from temp → final location (server-side S3 copy)
501501+ tempPath := fmt.Sprintf("uploads/temp-%s", w.id)
490502 finalPath := desc.Digest.String()
491503492504 moveURL := fmt.Sprintf("%s/move?from=%s&to=%s&did=%s",
493505 w.store.storageEndpoint, tempPath, finalPath, w.store.did)
494506495495- req, err := http.NewRequestWithContext(context.Background(), "POST", moveURL, nil)
507507+ req, err := http.NewRequestWithContext(ctx, "POST", moveURL, nil)
496508 if err != nil {
497509 return distribution.Descriptor{}, fmt.Errorf("failed to create move request: %w", err)
498510 }
···505517506518 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
507519 bodyBytes, _ := io.ReadAll(resp.Body)
508508- return distribution.Descriptor{}, fmt.Errorf("move blob failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
520520+ return distribution.Descriptor{}, fmt.Errorf("move failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
509521 }
510522511511- fmt.Printf("DEBUG [proxy_blob_store]: Committed upload: digest=%s, size=%d (moved from temp)\n", desc.Digest, w.size)
523523+ // Remove from global map
524524+ globalUploadsMu.Lock()
525525+ delete(globalUploads, w.id)
526526+ globalUploadsMu.Unlock()
527527+528528+ fmt.Printf("DEBUG [proxy_blob_store/Commit]: Successfully committed: digest=%s, size=%d\n", desc.Digest, w.size)
512529513530 return distribution.Descriptor{
514531 Digest: desc.Digest,
···526543 delete(globalUploads, w.id)
527544 globalUploadsMu.Unlock()
528545529529- // Close digest channel without sending digest
530530- close(w.digestChan)
531531-532532- // Close pipe with error to stop streaming
533533- if w.pipeWriter != nil {
534534- w.pipeWriter.CloseWithError(fmt.Errorf("upload cancelled"))
546546+ // Abort multipart upload on S3
547547+ if err := w.store.abortMultipartUpload(ctx, w.tempDigest, w.uploadID); err != nil {
548548+ fmt.Printf("DEBUG [proxy_blob_store/Cancel]: Failed to abort multipart upload: %v\n", err)
549549+ // Continue anyway - we still want to clean up
535550 }
536551537537- // Wait for goroutine to finish
538538- <-w.uploadErr
539539-540540- fmt.Printf("DEBUG [proxy_blob_store]: Cancelled upload: id=%s\n", w.id)
552552+ fmt.Printf("DEBUG [proxy_blob_store/Cancel]: Cancelled upload: id=%s, uploadID=%s\n", w.id, w.uploadID)
541553 return nil
542554}
543555544556// Close closes the writer
545545-// Just returns - streaming continues via pipe
557557+// Does nothing - actual completion happens in Commit() or Cancel()
546558func (w *ProxyBlobWriter) Close() error {
547547- // Don't close pipe here - that happens in Commit() or Cancel()
548559 // Don't set w.closed = true - allow resuming for next PATCH
560560+ return nil
561561+}
562562+563563+// startMultipartUpload initiates a multipart upload via hold service
564564+func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, dgst digest.Digest) (string, error) {
565565+ reqBody := map[string]any{
566566+ "did": p.did,
567567+ "digest": dgst.String(),
568568+ }
569569+ body, _ := json.Marshal(reqBody)
570570+571571+ url := fmt.Sprintf("%s/start-multipart", p.storageEndpoint)
572572+ req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
573573+ req.Header.Set("Content-Type", "application/json")
574574+575575+ resp, err := p.httpClient.Do(req)
576576+ if err != nil {
577577+ return "", err
578578+ }
579579+ defer resp.Body.Close()
580580+581581+ if resp.StatusCode != http.StatusOK {
582582+ return "", fmt.Errorf("failed to start multipart upload: status %d", resp.StatusCode)
583583+ }
584584+585585+ var result struct {
586586+ UploadID string `json:"upload_id"`
587587+ }
588588+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
589589+ return "", err
590590+ }
591591+592592+ return result.UploadID, nil
593593+}
594594+595595+// getPartPresignedURL gets a presigned URL for uploading a specific part
596596+func (p *ProxyBlobStore) getPartPresignedURL(ctx context.Context, dgst digest.Digest, uploadID string, partNumber int) (string, error) {
597597+ reqBody := map[string]any{
598598+ "did": p.did,
599599+ "digest": dgst.String(),
600600+ "upload_id": uploadID,
601601+ "part_number": partNumber,
602602+ }
603603+ body, _ := json.Marshal(reqBody)
604604+605605+ url := fmt.Sprintf("%s/part-presigned-url", p.storageEndpoint)
606606+ req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
607607+ req.Header.Set("Content-Type", "application/json")
608608+609609+ resp, err := p.httpClient.Do(req)
610610+ if err != nil {
611611+ return "", err
612612+ }
613613+ defer resp.Body.Close()
614614+615615+ if resp.StatusCode != http.StatusOK {
616616+ return "", fmt.Errorf("failed to get part presigned URL: status %d", resp.StatusCode)
617617+ }
618618+619619+ var result struct {
620620+ URL string `json:"url"`
621621+ }
622622+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
623623+ return "", err
624624+ }
625625+626626+ return result.URL, nil
627627+}
628628+629629+// completeMultipartUpload completes a multipart upload
630630+func (p *ProxyBlobStore) completeMultipartUpload(ctx context.Context, dgst digest.Digest, uploadID string, parts []CompletedPart) error {
631631+ reqBody := map[string]any{
632632+ "did": p.did,
633633+ "digest": dgst.String(),
634634+ "upload_id": uploadID,
635635+ "parts": parts,
636636+ }
637637+ body, _ := json.Marshal(reqBody)
638638+639639+ url := fmt.Sprintf("%s/complete-multipart", p.storageEndpoint)
640640+ req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
641641+ req.Header.Set("Content-Type", "application/json")
642642+643643+ resp, err := p.httpClient.Do(req)
644644+ if err != nil {
645645+ return err
646646+ }
647647+ defer resp.Body.Close()
648648+649649+ if resp.StatusCode != http.StatusOK {
650650+ bodyBytes, _ := io.ReadAll(resp.Body)
651651+ return fmt.Errorf("complete multipart failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
652652+ }
653653+654654+ return nil
655655+}
656656+657657+// abortMultipartUpload aborts a multipart upload
658658+func (p *ProxyBlobStore) abortMultipartUpload(ctx context.Context, dgst digest.Digest, uploadID string) error {
659659+ reqBody := map[string]any{
660660+ "did": p.did,
661661+ "digest": dgst.String(),
662662+ "upload_id": uploadID,
663663+ }
664664+ body, _ := json.Marshal(reqBody)
665665+666666+ url := fmt.Sprintf("%s/abort-multipart", p.storageEndpoint)
667667+ req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
668668+ req.Header.Set("Content-Type", "application/json")
669669+670670+ resp, err := p.httpClient.Do(req)
671671+ if err != nil {
672672+ return err
673673+ }
674674+ defer resp.Body.Close()
675675+676676+ if resp.StatusCode != http.StatusOK {
677677+ bodyBytes, _ := io.ReadAll(resp.Body)
678678+ return fmt.Errorf("abort multipart failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
679679+ }
680680+549681 return nil
550682}
551683