v2.0.0 ae3e752

storage.go RawHistoryFolder

1package registry
2
3import (
4	"encoding/json"
5	"fmt"
6	"io"
7	"os"
8	"path/filepath"
9	"strings"
10	"time"
11
12	"github.com/rs/zerolog/log"
13)
14
15// Storage interface defines the contract for registry storage backends
16type Storage interface {
17	// Manifest operations
18	GetManifest(name, reference string) ([]byte, string, error)
19	PutManifest(name, reference, contentType string, data []byte) error
20	DeleteManifest(name, reference string) error
21
22	// Blob operations
23	GetBlob(digest string) (io.ReadCloser, error)
24	GetBlobPath(digest string) (string, error)
25	PutBlob(digest string, data io.Reader, size int64) error
26	DeleteBlob(digest string) error
27	BlobExists(digest string) bool
28
29	// Upload operations
30	StartBlobUpload(name string) (string, error) // returns upload UUID
31	AppendBlobChunk(name, uuid string, chunk []byte) (int64, error)
32	GetBlobUpload(uuid string) (io.WriteCloser, error)
33	FinishBlobUpload(uuid, digest string) error
34	CancelBlobUpload(uuid string) error
35
36	// Tag operations
37	ListTags(name string) ([]string, error)
38
39	// Repository operations
40	ListRepositories() ([]string, error)
41}
42
43// FilesystemStorage implements Storage using the local filesystem
44type FilesystemStorage struct {
45	rootDir string
46}
47
48// NewFilesystemStorage creates a new filesystem storage instance
49func NewFilesystemStorage(rootDir string) (*FilesystemStorage, error) {
50	// Create directory structure if it doesn't exist
51	dirs := []string{
52		filepath.Join(rootDir, "repositories"),
53		filepath.Join(rootDir, "blobs"),
54		filepath.Join(rootDir, "uploads"),
55	}
56
57	for _, dir := range dirs {
58		if err := os.MkdirAll(dir, 0755); err != nil {
59			return nil, fmt.Errorf("failed to create directory %s: %w", dir, err)
60		}
61	}
62
63	log.Info().Str("root_dir", rootDir).Msg("Filesystem storage initialized")
64
65	return &FilesystemStorage{
66		rootDir: rootDir,
67	}, nil
68}
69
70// Manifest operations
71
72func (fs *FilesystemStorage) GetManifest(name, reference string) ([]byte, string, error) {
73	manifestPath := fs.getManifestPath(name, reference)
74
75	data, err := os.ReadFile(manifestPath)
76	if err != nil {
77		if os.IsNotExist(err) {
78			return nil, "", fmt.Errorf("manifest not found: %s/%s", name, reference)
79		}
80		return nil, "", fmt.Errorf("failed to read manifest: %w", err)
81	}
82
83	contentType, err := fs.getManifestContentType(name, reference)
84	if err != nil {
85		// Fallback for manifests stored before this change
86		log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Could not get manifest content type, falling back to default")
87		contentType = "application/vnd.docker.distribution.manifest.v2+json"
88	}
89
90	return data, contentType, nil
91}
92
93func (fs *FilesystemStorage) PutManifest(name, reference, contentType string, data []byte) error {
94	manifestPath := fs.getManifestPath(name, reference)
95
96	// Create directory if it doesn't exist
97	if err := os.MkdirAll(filepath.Dir(manifestPath), 0755); err != nil {
98		return fmt.Errorf("failed to create manifest directory: %w", err)
99	}
100
101	// Write manifest file
102	if err := os.WriteFile(manifestPath, data, 0644); err != nil {
103		return fmt.Errorf("failed to write manifest: %w", err)
104	}
105
106	// Store content type
107	if err := fs.putManifestContentType(name, reference, contentType); err != nil {
108		// Don't fail the whole operation, but log a warning
109		log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to store manifest content type")
110	}
111
112	// Update tags list
113	if err := fs.updateTagsList(name, reference); err != nil {
114		log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to update tags list")
115	}
116
117	log.Info().Str("name", name).Str("reference", reference).Msg("Manifest stored")
118	return nil
119}
120
121func (fs *FilesystemStorage) DeleteManifest(name, reference string) error {
122	manifestPath := fs.getManifestPath(name, reference)
123
124	if err := os.Remove(manifestPath); err != nil {
125		if os.IsNotExist(err) {
126			return fmt.Errorf("manifest not found: %s/%s", name, reference)
127		}
128		return fmt.Errorf("failed to delete manifest: %w", err)
129	}
130
131	// Delete content type file
132	if err := fs.deleteManifestContentType(name, reference); err != nil {
133		log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to delete manifest content type")
134	}
135
136	// Remove from tags list
137	if err := fs.removeFromTagsList(name, reference); err != nil {
138		log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to update tags list")
139	}
140
141	log.Info().Str("name", name).Str("reference", reference).Msg("Manifest deleted")
142	return nil
143}
144
145// Blob operations
146
147func (fs *FilesystemStorage) GetBlob(digest string) (io.ReadCloser, error) {
148	blobPath := fs.getBlobPath(digest)
149
150	file, err := os.Open(blobPath)
151	if err != nil {
152		if os.IsNotExist(err) {
153			return nil, fmt.Errorf("blob not found: %s", digest)
154		}
155		return nil, fmt.Errorf("failed to open blob: %w", err)
156	}
157
158	return file, nil
159}
160
161func (fs *FilesystemStorage) GetBlobPath(digest string) (string, error) {
162	path := fs.getBlobPath(digest)
163	if _, err := os.Stat(path); err != nil {
164		if os.IsNotExist(err) {
165			return "", fmt.Errorf("blob not found: %s", digest)
166		}
167		return "", err
168	}
169	return path, nil
170}
171
172func (fs *FilesystemStorage) PutBlob(digest string, data io.Reader, size int64) error {
173	blobPath := fs.getBlobPath(digest)
174
175	// Create directory if it doesn't exist
176	if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil {
177		return fmt.Errorf("failed to create blob directory: %w", err)
178	}
179
180	// Create temporary file first
181	tmpPath := blobPath + ".tmp"
182	file, err := os.Create(tmpPath)
183	if err != nil {
184		return fmt.Errorf("failed to create temporary blob file: %w", err)
185	}
186	defer file.Close()
187
188	// Copy data to file
189	written, err := io.Copy(file, data)
190	if err != nil {
191		os.Remove(tmpPath)
192		return fmt.Errorf("failed to write blob data: %w", err)
193	}
194
195	// Verify size if provided
196	if size > 0 && written != size {
197		os.Remove(tmpPath)
198		return fmt.Errorf("blob size mismatch: expected %d, got %d", size, written)
199	}
200
201	// Move to final location
202	if err := os.Rename(tmpPath, blobPath); err != nil {
203		os.Remove(tmpPath)
204		return fmt.Errorf("failed to move blob to final location: %w", err)
205	}
206
207	log.Info().Str("digest", digest).Int64("size", written).Msg("Blob stored")
208	return nil
209}
210
211func (fs *FilesystemStorage) DeleteBlob(digest string) error {
212	blobPath := fs.getBlobPath(digest)
213
214	if err := os.Remove(blobPath); err != nil {
215		if os.IsNotExist(err) {
216			return fmt.Errorf("blob not found: %s", digest)
217		}
218		return fmt.Errorf("failed to delete blob: %w", err)
219	}
220
221	log.Info().Str("digest", digest).Msg("Blob deleted")
222	return nil
223}
224
225func (fs *FilesystemStorage) BlobExists(digest string) bool {
226	blobPath := fs.getBlobPath(digest)
227	_, err := os.Stat(blobPath)
228	return err == nil
229}
230
231// Upload operations
232
233func (fs *FilesystemStorage) StartBlobUpload(name string) (string, error) {
234	// Generate UUID-like upload ID using timestamp
235	uuid := fmt.Sprintf("%d-%s", time.Now().UnixNano(), strings.ReplaceAll(name, "/", "_"))
236
237	uploadPath := fs.getUploadPath(uuid)
238
239	// Create directory if it doesn't exist
240	if err := os.MkdirAll(filepath.Dir(uploadPath), 0755); err != nil {
241		return "", fmt.Errorf("failed to create upload directory: %w", err)
242	}
243
244	// Create empty upload file
245	file, err := os.Create(uploadPath)
246	if err != nil {
247		return "", fmt.Errorf("failed to create upload file: %w", err)
248	}
249	file.Close()
250
251	log.Info().Str("uuid", uuid).Str("name", name).Msg("Blob upload started")
252	return uuid, nil
253}
254
255func (fs *FilesystemStorage) AppendBlobChunk(name, uuid string, chunk []byte) (int64, error) {
256	uploadPath := fs.getUploadPath(uuid)
257
258	file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0644)
259	if err != nil {
260		if os.IsNotExist(err) {
261			return 0, fmt.Errorf("upload not found: %s", uuid)
262		}
263		return 0, fmt.Errorf("failed to open upload file: %w", err)
264	}
265	defer file.Close()
266
267	written, err := file.Write(chunk)
268	if err != nil {
269		return 0, fmt.Errorf("failed to write chunk to upload file: %w", err)
270	}
271
272	fi, err := file.Stat()
273	if err != nil {
274		return 0, fmt.Errorf("failed to get file info: %w", err)
275	}
276
277	log.Debug().
278		Str("uuid", uuid).
279		Str("name", name).
280		Int("chunk_size", written).
281		Int64("total_size", fi.Size()).
282		Msg("Appended chunk to blob upload")
283
284	return fi.Size(), nil
285}
286
287func (fs *FilesystemStorage) GetBlobUpload(uuid string) (io.WriteCloser, error) {
288	uploadPath := fs.getUploadPath(uuid)
289
290	file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0644)
291	if err != nil {
292		if os.IsNotExist(err) {
293			return nil, fmt.Errorf("upload not found: %s", uuid)
294		}
295		return nil, fmt.Errorf("failed to open upload file: %w", err)
296	}
297
298	return file, nil
299}
300
301func (fs *FilesystemStorage) FinishBlobUpload(uuid, digest string) error {
302	uploadPath := fs.getUploadPath(uuid)
303	blobPath := fs.getBlobPath(digest)
304
305	// Create blob directory if it doesn't exist
306	if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil {
307		return fmt.Errorf("failed to create blob directory: %w", err)
308	}
309
310	// Move upload file to blob location
311	if err := os.Rename(uploadPath, blobPath); err != nil {
312		return fmt.Errorf("failed to move upload to blob location: %w", err)
313	}
314
315	log.Info().Str("uuid", uuid).Str("digest", digest).Msg("Blob upload finished")
316	return nil
317}
318
319func (fs *FilesystemStorage) CancelBlobUpload(uuid string) error {
320	uploadPath := fs.getUploadPath(uuid)
321
322	if err := os.Remove(uploadPath); err != nil {
323		if os.IsNotExist(err) {
324			return fmt.Errorf("upload not found: %s", uuid)
325		}
326		return fmt.Errorf("failed to cancel upload: %w", err)
327	}
328
329	log.Info().Str("uuid", uuid).Msg("Blob upload cancelled")
330	return nil
331}
332
333// Tag operations
334
335func (fs *FilesystemStorage) ListTags(name string) ([]string, error) {
336	tagsPath := fs.getTagsPath(name)
337
338	data, err := os.ReadFile(tagsPath)
339	if err != nil {
340		if os.IsNotExist(err) {
341			return []string{}, nil // No tags file means no tags
342		}
343		return nil, fmt.Errorf("failed to read tags file: %w", err)
344	}
345
346	var tags []string
347	if err := json.Unmarshal(data, &tags); err != nil {
348		return nil, fmt.Errorf("failed to parse tags file: %w", err)
349	}
350
351	return tags, nil
352}
353
354// Repository operations
355
356func (fs *FilesystemStorage) ListRepositories() ([]string, error) {
357	reposDir := filepath.Join(fs.rootDir, "repositories")
358
359	var repositories []string
360	err := filepath.Walk(reposDir, func(path string, info os.FileInfo, err error) error {
361		if err != nil {
362			return err
363		}
364
365		if info.IsDir() && path != reposDir {
366			// Get relative path from repositories directory
367			relPath, err := filepath.Rel(reposDir, path)
368			if err != nil {
369				return err
370			}
371
372			// Check if this directory contains manifests
373			manifestsDir := filepath.Join(path, "manifests")
374			if _, err := os.Stat(manifestsDir); err == nil {
375				repositories = append(repositories, relPath)
376			}
377		}
378
379		return nil
380	})
381
382	if err != nil {
383		return nil, fmt.Errorf("failed to list repositories: %w", err)
384	}
385
386	return repositories, nil
387}
388
389// Helper methods for path generation
390
391func (fs *FilesystemStorage) getManifestPath(name, reference string) string {
392	return filepath.Join(fs.rootDir, "repositories", name, "manifests", reference)
393}
394
395func (fs *FilesystemStorage) getManifestContentTypePath(name, reference string) string {
396	return fs.getManifestPath(name, reference) + ".contenttype"
397}
398
399func (fs *FilesystemStorage) getManifestContentType(name, reference string) (string, error) {
400	contentTypePath := fs.getManifestContentTypePath(name, reference)
401	data, err := os.ReadFile(contentTypePath)
402	if err != nil {
403		return "", err
404	}
405	return string(data), nil
406}
407
408func (fs *FilesystemStorage) putManifestContentType(name, reference, contentType string) error {
409	contentTypePath := fs.getManifestContentTypePath(name, reference)
410	return os.WriteFile(contentTypePath, []byte(contentType), 0644)
411}
412
413func (fs *FilesystemStorage) deleteManifestContentType(name, reference string) error {
414	contentTypePath := fs.getManifestContentTypePath(name, reference)
415	err := os.Remove(contentTypePath)
416	if err != nil && !os.IsNotExist(err) {
417		return err
418	}
419	return nil
420}
421
422func (fs *FilesystemStorage) getBlobPath(digest string) string {
423	// Split digest into directory structure (e.g., sha256:abc123... -> sha256/ab/abc123...)
424	parts := strings.SplitN(digest, ":", 2)
425	if len(parts) != 2 {
426		// Fallback if digest format is unexpected
427		return filepath.Join(fs.rootDir, "blobs", digest)
428	}
429
430	algorithm := parts[0]
431	hash := parts[1]
432
433	// Create two-level directory structure for better performance
434	if len(hash) >= 2 {
435		return filepath.Join(fs.rootDir, "blobs", algorithm, hash[:2], hash)
436	}
437
438	return filepath.Join(fs.rootDir, "blobs", algorithm, hash)
439}
440
441func (fs *FilesystemStorage) getUploadPath(uuid string) string {
442	return filepath.Join(fs.rootDir, "uploads", uuid)
443}
444
445func (fs *FilesystemStorage) getTagsPath(name string) string {
446	return filepath.Join(fs.rootDir, "repositories", name, "tags.json")
447}
448
449// Helper methods for tags management
450
451func (fs *FilesystemStorage) updateTagsList(name, reference string) error {
452	tags, err := fs.ListTags(name)
453	if err != nil {
454		return err
455	}
456
457	// Add tag if not already present
458	for _, tag := range tags {
459		if tag == reference {
460			return nil // Already exists
461		}
462	}
463
464	tags = append(tags, reference)
465	return fs.saveTagsList(name, tags)
466}
467
468func (fs *FilesystemStorage) removeFromTagsList(name, reference string) error {
469	tags, err := fs.ListTags(name)
470	if err != nil {
471		return err
472	}
473
474	// Remove tag if present
475	var newTags []string
476	for _, tag := range tags {
477		if tag != reference {
478			newTags = append(newTags, tag)
479		}
480	}
481
482	return fs.saveTagsList(name, newTags)
483}
484
485func (fs *FilesystemStorage) saveTagsList(name string, tags []string) error {
486	tagsPath := fs.getTagsPath(name)
487
488	// Create directory if it doesn't exist
489	if err := os.MkdirAll(filepath.Dir(tagsPath), 0755); err != nil {
490		return fmt.Errorf("failed to create tags directory: %w", err)
491	}
492
493	data, err := json.Marshal(tags)
494	if err != nil {
495		return fmt.Errorf("failed to marshal tags: %w", err)
496	}
497
498	if err := os.WriteFile(tagsPath, data, 0644); err != nil {
499		return fmt.Errorf("failed to write tags file: %w", err)
500	}
501
502	return nil
503}

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 8

Tags 106

↑↓ moveenter openesc close