1// Package registry implements the HTTP adapter for the registry API.
2package registry
3
4import (
5 "crypto/sha256"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "strconv"
11 "strings"
12
13 "github.com/bnema/zerowrap"
14
15 "gordon/internal/boundaries/in"
16 "gordon/internal/boundaries/out"
17 "gordon/internal/domain"
18 "gordon/pkg/manifest"
19)
20
21// Handler implements the HTTP handler for Docker Registry API v2.
22type Handler struct {
23 registrySvc in.RegistryService
24 blobStorage out.BlobStorage
25 eventBus out.EventPublisher
26 log zerowrap.Logger
27}
28
29// NewHandler creates a new registry HTTP handler.
30func NewHandler(
31 registrySvc in.RegistryService,
32 blobStorage out.BlobStorage,
33 eventBus out.EventPublisher,
34 log zerowrap.Logger,
35) *Handler {
36 return &Handler{
37 registrySvc: registrySvc,
38 blobStorage: blobStorage,
39 eventBus: eventBus,
40 log: log,
41 }
42}
43
44// RegisterRoutes registers the registry routes on the given mux.
45func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
46 mux.HandleFunc("/v2/", h.handleRegistryRoutes)
47}
48
49// ServeHTTP implements http.Handler.
50func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
51 h.handleRegistryRoutes(w, r)
52}
53
54func (h *Handler) handleRegistryRoutes(w http.ResponseWriter, r *http.Request) {
55 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
56 zerowrap.FieldLayer: "adapter",
57 zerowrap.FieldAdapter: "http",
58 zerowrap.FieldHandler: "registry",
59 zerowrap.FieldMethod: r.Method,
60 zerowrap.FieldPath: r.URL.Path,
61 })
62 r = r.WithContext(ctx)
63
64 path := r.URL.Path
65
66 // Route manifest operations: /v2/{name}/manifests/{reference}
67 if strings.Contains(path, "/manifests/") {
68 h.handleManifestRoutes(w, r)
69 return
70 }
71
72 // Route blob operations: /v2/{name}/blobs/{digest}
73 if strings.Contains(path, "/blobs/") && !strings.Contains(path, "/uploads/") {
74 h.handleBlobRoutes(w, r)
75 return
76 }
77
78 // Route blob upload operations: /v2/{name}/blobs/uploads/
79 if strings.Contains(path, "/blobs/uploads/") {
80 h.handleBlobUploadRoutes(w, r)
81 return
82 }
83
84 // Route tag list operations: /v2/{name}/tags/list
85 if strings.Contains(path, "/tags/list") {
86 h.handleTagListRoutes(w, r)
87 return
88 }
89
90 // Base endpoint: /v2/
91 if path == "/v2/" {
92 h.handleBase(w, r)
93 return
94 }
95
96 http.NotFound(w, r)
97}
98
99func (h *Handler) handleManifestRoutes(w http.ResponseWriter, r *http.Request) {
100 // Parse path: /v2/{name}/manifests/{reference}
101 parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v2/"), "/")
102 if len(parts) < 3 || parts[len(parts)-2] != "manifests" {
103 http.NotFound(w, r)
104 return
105 }
106
107 reference := parts[len(parts)-1]
108 name := strings.Join(parts[:len(parts)-2], "/")
109
110 r.SetPathValue("name", name)
111 r.SetPathValue("reference", reference)
112
113 switch r.Method {
114 case "HEAD", "GET":
115 h.handleGetManifest(w, r)
116 case "PUT":
117 h.handlePutManifest(w, r)
118 default:
119 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
120 }
121}
122
123func (h *Handler) handleBlobRoutes(w http.ResponseWriter, r *http.Request) {
124 // Parse path: /v2/{name}/blobs/{digest}
125 parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v2/"), "/")
126 if len(parts) < 3 || parts[len(parts)-2] != "blobs" {
127 http.NotFound(w, r)
128 return
129 }
130
131 digest := parts[len(parts)-1]
132 name := strings.Join(parts[:len(parts)-2], "/")
133
134 r.SetPathValue("name", name)
135 r.SetPathValue("digest", digest)
136
137 switch r.Method {
138 case "HEAD", "GET":
139 h.handleGetBlob(w, r)
140 default:
141 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
142 }
143}
144
145func (h *Handler) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request) {
146 // Parse path: /v2/{name}/blobs/uploads/{uuid?}
147 path := strings.TrimPrefix(r.URL.Path, "/v2/")
148 uploadIndex := strings.Index(path, "/blobs/uploads/")
149 if uploadIndex == -1 {
150 http.NotFound(w, r)
151 return
152 }
153
154 name := path[:uploadIndex]
155 uploadPart := path[uploadIndex+15:] // len("/blobs/uploads/") = 15
156
157 r.SetPathValue("name", name)
158
159 if uploadPart == "" {
160 // POST /v2/{name}/blobs/uploads/
161 switch r.Method {
162 case "POST":
163 h.handleStartBlobUpload(w, r)
164 default:
165 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
166 }
167 } else {
168 // PATCH/PUT /v2/{name}/blobs/uploads/{uuid}
169 r.SetPathValue("uuid", uploadPart)
170 switch r.Method {
171 case "PATCH", "PUT":
172 h.handleBlobUpload(w, r)
173 default:
174 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
175 }
176 }
177}
178
179func (h *Handler) handleTagListRoutes(w http.ResponseWriter, r *http.Request) {
180 // Parse path: /v2/{name}/tags/list
181 path := strings.TrimPrefix(r.URL.Path, "/v2/")
182 if !strings.HasSuffix(path, "/tags/list") {
183 http.NotFound(w, r)
184 return
185 }
186
187 name := strings.TrimSuffix(path, "/tags/list")
188 r.SetPathValue("name", name)
189
190 switch r.Method {
191 case "GET":
192 h.handleListTags(w, r)
193 default:
194 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
195 }
196}
197
198func (h *Handler) handleBase(w http.ResponseWriter, r *http.Request) {
199 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
200 w.WriteHeader(http.StatusOK)
201}
202
203func (h *Handler) handleGetManifest(w http.ResponseWriter, r *http.Request) {
204 ctx := r.Context()
205 log := zerowrap.FromCtx(ctx)
206 name := r.PathValue("name")
207 reference := r.PathValue("reference")
208
209 log.Debug().Str("name", name).Str("reference", reference).Msg("GET manifest")
210
211 manifestData, err := h.registrySvc.GetManifest(ctx, name, reference)
212 if err != nil {
213 log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("manifest not found")
214 http.NotFound(w, r)
215 return
216 }
217
218 w.Header().Set("Content-Type", manifestData.ContentType)
219 w.Header().Set("Content-Length", strconv.Itoa(len(manifestData.Data)))
220 w.WriteHeader(http.StatusOK)
221
222 if r.Method == "GET" {
223 _, _ = w.Write(manifestData.Data)
224 }
225}
226
227func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
228 ctx := r.Context()
229 log := zerowrap.FromCtx(ctx)
230 name := r.PathValue("name")
231 reference := r.PathValue("reference")
232
233 contentType := r.Header.Get("Content-Type")
234 log.Debug().Str("name", name).Str("reference", reference).Str("content_type", contentType).Msg("PUT manifest")
235
236 if contentType == "" {
237 log.Warn().Str("name", name).Str("reference", reference).Msg("Content-Type header missing")
238 http.Error(w, "Content-Type header required", http.StatusBadRequest)
239 return
240 }
241
242 // Read manifest data
243 data, err := io.ReadAll(r.Body)
244 if err != nil {
245 log.Error().Err(err).Msg("failed to read manifest data")
246 http.Error(w, "Bad Request", http.StatusBadRequest)
247 return
248 }
249
250 // Parse manifest annotations
251 annotations, err := manifest.ParseManifestAnnotations(data, contentType)
252 if err != nil {
253 log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("failed to parse manifest annotations")
254 annotations = map[string]string{}
255 }
256
257 // Store manifest via service
258 manifestObj := &domain.Manifest{
259 Name: name,
260 Reference: reference,
261 ContentType: contentType,
262 Data: data,
263 Annotations: annotations,
264 }
265
266 if err := h.registrySvc.PutManifest(ctx, manifestObj); err != nil {
267 log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest")
268 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
269 return
270 }
271
272 // Calculate digest for Docker-Content-Digest header
273 digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data))
274 w.Header().Set("Docker-Content-Digest", digest)
275 w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference))
276 w.WriteHeader(http.StatusCreated)
277}
278
279func (h *Handler) handleGetBlob(w http.ResponseWriter, r *http.Request) {
280 ctx := r.Context()
281 log := zerowrap.FromCtx(ctx)
282 name := r.PathValue("name")
283 digest := r.PathValue("digest")
284
285 log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob")
286
287 path, err := h.blobStorage.GetBlobPath(digest)
288 if err != nil {
289 log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found")
290 http.NotFound(w, r)
291 return
292 }
293
294 // Serve the file directly
295 http.ServeFile(w, r, path)
296}
297
298func (h *Handler) handleStartBlobUpload(w http.ResponseWriter, r *http.Request) {
299 ctx := r.Context()
300 log := zerowrap.FromCtx(ctx)
301 name := r.PathValue("name")
302
303 log.Debug().Str("name", name).Msg("starting blob upload")
304
305 uuid, err := h.registrySvc.StartUpload(ctx, name)
306 if err != nil {
307 log.Error().Err(err).Msg("failed to start blob upload")
308 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
309 return
310 }
311
312 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
313 w.Header().Set("Range", "0-0")
314 w.WriteHeader(http.StatusAccepted)
315}
316
317func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
318 ctx := r.Context()
319 log := zerowrap.FromCtx(ctx)
320 name := r.PathValue("name")
321 uuid := r.PathValue("uuid")
322 digest := r.URL.Query().Get("digest")
323
324 log.Debug().
325 Str("name", name).
326 Str("uuid", uuid).
327 Str("digest", digest).
328 Str(zerowrap.FieldMethod, r.Method).
329 Msg("handling blob upload chunk")
330
331 // Read the chunk from the request body
332 chunk, err := io.ReadAll(r.Body)
333 if err != nil {
334 log.Error().Err(err).Msg("failed to read blob chunk")
335 http.Error(w, "Bad Request", http.StatusBadRequest)
336 return
337 }
338
339 // Append the chunk to the upload
340 length, err := h.blobStorage.AppendBlobChunk(name, uuid, chunk)
341 if err != nil {
342 log.Error().Err(err).Msg("failed to append blob chunk")
343 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
344 return
345 }
346
347 // If this is the final chunk (PUT request with digest), finalize the upload
348 if r.Method == "PUT" && digest != "" {
349 if err := h.registrySvc.FinishUpload(ctx, uuid, digest); err != nil {
350 log.Error().Err(err).Str("digest", digest).Msg("failed to finalize blob upload")
351 _ = h.registrySvc.CancelUpload(ctx, uuid)
352 http.Error(w, "Bad Request: digest mismatch", http.StatusBadRequest)
353 return
354 }
355
356 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest))
357 w.Header().Set("Docker-Content-Digest", digest)
358 w.WriteHeader(http.StatusCreated)
359 return
360 }
361
362 // For PATCH requests, respond with 202 Accepted
363 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
364 w.Header().Set("Range", fmt.Sprintf("0-%d", length-1))
365 w.WriteHeader(http.StatusAccepted)
366}
367
368func (h *Handler) handleListTags(w http.ResponseWriter, r *http.Request) {
369 ctx := r.Context()
370 log := zerowrap.FromCtx(ctx)
371 name := r.PathValue("name")
372
373 log.Debug().Str("name", name).Msg("listing tags")
374
375 tags, err := h.registrySvc.ListTags(ctx, name)
376 if err != nil {
377 log.Warn().Err(err).Str("name", name).Msg("tags not found")
378 w.WriteHeader(http.StatusNotFound)
379 return
380 }
381
382 tagsJSON, err := json.Marshal(tags)
383 if err != nil {
384 log.Error().Err(err).Str("name", name).Msg("failed to marshal tags")
385 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
386 return
387 }
388
389 response := fmt.Sprintf(`{"name":"%s","tags":%s}`, name, string(tagsJSON))
390
391 w.Header().Set("Content-Type", "application/json")
392 w.Header().Set("Content-Length", strconv.Itoa(len(response)))
393 w.WriteHeader(http.StatusOK)
394 w.Write([]byte(response))
395}