2.0.1 dcf7d6b

server.go RawHistoryFolder

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}

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