detached c00c10c

fix(registry): make blob chunk upload limit configurable

commit
c00c10c5df8a53b25afe9b9f0264fb9eb4cf195a
author
brice · · 2026-02-06 14:54 UTC
committer
brice · · 2026-02-06 19:34 UTC
parent
dca6537
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +116 -58
M docs/config/reference.md +2 -0
@@ -13,6 +13,7 @@ port = 80 # HTTP proxy port
1313 registry_port = 5000 # Container registry port
1414 gordon_domain = "" # Required: Gordon domain (registry + API)
1515 data_dir = "~/.gordon" # Data directory (varies by install type)
16+max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk
1617
1718 # =============================================================================
1819 # AUTHENTICATION (required - Gordon won't start without credentials configured)
@@ -125,6 +126,7 @@ preserve = true # Keep volumes when containers are
125126 | `server.registry_port` | `5000` | Container registry port |
126127 | `server.gordon_domain` | `""` | **Required** - Gordon domain |
127128 | `server.data_dir` | `~/.gordon` | Data directory |
129+| `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk |
128130 | `auth.enabled` | `true` | Enable authentication |
129131 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
130132 | `auth.token_expiry` | `"720h"` | 30 days |
@@ -13,6 +13,7 @@ port = 80 # HTTP proxy port
13 registry_port = 5000 # Container registry port13 registry_port = 5000 # Container registry port
14 gordon_domain = "" # Required: Gordon domain (registry + API)14 gordon_domain = "" # Required: Gordon domain (registry + API)
15 data_dir = "~/.gordon" # Data directory (varies by install type)15 data_dir = "~/.gordon" # Data directory (varies by install type)
16+max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk
16 17
17 # =============================================================================18 # =============================================================================
18 # AUTHENTICATION (required - Gordon won't start without credentials configured)19 # AUTHENTICATION (required - Gordon won't start without credentials configured)
@@ -125,6 +126,7 @@ preserve = true # Keep volumes when containers are
125 | `server.registry_port` | `5000` | Container registry port |126 | `server.registry_port` | `5000` | Container registry port |
126 | `server.gordon_domain` | `""` | **Required** - Gordon domain |127 | `server.gordon_domain` | `""` | **Required** - Gordon domain |
127 | `server.data_dir` | `~/.gordon` | Data directory |128 | `server.data_dir` | `~/.gordon` | Data directory |
129+| `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk |
128 | `auth.enabled` | `true` | Enable authentication |130 | `auth.enabled` | `true` | Enable authentication |
129 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |131 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
130 | `auth.token_expiry` | `"720h"` | 30 days |132 | `auth.token_expiry` | `"720h"` | 30 days |
M docs/config/server.md +26 -1
@@ -22,6 +22,7 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
2222 | `registry_domain` | string | - | Deprecated alias for `gordon_domain` |
2323 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |
2424 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |
25+| `max_blob_chunk_size` | string | `"512MB"` | Maximum request body size for a single registry blob upload chunk |
2526
2627 ## Port Configuration
2728
@@ -140,7 +141,31 @@ data_dir = "~/.gordon" # Default for user installations
140141
141142 > **Warning:** Setting this to `0` (no limit) allows unlimited upload sizes, which can lead to disk exhaustion and DoS vulnerabilities. Always set a reasonable limit for production deployments.
142143
143- ## Examples
144+## Maximum Registry Blob Chunk Size
145+
146+The `max_blob_chunk_size` setting limits the size of a single blob upload request to the registry (`PATCH`/`PUT` under `/v2/*/blobs/uploads/*`):
147+
148+```toml
149+[server]
150+max_blob_chunk_size = "512MB" # Default
151+```
152+
153+**Purpose:** Prevents excessive memory usage during blob uploads while allowing large real-world image layers.
154+
155+**Supported units:** B, KB, MB, GB, TB (case-insensitive, decimal/1024-based)
156+
157+**Examples:**
158+```toml
159+max_blob_chunk_size = "256MB" # Stricter limit
160+max_blob_chunk_size = "1GB" # Allow larger layers
161+```
162+
163+**Behavior:**
164+- When a blob chunk exceeds the limit, Gordon returns `413 Request Entity Too Large`
165+- Docker/Podman may send a full layer in one request, so set this high enough for your base images
166+- Default (`512MB`) supports common large base layers (for example, `postgres`)
167+
168+## Examples
144169
145170 ### Development Configuration
146171
@@ -22,6 +22,7 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
22 | `registry_domain` | string | - | Deprecated alias for `gordon_domain` |22 | `registry_domain` | string | - | Deprecated alias for `gordon_domain` |
23 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |23 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |
24 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |24 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |
25+| `max_blob_chunk_size` | string | `"512MB"` | Maximum request body size for a single registry blob upload chunk |
25 26
26 ## Port Configuration27 ## Port Configuration
27 28
@@ -140,7 +141,31 @@ data_dir = "~/.gordon" # Default for user installations
140 141
141 > **Warning:** Setting this to `0` (no limit) allows unlimited upload sizes, which can lead to disk exhaustion and DoS vulnerabilities. Always set a reasonable limit for production deployments.142 > **Warning:** Setting this to `0` (no limit) allows unlimited upload sizes, which can lead to disk exhaustion and DoS vulnerabilities. Always set a reasonable limit for production deployments.
142 143
143- ## Examples144+## Maximum Registry Blob Chunk Size
145+
146+The `max_blob_chunk_size` setting limits the size of a single blob upload request to the registry (`PATCH`/`PUT` under `/v2/*/blobs/uploads/*`):
147+
148+```toml
149+[server]
150+max_blob_chunk_size = "512MB" # Default
151+```
152+
153+**Purpose:** Prevents excessive memory usage during blob uploads while allowing large real-world image layers.
154+
155+**Supported units:** B, KB, MB, GB, TB (case-insensitive, decimal/1024-based)
156+
157+**Examples:**
158+```toml
159+max_blob_chunk_size = "256MB" # Stricter limit
160+max_blob_chunk_size = "1GB" # Allow larger layers
161+```
162+
163+**Behavior:**
164+- When a blob chunk exceeds the limit, Gordon returns `413 Request Entity Too Large`
165+- Docker/Podman may send a full layer in one request, so set this high enough for your base images
166+- Default (`512MB`) supports common large base layers (for example, `postgres`)
167+
168+## Examples
144 169
145 ### Development Configuration170 ### Development Configuration
146 171
M internal/adapters/in/http/registry/handler.go +17 -10
@@ -22,26 +22,33 @@ import (
2222 const (
2323 // MaxManifestSize limits manifest uploads to 10MB.
2424 MaxManifestSize = 10 * 1024 * 1024
25- // MaxBlobChunkSize limits individual blob chunks to 95MB.
26- // Kept below 100MB to leave headroom for HTTP overhead when behind
27- // proxies like Cloudflare that enforce a 100MB per-request limit.
28- MaxBlobChunkSize = 95 * 1024 * 1024
25+ // DefaultMaxBlobChunkSize is the default maximum size for a single blob upload chunk.
26+ // Docker/Podman typically send a full layer as one request body, so this default
27+ // must handle common base image layer sizes.
28+ DefaultMaxBlobChunkSize = 512 * 1024 * 1024
2929 )
3030
3131 // Handler implements the HTTP handler for Docker Registry API v2.
3232 type Handler struct {
33- registrySvc in.RegistryService
34- log zerowrap.Logger
33+ registrySvc in.RegistryService
34+ log zerowrap.Logger
35+ maxBlobChunkSize int64
3536 }
3637
3738 // NewHandler creates a new registry HTTP handler.
3839 func NewHandler(
3940 registrySvc in.RegistryService,
4041 log zerowrap.Logger,
42+ maxBlobChunkSize int64,
4143 ) *Handler {
44+ if maxBlobChunkSize <= 0 {
45+ maxBlobChunkSize = DefaultMaxBlobChunkSize
46+ }
47+
4248 return &Handler{
43- registrySvc: registrySvc,
44- log: log,
49+ registrySvc: registrySvc,
50+ log: log,
51+ maxBlobChunkSize: maxBlobChunkSize,
4552 }
4653 }
4754
@@ -401,14 +408,14 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
401408 }
402409
403410 // Limit blob chunk size to prevent memory exhaustion
404- r.Body = http.MaxBytesReader(w, r.Body, MaxBlobChunkSize)
411+ r.Body = http.MaxBytesReader(w, r.Body, h.maxBlobChunkSize)
405412
406413 // Read the chunk from the request body
407414 chunk, err := io.ReadAll(r.Body)
408415 if err != nil {
409416 var maxBytesErr *http.MaxBytesError
410417 if errors.As(err, &maxBytesErr) {
411- log.Warn().Int64("max_size", MaxBlobChunkSize).Msg("blob chunk too large")
418+ log.Warn().Int64("max_size", h.maxBlobChunkSize).Msg("blob chunk too large")
412419 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size")
413420 return
414421 }
@@ -22,26 +22,33 @@ import (
22 const (22 const (
23 // MaxManifestSize limits manifest uploads to 10MB.23 // MaxManifestSize limits manifest uploads to 10MB.
24 MaxManifestSize = 10 * 1024 * 102424 MaxManifestSize = 10 * 1024 * 1024
25- // MaxBlobChunkSize limits individual blob chunks to 95MB.25+ // DefaultMaxBlobChunkSize is the default maximum size for a single blob upload chunk.
26- // Kept below 100MB to leave headroom for HTTP overhead when behind26+ // Docker/Podman typically send a full layer as one request body, so this default
27- // proxies like Cloudflare that enforce a 100MB per-request limit.27+ // must handle common base image layer sizes.
28- MaxBlobChunkSize = 95 * 1024 * 102428+ DefaultMaxBlobChunkSize = 512 * 1024 * 1024
29 )29 )
30 30
31 // Handler implements the HTTP handler for Docker Registry API v2.31 // Handler implements the HTTP handler for Docker Registry API v2.
32 type Handler struct {32 type Handler struct {
33- registrySvc in.RegistryService33+ registrySvc in.RegistryService
34- log zerowrap.Logger34+ log zerowrap.Logger
35+ maxBlobChunkSize int64
35 }36 }
36 37
37 // NewHandler creates a new registry HTTP handler.38 // NewHandler creates a new registry HTTP handler.
38 func NewHandler(39 func NewHandler(
39 registrySvc in.RegistryService,40 registrySvc in.RegistryService,
40 log zerowrap.Logger,41 log zerowrap.Logger,
42+ maxBlobChunkSize int64,
41 ) *Handler {43 ) *Handler {
44+ if maxBlobChunkSize <= 0 {
45+ maxBlobChunkSize = DefaultMaxBlobChunkSize
46+ }
47+
42 return &Handler{48 return &Handler{
43- registrySvc: registrySvc,49+ registrySvc: registrySvc,
44- log: log,50+ log: log,
51+ maxBlobChunkSize: maxBlobChunkSize,
45 }52 }
46 }53 }
47 54
@@ -401,14 +408,14 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
401 }408 }
402 409
403 // Limit blob chunk size to prevent memory exhaustion410 // Limit blob chunk size to prevent memory exhaustion
404- r.Body = http.MaxBytesReader(w, r.Body, MaxBlobChunkSize)411+ r.Body = http.MaxBytesReader(w, r.Body, h.maxBlobChunkSize)
405 412
406 // Read the chunk from the request body413 // Read the chunk from the request body
407 chunk, err := io.ReadAll(r.Body)414 chunk, err := io.ReadAll(r.Body)
408 if err != nil {415 if err != nil {
409 var maxBytesErr *http.MaxBytesError416 var maxBytesErr *http.MaxBytesError
410 if errors.As(err, &maxBytesErr) {417 if errors.As(err, &maxBytesErr) {
411- log.Warn().Int64("max_size", MaxBlobChunkSize).Msg("blob chunk too large")418+ log.Warn().Int64("max_size", h.maxBlobChunkSize).Msg("blob chunk too large")
412 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size")419 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size")
413 return420 return
414 }421 }
M internal/adapters/in/http/registry/handler_test.go +39 -29
@@ -23,7 +23,7 @@ func testLogger() zerowrap.Logger {
2323 func TestHandler_Base(t *testing.T) {
2424 registrySvc := inmocks.NewMockRegistryService(t)
2525
26- handler := NewHandler(registrySvc, testLogger())
26+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
2727
2828 req := httptest.NewRequest("GET", "/v2/", nil)
2929 rec := httptest.NewRecorder()
@@ -37,7 +37,7 @@ func TestHandler_Base(t *testing.T) {
3737 func TestHandler_NotFound(t *testing.T) {
3838 registrySvc := inmocks.NewMockRegistryService(t)
3939
40- handler := NewHandler(registrySvc, testLogger())
40+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
4141
4242 req := httptest.NewRequest("GET", "/v2/unknown/path", nil)
4343 rec := httptest.NewRecorder()
@@ -50,7 +50,7 @@ func TestHandler_NotFound(t *testing.T) {
5050 func TestHandler_GetManifest_Success(t *testing.T) {
5151 registrySvc := inmocks.NewMockRegistryService(t)
5252
53- handler := NewHandler(registrySvc, testLogger())
53+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
5454
5555 manifestData := []byte(`{"schemaVersion": 2}`)
5656 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{
@@ -73,7 +73,7 @@ func TestHandler_GetManifest_Success(t *testing.T) {
7373 func TestHandler_GetManifest_HEAD(t *testing.T) {
7474 registrySvc := inmocks.NewMockRegistryService(t)
7575
76- handler := NewHandler(registrySvc, testLogger())
76+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
7777
7878 manifestData := []byte(`{"schemaVersion": 2}`)
7979 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{
@@ -97,7 +97,7 @@ func TestHandler_GetManifest_HEAD(t *testing.T) {
9797 func TestHandler_GetManifest_NotFound(t *testing.T) {
9898 registrySvc := inmocks.NewMockRegistryService(t)
9999
100- handler := NewHandler(registrySvc, testLogger())
100+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
101101
102102 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "notexists").Return(nil, assert.AnError)
103103
@@ -113,7 +113,7 @@ func TestHandler_GetManifest_NotFound(t *testing.T) {
113113 func TestHandler_GetManifest_NestedName(t *testing.T) {
114114 registrySvc := inmocks.NewMockRegistryService(t)
115115
116- handler := NewHandler(registrySvc, testLogger())
116+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
117117
118118 manifestData := []byte(`{"schemaVersion": 2}`)
119119 registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{
@@ -134,7 +134,7 @@ func TestHandler_GetManifest_NestedName(t *testing.T) {
134134 func TestHandler_PutManifest_Success(t *testing.T) {
135135 registrySvc := inmocks.NewMockRegistryService(t)
136136
137- handler := NewHandler(registrySvc, testLogger())
137+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
138138
139139 manifestData := []byte(`{"schemaVersion": 2}`)
140140 registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool {
@@ -155,7 +155,7 @@ func TestHandler_PutManifest_Success(t *testing.T) {
155155 func TestHandler_PutManifest_MissingContentType(t *testing.T) {
156156 registrySvc := inmocks.NewMockRegistryService(t)
157157
158- handler := NewHandler(registrySvc, testLogger())
158+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
159159
160160 manifestData := []byte(`{"schemaVersion": 2}`)
161161
@@ -171,7 +171,7 @@ func TestHandler_PutManifest_MissingContentType(t *testing.T) {
171171 func TestHandler_PutManifest_StorageError(t *testing.T) {
172172 registrySvc := inmocks.NewMockRegistryService(t)
173173
174- handler := NewHandler(registrySvc, testLogger())
174+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
175175
176176 manifestData := []byte(`{"schemaVersion": 2}`)
177177 registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return("", assert.AnError)
@@ -188,7 +188,7 @@ func TestHandler_PutManifest_StorageError(t *testing.T) {
188188 func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {
189189 registrySvc := inmocks.NewMockRegistryService(t)
190190
191- handler := NewHandler(registrySvc, testLogger())
191+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
192192
193193 req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil)
194194 rec := httptest.NewRecorder()
@@ -201,7 +201,7 @@ func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {
201201 func TestHandler_GetBlob_Success(t *testing.T) {
202202 registrySvc := inmocks.NewMockRegistryService(t)
203203
204- handler := NewHandler(registrySvc, testLogger())
204+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
205205
206206 // Create a temp file for http.ServeFile
207207 tmpFile, err := os.CreateTemp("", "blob-*")
@@ -226,7 +226,7 @@ func TestHandler_GetBlob_Success(t *testing.T) {
226226 func TestHandler_GetBlob_NotFound(t *testing.T) {
227227 registrySvc := inmocks.NewMockRegistryService(t)
228228
229- handler := NewHandler(registrySvc, testLogger())
229+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
230230
231231 registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
232232
@@ -241,7 +241,7 @@ func TestHandler_GetBlob_NotFound(t *testing.T) {
241241 func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
242242 registrySvc := inmocks.NewMockRegistryService(t)
243243
244- handler := NewHandler(registrySvc, testLogger())
244+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
245245
246246 req := httptest.NewRequest("POST", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)
247247 rec := httptest.NewRecorder()
@@ -255,7 +255,7 @@ func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
255255 func TestHandler_StartBlobUpload_Success(t *testing.T) {
256256 registrySvc := inmocks.NewMockRegistryService(t)
257257
258- handler := NewHandler(registrySvc, testLogger())
258+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
259259
260260 registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("550e8400-e29b-41d4-a716-446655440000", nil)
261261
@@ -272,7 +272,7 @@ func TestHandler_StartBlobUpload_Success(t *testing.T) {
272272 func TestHandler_StartBlobUpload_Error(t *testing.T) {
273273 registrySvc := inmocks.NewMockRegistryService(t)
274274
275- handler := NewHandler(registrySvc, testLogger())
275+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
276276
277277 registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("", assert.AnError)
278278
@@ -288,7 +288,7 @@ func TestHandler_StartBlobUpload_Error(t *testing.T) {
288288 func TestHandler_BlobUpload_PATCH(t *testing.T) {
289289 registrySvc := inmocks.NewMockRegistryService(t)
290290
291- handler := NewHandler(registrySvc, testLogger())
291+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
292292
293293 chunkData := []byte("chunk content")
294294 registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil)
@@ -305,7 +305,7 @@ func TestHandler_BlobUpload_PATCH(t *testing.T) {
305305 func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
306306 registrySvc := inmocks.NewMockRegistryService(t)
307307
308- handler := NewHandler(registrySvc, testLogger())
308+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
309309
310310 chunkData := []byte("final chunk")
311311 registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil)
@@ -324,7 +324,7 @@ func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
324324 func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {
325325 registrySvc := inmocks.NewMockRegistryService(t)
326326
327- handler := NewHandler(registrySvc, testLogger())
327+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
328328
329329 chunkData := []byte("final chunk")
330330 // Note: Invalid digest format is rejected by validation before reaching storage layer
@@ -342,7 +342,7 @@ func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {
342342 func TestHandler_ListTags_Success(t *testing.T) {
343343 registrySvc := inmocks.NewMockRegistryService(t)
344344
345- handler := NewHandler(registrySvc, testLogger())
345+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
346346
347347 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0", "v2.0"}, nil)
348348
@@ -366,7 +366,7 @@ func TestHandler_ListTags_Success(t *testing.T) {
366366 func TestHandler_ListTags_NotFound(t *testing.T) {
367367 registrySvc := inmocks.NewMockRegistryService(t)
368368
369- handler := NewHandler(registrySvc, testLogger())
369+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
370370
371371 registrySvc.EXPECT().ListTags(mock.Anything, "notexists").Return(nil, assert.AnError)
372372
@@ -382,7 +382,7 @@ func TestHandler_ListTags_NotFound(t *testing.T) {
382382 func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {
383383 registrySvc := inmocks.NewMockRegistryService(t)
384384
385- handler := NewHandler(registrySvc, testLogger())
385+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
386386
387387 req := httptest.NewRequest("POST", "/v2/myapp/tags/list", nil)
388388 rec := httptest.NewRecorder()
@@ -396,7 +396,7 @@ func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {
396396 func TestHandler_RegisterRoutes(t *testing.T) {
397397 registrySvc := inmocks.NewMockRegistryService(t)
398398
399- handler := NewHandler(registrySvc, testLogger())
399+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
400400
401401 mux := http.NewServeMux()
402402 handler.RegisterRoutes(mux)
@@ -412,7 +412,7 @@ func TestHandler_RegisterRoutes(t *testing.T) {
412412
413413 func TestHandler_StartBlobUpload_ReturnsDockerUploadUUID(t *testing.T) {
414414 registrySvc := inmocks.NewMockRegistryService(t)
415- handler := NewHandler(registrySvc, testLogger())
415+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
416416
417417 registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("550e8400-e29b-41d4-a716-446655440000", nil)
418418
@@ -427,7 +427,7 @@ func TestHandler_StartBlobUpload_ReturnsDockerUploadUUID(t *testing.T) {
427427
428428 func TestHandler_BlobUpload_PATCH_ReturnsDockerUploadUUID(t *testing.T) {
429429 registrySvc := inmocks.NewMockRegistryService(t)
430- handler := NewHandler(registrySvc, testLogger())
430+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
431431
432432 chunkData := []byte("chunk content")
433433 registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil)
@@ -441,15 +441,25 @@ func TestHandler_BlobUpload_PATCH_ReturnsDockerUploadUUID(t *testing.T) {
441441 assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", rec.Header().Get("Docker-Upload-UUID"))
442442 }
443443
444-func TestMaxBlobChunkSize_Under100MB(t *testing.T) {
445- // Verify the constant leaves headroom below Cloudflare's 100MB limit
446- assert.Less(t, int64(MaxBlobChunkSize), int64(100*1024*1024))
447- assert.Greater(t, int64(MaxBlobChunkSize), int64(90*1024*1024))
444+func TestDefaultMaxBlobChunkSize_Is512MB(t *testing.T) {
445+ assert.Equal(t, int64(512*1024*1024), int64(DefaultMaxBlobChunkSize))
446+}
447+
448+func TestHandler_BlobUpload_PATCH_RespectsConfiguredMaxBlobChunkSize(t *testing.T) {
449+ registrySvc := inmocks.NewMockRegistryService(t)
450+ handler := NewHandler(registrySvc, testLogger(), 5)
451+
452+ req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("chunk content")))
453+ rec := httptest.NewRecorder()
454+
455+ handler.ServeHTTP(rec, req)
456+
457+ assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
448458 }
449459
450460 func TestHandler_BlobUpload_PATCH_ReturnsRangeHeader(t *testing.T) {
451461 registrySvc := inmocks.NewMockRegistryService(t)
452- handler := NewHandler(registrySvc, testLogger())
462+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
453463
454464 chunkData := []byte("chunk content")
455465 registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil)
@@ -23,7 +23,7 @@ func testLogger() zerowrap.Logger {
23 func TestHandler_Base(t *testing.T) {23 func TestHandler_Base(t *testing.T) {
24 registrySvc := inmocks.NewMockRegistryService(t)24 registrySvc := inmocks.NewMockRegistryService(t)
25 25
26- handler := NewHandler(registrySvc, testLogger())26+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
27 27
28 req := httptest.NewRequest("GET", "/v2/", nil)28 req := httptest.NewRequest("GET", "/v2/", nil)
29 rec := httptest.NewRecorder()29 rec := httptest.NewRecorder()
@@ -37,7 +37,7 @@ func TestHandler_Base(t *testing.T) {
37 func TestHandler_NotFound(t *testing.T) {37 func TestHandler_NotFound(t *testing.T) {
38 registrySvc := inmocks.NewMockRegistryService(t)38 registrySvc := inmocks.NewMockRegistryService(t)
39 39
40- handler := NewHandler(registrySvc, testLogger())40+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
41 41
42 req := httptest.NewRequest("GET", "/v2/unknown/path", nil)42 req := httptest.NewRequest("GET", "/v2/unknown/path", nil)
43 rec := httptest.NewRecorder()43 rec := httptest.NewRecorder()
@@ -50,7 +50,7 @@ func TestHandler_NotFound(t *testing.T) {
50 func TestHandler_GetManifest_Success(t *testing.T) {50 func TestHandler_GetManifest_Success(t *testing.T) {
51 registrySvc := inmocks.NewMockRegistryService(t)51 registrySvc := inmocks.NewMockRegistryService(t)
52 52
53- handler := NewHandler(registrySvc, testLogger())53+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
54 54
55 manifestData := []byte(`{"schemaVersion": 2}`)55 manifestData := []byte(`{"schemaVersion": 2}`)
56 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{56 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{
@@ -73,7 +73,7 @@ func TestHandler_GetManifest_Success(t *testing.T) {
73 func TestHandler_GetManifest_HEAD(t *testing.T) {73 func TestHandler_GetManifest_HEAD(t *testing.T) {
74 registrySvc := inmocks.NewMockRegistryService(t)74 registrySvc := inmocks.NewMockRegistryService(t)
75 75
76- handler := NewHandler(registrySvc, testLogger())76+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
77 77
78 manifestData := []byte(`{"schemaVersion": 2}`)78 manifestData := []byte(`{"schemaVersion": 2}`)
79 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{79 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{
@@ -97,7 +97,7 @@ func TestHandler_GetManifest_HEAD(t *testing.T) {
97 func TestHandler_GetManifest_NotFound(t *testing.T) {97 func TestHandler_GetManifest_NotFound(t *testing.T) {
98 registrySvc := inmocks.NewMockRegistryService(t)98 registrySvc := inmocks.NewMockRegistryService(t)
99 99
100- handler := NewHandler(registrySvc, testLogger())100+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
101 101
102 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "notexists").Return(nil, assert.AnError)102 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "notexists").Return(nil, assert.AnError)
103 103
@@ -113,7 +113,7 @@ func TestHandler_GetManifest_NotFound(t *testing.T) {
113 func TestHandler_GetManifest_NestedName(t *testing.T) {113 func TestHandler_GetManifest_NestedName(t *testing.T) {
114 registrySvc := inmocks.NewMockRegistryService(t)114 registrySvc := inmocks.NewMockRegistryService(t)
115 115
116- handler := NewHandler(registrySvc, testLogger())116+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
117 117
118 manifestData := []byte(`{"schemaVersion": 2}`)118 manifestData := []byte(`{"schemaVersion": 2}`)
119 registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{119 registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{
@@ -134,7 +134,7 @@ func TestHandler_GetManifest_NestedName(t *testing.T) {
134 func TestHandler_PutManifest_Success(t *testing.T) {134 func TestHandler_PutManifest_Success(t *testing.T) {
135 registrySvc := inmocks.NewMockRegistryService(t)135 registrySvc := inmocks.NewMockRegistryService(t)
136 136
137- handler := NewHandler(registrySvc, testLogger())137+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
138 138
139 manifestData := []byte(`{"schemaVersion": 2}`)139 manifestData := []byte(`{"schemaVersion": 2}`)
140 registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool {140 registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool {
@@ -155,7 +155,7 @@ func TestHandler_PutManifest_Success(t *testing.T) {
155 func TestHandler_PutManifest_MissingContentType(t *testing.T) {155 func TestHandler_PutManifest_MissingContentType(t *testing.T) {
156 registrySvc := inmocks.NewMockRegistryService(t)156 registrySvc := inmocks.NewMockRegistryService(t)
157 157
158- handler := NewHandler(registrySvc, testLogger())158+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
159 159
160 manifestData := []byte(`{"schemaVersion": 2}`)160 manifestData := []byte(`{"schemaVersion": 2}`)
161 161
@@ -171,7 +171,7 @@ func TestHandler_PutManifest_MissingContentType(t *testing.T) {
171 func TestHandler_PutManifest_StorageError(t *testing.T) {171 func TestHandler_PutManifest_StorageError(t *testing.T) {
172 registrySvc := inmocks.NewMockRegistryService(t)172 registrySvc := inmocks.NewMockRegistryService(t)
173 173
174- handler := NewHandler(registrySvc, testLogger())174+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
175 175
176 manifestData := []byte(`{"schemaVersion": 2}`)176 manifestData := []byte(`{"schemaVersion": 2}`)
177 registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return("", assert.AnError)177 registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return("", assert.AnError)
@@ -188,7 +188,7 @@ func TestHandler_PutManifest_StorageError(t *testing.T) {
188 func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {188 func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {
189 registrySvc := inmocks.NewMockRegistryService(t)189 registrySvc := inmocks.NewMockRegistryService(t)
190 190
191- handler := NewHandler(registrySvc, testLogger())191+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
192 192
193 req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil)193 req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil)
194 rec := httptest.NewRecorder()194 rec := httptest.NewRecorder()
@@ -201,7 +201,7 @@ func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {
201 func TestHandler_GetBlob_Success(t *testing.T) {201 func TestHandler_GetBlob_Success(t *testing.T) {
202 registrySvc := inmocks.NewMockRegistryService(t)202 registrySvc := inmocks.NewMockRegistryService(t)
203 203
204- handler := NewHandler(registrySvc, testLogger())204+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
205 205
206 // Create a temp file for http.ServeFile206 // Create a temp file for http.ServeFile
207 tmpFile, err := os.CreateTemp("", "blob-*")207 tmpFile, err := os.CreateTemp("", "blob-*")
@@ -226,7 +226,7 @@ func TestHandler_GetBlob_Success(t *testing.T) {
226 func TestHandler_GetBlob_NotFound(t *testing.T) {226 func TestHandler_GetBlob_NotFound(t *testing.T) {
227 registrySvc := inmocks.NewMockRegistryService(t)227 registrySvc := inmocks.NewMockRegistryService(t)
228 228
229- handler := NewHandler(registrySvc, testLogger())229+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
230 230
231 registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)231 registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
232 232
@@ -241,7 +241,7 @@ func TestHandler_GetBlob_NotFound(t *testing.T) {
241 func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {241 func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
242 registrySvc := inmocks.NewMockRegistryService(t)242 registrySvc := inmocks.NewMockRegistryService(t)
243 243
244- handler := NewHandler(registrySvc, testLogger())244+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
245 245
246 req := httptest.NewRequest("POST", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)246 req := httptest.NewRequest("POST", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)
247 rec := httptest.NewRecorder()247 rec := httptest.NewRecorder()
@@ -255,7 +255,7 @@ func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
255 func TestHandler_StartBlobUpload_Success(t *testing.T) {255 func TestHandler_StartBlobUpload_Success(t *testing.T) {
256 registrySvc := inmocks.NewMockRegistryService(t)256 registrySvc := inmocks.NewMockRegistryService(t)
257 257
258- handler := NewHandler(registrySvc, testLogger())258+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
259 259
260 registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("550e8400-e29b-41d4-a716-446655440000", nil)260 registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("550e8400-e29b-41d4-a716-446655440000", nil)
261 261
@@ -272,7 +272,7 @@ func TestHandler_StartBlobUpload_Success(t *testing.T) {
272 func TestHandler_StartBlobUpload_Error(t *testing.T) {272 func TestHandler_StartBlobUpload_Error(t *testing.T) {
273 registrySvc := inmocks.NewMockRegistryService(t)273 registrySvc := inmocks.NewMockRegistryService(t)
274 274
275- handler := NewHandler(registrySvc, testLogger())275+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
276 276
277 registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("", assert.AnError)277 registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("", assert.AnError)
278 278
@@ -288,7 +288,7 @@ func TestHandler_StartBlobUpload_Error(t *testing.T) {
288 func TestHandler_BlobUpload_PATCH(t *testing.T) {288 func TestHandler_BlobUpload_PATCH(t *testing.T) {
289 registrySvc := inmocks.NewMockRegistryService(t)289 registrySvc := inmocks.NewMockRegistryService(t)
290 290
291- handler := NewHandler(registrySvc, testLogger())291+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
292 292
293 chunkData := []byte("chunk content")293 chunkData := []byte("chunk content")
294 registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil)294 registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil)
@@ -305,7 +305,7 @@ func TestHandler_BlobUpload_PATCH(t *testing.T) {
305 func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {305 func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
306 registrySvc := inmocks.NewMockRegistryService(t)306 registrySvc := inmocks.NewMockRegistryService(t)
307 307
308- handler := NewHandler(registrySvc, testLogger())308+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
309 309
310 chunkData := []byte("final chunk")310 chunkData := []byte("final chunk")
311 registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil)311 registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil)
@@ -324,7 +324,7 @@ func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
324 func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {324 func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {
325 registrySvc := inmocks.NewMockRegistryService(t)325 registrySvc := inmocks.NewMockRegistryService(t)
326 326
327- handler := NewHandler(registrySvc, testLogger())327+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
328 328
329 chunkData := []byte("final chunk")329 chunkData := []byte("final chunk")
330 // Note: Invalid digest format is rejected by validation before reaching storage layer330 // Note: Invalid digest format is rejected by validation before reaching storage layer
@@ -342,7 +342,7 @@ func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {
342 func TestHandler_ListTags_Success(t *testing.T) {342 func TestHandler_ListTags_Success(t *testing.T) {
343 registrySvc := inmocks.NewMockRegistryService(t)343 registrySvc := inmocks.NewMockRegistryService(t)
344 344
345- handler := NewHandler(registrySvc, testLogger())345+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
346 346
347 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0", "v2.0"}, nil)347 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0", "v2.0"}, nil)
348 348
@@ -366,7 +366,7 @@ func TestHandler_ListTags_Success(t *testing.T) {
366 func TestHandler_ListTags_NotFound(t *testing.T) {366 func TestHandler_ListTags_NotFound(t *testing.T) {
367 registrySvc := inmocks.NewMockRegistryService(t)367 registrySvc := inmocks.NewMockRegistryService(t)
368 368
369- handler := NewHandler(registrySvc, testLogger())369+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
370 370
371 registrySvc.EXPECT().ListTags(mock.Anything, "notexists").Return(nil, assert.AnError)371 registrySvc.EXPECT().ListTags(mock.Anything, "notexists").Return(nil, assert.AnError)
372 372
@@ -382,7 +382,7 @@ func TestHandler_ListTags_NotFound(t *testing.T) {
382 func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {382 func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {
383 registrySvc := inmocks.NewMockRegistryService(t)383 registrySvc := inmocks.NewMockRegistryService(t)
384 384
385- handler := NewHandler(registrySvc, testLogger())385+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
386 386
387 req := httptest.NewRequest("POST", "/v2/myapp/tags/list", nil)387 req := httptest.NewRequest("POST", "/v2/myapp/tags/list", nil)
388 rec := httptest.NewRecorder()388 rec := httptest.NewRecorder()
@@ -396,7 +396,7 @@ func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {
396 func TestHandler_RegisterRoutes(t *testing.T) {396 func TestHandler_RegisterRoutes(t *testing.T) {
397 registrySvc := inmocks.NewMockRegistryService(t)397 registrySvc := inmocks.NewMockRegistryService(t)
398 398
399- handler := NewHandler(registrySvc, testLogger())399+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
400 400
401 mux := http.NewServeMux()401 mux := http.NewServeMux()
402 handler.RegisterRoutes(mux)402 handler.RegisterRoutes(mux)
@@ -412,7 +412,7 @@ func TestHandler_RegisterRoutes(t *testing.T) {
412 412
413 func TestHandler_StartBlobUpload_ReturnsDockerUploadUUID(t *testing.T) {413 func TestHandler_StartBlobUpload_ReturnsDockerUploadUUID(t *testing.T) {
414 registrySvc := inmocks.NewMockRegistryService(t)414 registrySvc := inmocks.NewMockRegistryService(t)
415- handler := NewHandler(registrySvc, testLogger())415+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
416 416
417 registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("550e8400-e29b-41d4-a716-446655440000", nil)417 registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("550e8400-e29b-41d4-a716-446655440000", nil)
418 418
@@ -427,7 +427,7 @@ func TestHandler_StartBlobUpload_ReturnsDockerUploadUUID(t *testing.T) {
427 427
428 func TestHandler_BlobUpload_PATCH_ReturnsDockerUploadUUID(t *testing.T) {428 func TestHandler_BlobUpload_PATCH_ReturnsDockerUploadUUID(t *testing.T) {
429 registrySvc := inmocks.NewMockRegistryService(t)429 registrySvc := inmocks.NewMockRegistryService(t)
430- handler := NewHandler(registrySvc, testLogger())430+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
431 431
432 chunkData := []byte("chunk content")432 chunkData := []byte("chunk content")
433 registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil)433 registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil)
@@ -441,15 +441,25 @@ func TestHandler_BlobUpload_PATCH_ReturnsDockerUploadUUID(t *testing.T) {
441 assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", rec.Header().Get("Docker-Upload-UUID"))441 assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", rec.Header().Get("Docker-Upload-UUID"))
442 }442 }
443 443
444-func TestMaxBlobChunkSize_Under100MB(t *testing.T) {444+func TestDefaultMaxBlobChunkSize_Is512MB(t *testing.T) {
445- // Verify the constant leaves headroom below Cloudflare's 100MB limit445+ assert.Equal(t, int64(512*1024*1024), int64(DefaultMaxBlobChunkSize))
446- assert.Less(t, int64(MaxBlobChunkSize), int64(100*1024*1024))446+}
447- assert.Greater(t, int64(MaxBlobChunkSize), int64(90*1024*1024))447+
448+func TestHandler_BlobUpload_PATCH_RespectsConfiguredMaxBlobChunkSize(t *testing.T) {
449+ registrySvc := inmocks.NewMockRegistryService(t)
450+ handler := NewHandler(registrySvc, testLogger(), 5)
451+
452+ req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("chunk content")))
453+ rec := httptest.NewRecorder()
454+
455+ handler.ServeHTTP(rec, req)
456+
457+ assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
448 }458 }
449 459
450 func TestHandler_BlobUpload_PATCH_ReturnsRangeHeader(t *testing.T) {460 func TestHandler_BlobUpload_PATCH_ReturnsRangeHeader(t *testing.T) {
451 registrySvc := inmocks.NewMockRegistryService(t)461 registrySvc := inmocks.NewMockRegistryService(t)
452- handler := NewHandler(registrySvc, testLogger())462+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
453 463
454 chunkData := []byte("chunk content")464 chunkData := []byte("chunk content")
455 registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil)465 registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil)
M internal/app/run.go +32 -18
@@ -68,6 +68,7 @@ type Config struct {
6868 RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
6969 DataDir string `mapstructure:"data_dir"`
7070 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
71+ MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
7172 } `mapstructure:"server"`
7273
7374 Logging struct {
@@ -117,23 +118,24 @@ type Config struct {
117118
118119 // services holds all the services used by the application.
119120 type services struct {
120- runtime *docker.Runtime
121- eventBus *eventbus.InMemory
122- blobStorage *filesystem.BlobStorage
123- manifestStorage *filesystem.ManifestStorage
124- envLoader out.EnvLoader
125- logWriter *logwriter.LogWriter
126- tokenStore out.TokenStore
127- configSvc *config.Service
128- containerSvc *container.Service
129- registrySvc *registrySvc.Service
130- proxySvc *proxy.Service
131- authSvc *auth.Service
132- authHandler *authhandler.Handler
133- adminHandler *admin.Handler
134- internalRegUser string
135- internalRegPass string
136- envDir string
121+ runtime *docker.Runtime
122+ eventBus *eventbus.InMemory
123+ blobStorage *filesystem.BlobStorage
124+ manifestStorage *filesystem.ManifestStorage
125+ envLoader out.EnvLoader
126+ logWriter *logwriter.LogWriter
127+ tokenStore out.TokenStore
128+ configSvc *config.Service
129+ containerSvc *container.Service
130+ registrySvc *registrySvc.Service
131+ proxySvc *proxy.Service
132+ authSvc *auth.Service
133+ authHandler *authhandler.Handler
134+ adminHandler *admin.Handler
135+ internalRegUser string
136+ internalRegPass string
137+ envDir string
138+ maxBlobChunkSize int64
137139 }
138140
139141 // Run initializes and starts the Gordon application.
@@ -306,6 +308,18 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
306308 maxProxyBodySize = parsedSize
307309 }
308310
311+ // Parse max_blob_chunk_size config (default: 512MB)
312+ maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize)
313+ if cfg.Server.MaxBlobChunkSize != "" {
314+ parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize)
315+ if err != nil {
316+ return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize})
317+ }
318+ maxBlobChunkSize = parsedSize
319+ }
320+
321+ svc.maxBlobChunkSize = maxBlobChunkSize
322+
309323 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{
310324 RegistryDomain: cfg.Server.RegistryDomain,
311325 RegistryPort: cfg.Server.RegistryPort,
@@ -971,7 +985,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
971985 // This ensures consistent IP extraction across logging, rate limiting, and auth.
972986 trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
973987
974- registryHandler := registry.NewHandler(svc.registrySvc, log)
988+ registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)
975989
976990 registryMiddlewares := []func(http.Handler) http.Handler{
977991 middleware.PanicRecovery(log),
@@ -68,6 +68,7 @@ type Config struct {
68 RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain68 RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
69 DataDir string `mapstructure:"data_dir"`69 DataDir string `mapstructure:"data_dir"`
70 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"70 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
71+ MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
71 } `mapstructure:"server"`72 } `mapstructure:"server"`
72 73
73 Logging struct {74 Logging struct {
@@ -117,23 +118,24 @@ type Config struct {
117 118
118 // services holds all the services used by the application.119 // services holds all the services used by the application.
119 type services struct {120 type services struct {
120- runtime *docker.Runtime121+ runtime *docker.Runtime
121- eventBus *eventbus.InMemory122+ eventBus *eventbus.InMemory
122- blobStorage *filesystem.BlobStorage123+ blobStorage *filesystem.BlobStorage
123- manifestStorage *filesystem.ManifestStorage124+ manifestStorage *filesystem.ManifestStorage
124- envLoader out.EnvLoader125+ envLoader out.EnvLoader
125- logWriter *logwriter.LogWriter126+ logWriter *logwriter.LogWriter
126- tokenStore out.TokenStore127+ tokenStore out.TokenStore
127- configSvc *config.Service128+ configSvc *config.Service
128- containerSvc *container.Service129+ containerSvc *container.Service
129- registrySvc *registrySvc.Service130+ registrySvc *registrySvc.Service
130- proxySvc *proxy.Service131+ proxySvc *proxy.Service
131- authSvc *auth.Service132+ authSvc *auth.Service
132- authHandler *authhandler.Handler133+ authHandler *authhandler.Handler
133- adminHandler *admin.Handler134+ adminHandler *admin.Handler
134- internalRegUser string135+ internalRegUser string
135- internalRegPass string136+ internalRegPass string
136- envDir string137+ envDir string
138+ maxBlobChunkSize int64
137 }139 }
138 140
139 // Run initializes and starts the Gordon application.141 // Run initializes and starts the Gordon application.
@@ -306,6 +308,18 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
306 maxProxyBodySize = parsedSize308 maxProxyBodySize = parsedSize
307 }309 }
308 310
311+ // Parse max_blob_chunk_size config (default: 512MB)
312+ maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize)
313+ if cfg.Server.MaxBlobChunkSize != "" {
314+ parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize)
315+ if err != nil {
316+ return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize})
317+ }
318+ maxBlobChunkSize = parsedSize
319+ }
320+
321+ svc.maxBlobChunkSize = maxBlobChunkSize
322+
309 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{323 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{
310 RegistryDomain: cfg.Server.RegistryDomain,324 RegistryDomain: cfg.Server.RegistryDomain,
311 RegistryPort: cfg.Server.RegistryPort,325 RegistryPort: cfg.Server.RegistryPort,
@@ -971,7 +985,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
971 // This ensures consistent IP extraction across logging, rate limiting, and auth.985 // This ensures consistent IP extraction across logging, rate limiting, and auth.
972 trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)986 trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
973 987
974- registryHandler := registry.NewHandler(svc.registrySvc, log)988+ registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)
975 989
976 registryMiddlewares := []func(http.Handler) http.Handler{990 registryMiddlewares := []func(http.Handler) http.Handler{
977 middleware.PanicRecovery(log),991 middleware.PanicRecovery(log),

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close