1package registry
2
3import (
4 "context"
5 "crypto/sha256"
6 "fmt"
7 "io"
8 "net/http"
9 "strconv"
10 "strings"
11
12 "github.com/rs/zerolog/log"
13 "gordon/internal/config"
14 "gordon/internal/events"
15 "gordon/internal/middleware"
16)
17
18type Server struct {
19 config *config.Config
20 mux *http.ServeMux
21 storage Storage
22 eventBus events.EventBus
23}
24
25func NewServer(cfg *config.Config, eventBus events.EventBus) (*Server, error) {
26 // Initialize storage
27 storage, err := NewFilesystemStorage(cfg.Server.DataDir + "/registry")
28 if err != nil {
29 return nil, fmt.Errorf("failed to initialize storage: %w", err)
30 }
31
32 s := &Server{
33 config: cfg,
34 mux: http.NewServeMux(),
35 storage: storage,
36 eventBus: eventBus,
37 }
38 s.setupRoutes()
39 return s, nil
40}
41
42func (s *Server) setupRoutes() {
43 // Docker Registry API v2 endpoints using Go 1.22+ ServeMux patterns
44
45 // Registry info endpoint
46 s.mux.HandleFunc("GET /v2/", s.handleRegistryInfo)
47
48 // Since Go's ServeMux requires wildcards to be at the end, we need a different approach
49 // Use a catch-all handler for /v2/ paths and route internally
50 s.mux.HandleFunc("/v2/", s.handleV2Routes)
51}
52
53func (s *Server) handleV2Routes(w http.ResponseWriter, r *http.Request) {
54 path := r.URL.Path
55 method := r.Method
56
57 // Route based on path pattern and method
58 switch {
59 case path == "/v2/" && method == "GET":
60 s.handleRegistryInfo(w, r)
61 case method == "GET" && matches(path, "/v2/.*/manifests/.*"):
62 s.handleGetManifest(w, r)
63 case method == "PUT" && matches(path, "/v2/.*/manifests/.*"):
64 s.handlePutManifest(w, r)
65 case method == "GET" && matches(path, "/v2/.*/blobs/.*"):
66 s.handleGetBlob(w, r)
67 case method == "POST" && matches(path, "/v2/.*/blobs/uploads/"):
68 s.handleStartBlobUpload(w, r)
69 case (method == "PUT" || method == "PATCH") && matches(path, "/v2/.*/blobs/uploads/.*"):
70 s.handleBlobUpload(w, r)
71 case method == "GET" && matches(path, "/v2/.*/tags/list"):
72 s.handleListTags(w, r)
73 default:
74 http.NotFound(w, r)
75 }
76}
77
78// Simple pattern matching helper
79func matches(path, pattern string) bool {
80 // Convert pattern to regex-like matching
81 // For now, just check if the pattern structure matches
82 if pattern == "/v2/.*/manifests/.*" {
83 return len(path) > 4 && path[:4] == "/v2/" &&
84 len(path) > 14 && path[len(path)-10:] != "/manifests/" &&
85 contains(path, "/manifests/")
86 }
87 if pattern == "/v2/.*/blobs/.*" {
88 return len(path) > 4 && path[:4] == "/v2/" && contains(path, "/blobs/")
89 }
90 if pattern == "/v2/.*/blobs/uploads/" {
91 return len(path) > 4 && path[:4] == "/v2/" &&
92 contains(path, "/blobs/uploads/") && path[len(path)-1:] == "/"
93 }
94 if pattern == "/v2/.*/blobs/uploads/.*" {
95 return len(path) > 4 && path[:4] == "/v2/" && contains(path, "/blobs/uploads/")
96 }
97 if pattern == "/v2/.*/tags/list" {
98 return len(path) > 4 && path[:4] == "/v2/" &&
99 len(path) > 10 && path[len(path)-10:] == "/tags/list"
100 }
101 return false
102}
103
104func contains(s, substr string) bool {
105 for i := 0; i <= len(s)-len(substr); i++ {
106 if s[i:i+len(substr)] == substr {
107 return true
108 }
109 }
110 return false
111}
112
113// Path parsing helpers
114func (s *Server) parseManifestPath(path string) (name, reference string) {
115 // Expected format: /v2/{name}/manifests/{reference}
116 parts := strings.Split(path, "/")
117 if len(parts) >= 5 && parts[3] == "manifests" {
118 name = parts[2]
119 reference = parts[4]
120 }
121 return
122}
123
124func (s *Server) parseBlobPath(path string) (name, digest string) {
125 // Expected format: /v2/{name}/blobs/{digest}
126 parts := strings.Split(path, "/")
127 if len(parts) >= 5 && parts[3] == "blobs" {
128 name = parts[2]
129 digest = parts[4]
130 }
131 return
132}
133
134func (s *Server) parseUploadPath(path string) (name, uuid string) {
135 // Expected format: /v2/{name}/blobs/uploads/{uuid}
136 parts := strings.Split(path, "/")
137 if len(parts) >= 6 && parts[3] == "blobs" && parts[4] == "uploads" {
138 name = parts[2]
139 if len(parts) > 5 {
140 uuid = parts[5]
141 }
142 }
143 return
144}
145
146func (s *Server) parseTagsPath(path string) string {
147 // Expected format: /v2/{name}/tags/list
148 parts := strings.Split(path, "/")
149 if len(parts) >= 4 && parts[3] == "tags" {
150 return parts[2]
151 }
152 return ""
153}
154
155func (s *Server) Start(ctx context.Context) error {
156 addr := ":" + strconv.Itoa(s.config.Server.RegistryPort)
157
158 // Build middleware chain
159 middlewares := []func(http.Handler) http.Handler{
160 middleware.PanicRecovery,
161 middleware.RequestLogger,
162 }
163
164 // Add registry authentication if enabled
165 if s.config.RegistryAuth.Enabled {
166 middlewares = append(middlewares, middleware.RegistryAuth(s.config.RegistryAuth.Username, s.config.RegistryAuth.Password))
167 }
168
169 // Wrap the mux with middleware
170 handler := middleware.Chain(middlewares...)(s.mux)
171
172 server := &http.Server{
173 Addr: addr,
174 Handler: handler,
175 }
176
177 log.Info().Str("address", addr).Msg("Registry server starting")
178
179 // Start server in a goroutine
180 errChan := make(chan error, 1)
181 go func() {
182 if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
183 errChan <- err
184 }
185 }()
186
187 // Wait for context cancellation or server error
188 select {
189 case err := <-errChan:
190 return err
191 case <-ctx.Done():
192 log.Info().Msg("Registry server shutting down...")
193 return server.Shutdown(context.Background())
194 }
195}
196
197func (s *Server) handleRegistryInfo(w http.ResponseWriter, r *http.Request) {
198 w.Header().Set("Content-Type", "application/json")
199 w.WriteHeader(http.StatusOK)
200 fmt.Fprint(w, `{"name":"gordon-registry"}`)
201}
202
203func (s *Server) handleGetManifest(w http.ResponseWriter, r *http.Request) {
204 name, reference := s.parseManifestPath(r.URL.Path)
205
206 log.Debug().Str("name", name).Str("reference", reference).Msg("GET manifest")
207
208 manifest, err := s.storage.GetManifest(name, reference)
209 if err != nil {
210 log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Manifest not found")
211 w.WriteHeader(http.StatusNotFound)
212 return
213 }
214
215 w.Header().Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
216 w.Header().Set("Content-Length", strconv.Itoa(len(manifest)))
217 w.WriteHeader(http.StatusOK)
218 w.Write(manifest)
219}
220
221func (s *Server) handlePutManifest(w http.ResponseWriter, r *http.Request) {
222 name, reference := s.parseManifestPath(r.URL.Path)
223
224 log.Debug().Str("name", name).Str("reference", reference).Msg("PUT manifest")
225
226 // Read manifest data
227 data, err := io.ReadAll(r.Body)
228 if err != nil {
229 log.Error().Err(err).Msg("Failed to read manifest data")
230 http.Error(w, "Bad Request", http.StatusBadRequest)
231 return
232 }
233
234 // Store manifest
235 if err := s.storage.PutManifest(name, reference, data); err != nil {
236 log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to store manifest")
237 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
238 return
239 }
240
241 // Calculate digest for Docker-Content-Digest header
242 digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data))
243 w.Header().Set("Docker-Content-Digest", digest)
244 w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference))
245 w.WriteHeader(http.StatusCreated)
246
247 // Emit image pushed event
248 event := events.Event{
249 Type: events.ImagePushed,
250 ImageName: name,
251 Tag: reference,
252 Data: map[string]interface{}{
253 "digest": digest,
254 "size": len(data),
255 },
256 }
257
258 if err := s.eventBus.Publish(event); err != nil {
259 log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to publish image pushed event")
260 } else {
261 log.Info().Str("name", name).Str("reference", reference).Msg("Image pushed event published")
262 }
263}
264
265func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) {
266 name, digest := s.parseBlobPath(r.URL.Path)
267
268 log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob")
269
270 reader, err := s.storage.GetBlob(digest)
271 if err != nil {
272 log.Warn().Err(err).Str("digest", digest).Msg("Blob not found")
273 w.WriteHeader(http.StatusNotFound)
274 return
275 }
276 defer reader.Close()
277
278 w.Header().Set("Content-Type", "application/octet-stream")
279 w.WriteHeader(http.StatusOK)
280
281 _, err = io.Copy(w, reader)
282 if err != nil {
283 log.Error().Err(err).Str("digest", digest).Msg("Failed to serve blob")
284 }
285}
286
287func (s *Server) handleStartBlobUpload(w http.ResponseWriter, r *http.Request) {
288 name, _ := s.parseUploadPath(r.URL.Path)
289
290 log.Debug().Str("name", name).Msg("Start blob upload")
291
292 uuid, err := s.storage.StartBlobUpload(name)
293 if err != nil {
294 log.Error().Err(err).Str("name", name).Msg("Failed to start blob upload")
295 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
296 return
297 }
298
299 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
300 w.Header().Set("Range", "0-0")
301 w.WriteHeader(http.StatusAccepted)
302}
303
304func (s *Server) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
305 name, uuid := s.parseUploadPath(r.URL.Path)
306
307 log.Debug().Str("name", name).Str("uuid", uuid).Msg("Blob upload")
308
309 // Check if this is a completion request (has digest parameter)
310 digest := r.URL.Query().Get("digest")
311
312 if r.Method == "PUT" && digest != "" {
313 // This is a completion request
314 if err := s.storage.FinishBlobUpload(uuid, digest); err != nil {
315 log.Error().Err(err).Str("uuid", uuid).Str("digest", digest).Msg("Failed to finish blob upload")
316 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
317 return
318 }
319
320 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest))
321 w.Header().Set("Docker-Content-Digest", digest)
322 w.WriteHeader(http.StatusCreated)
323
324 log.Info().Str("name", name).Str("uuid", uuid).Str("digest", digest).Msg("Blob upload completed")
325 return
326 }
327
328 // This is a chunk upload (PATCH)
329 writer, err := s.storage.GetBlobUpload(uuid)
330 if err != nil {
331 log.Error().Err(err).Str("uuid", uuid).Msg("Failed to get blob upload")
332 http.Error(w, "Not Found", http.StatusNotFound)
333 return
334 }
335 defer writer.Close()
336
337 written, err := io.Copy(writer, r.Body)
338 if err != nil {
339 log.Error().Err(err).Str("uuid", uuid).Msg("Failed to write blob chunk")
340 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
341 return
342 }
343
344 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
345 w.Header().Set("Range", fmt.Sprintf("0-%d", written-1))
346 w.WriteHeader(http.StatusAccepted)
347
348 log.Debug().Str("uuid", uuid).Int64("bytes", written).Msg("Blob chunk uploaded")
349}
350
351func (s *Server) handleListTags(w http.ResponseWriter, r *http.Request) {
352 name := s.parseTagsPath(r.URL.Path)
353
354 log.Debug().Str("name", name).Msg("List tags")
355
356 tags, err := s.storage.ListTags(name)
357 if err != nil {
358 log.Error().Err(err).Str("name", name).Msg("Failed to list tags")
359 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
360 return
361 }
362
363 w.Header().Set("Content-Type", "application/json")
364 w.WriteHeader(http.StatusOK)
365
366 // Build JSON response
367 fmt.Fprintf(w, `{"name":"%s","tags":[`, name)
368 for i, tag := range tags {
369 if i > 0 {
370 fmt.Fprint(w, ",")
371 }
372 fmt.Fprintf(w, `"%s"`, tag)
373 }
374 fmt.Fprint(w, "]}")
375}