detached 851aace

Merge pull request #150 from bnema/fix/registry-prune-space-reclaimed

fix(registry): report actual space reclaimed during prune
commit
851aacea5d92f4a034b1b81309c9821e7a42df20
author
bnema · · 2026-03-27 19:07 UTC
committer
GitHub · · 2026-03-27 19:07 UTC
merge of
511705f + 1660658 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
6 files · +52 -21
M internal/adapters/out/filesystem/blob.go +14 -6
@@ -136,26 +136,34 @@ func (s *BlobStorage) PutBlob(digest string, data io.Reader, size int64) error {
136136 }
137137
138138 // DeleteBlob removes a blob by digest.
139-func (s *BlobStorage) DeleteBlob(digest string) error {
139+// Returns the size in bytes of the removed blob.
140+func (s *BlobStorage) DeleteBlob(digest string) (int64, error) {
140141 blobPath, err := s.getBlobPath(digest)
141142 if err != nil {
142- return fmt.Errorf("invalid path: %w", err)
143+ return 0, fmt.Errorf("invalid path: %w", err)
143144 }
144145
145- if err := os.Remove(blobPath); err != nil {
146+ info, err := os.Stat(blobPath)
147+ if err != nil {
146148 if os.IsNotExist(err) {
147- return fmt.Errorf("blob not found: %s", digest)
149+ return 0, fmt.Errorf("blob not found: %s", digest)
148150 }
149- return fmt.Errorf("failed to delete blob: %w", err)
151+ return 0, fmt.Errorf("failed to stat blob: %w", err)
152+ }
153+ size := info.Size()
154+
155+ if err := os.Remove(blobPath); err != nil {
156+ return 0, fmt.Errorf("failed to delete blob: %w", err)
150157 }
151158
152159 s.log.Info().
153160 Str(zerowrap.FieldLayer, "adapter").
154161 Str(zerowrap.FieldAdapter, "filesystem").
155162 Str("digest", digest).
163+ Int64(zerowrap.FieldSize, size).
156164 Msg("blob deleted")
157165
158- return nil
166+ return size, nil
159167 }
160168
161169 // BlobExists checks if a blob exists.
@@ -136,26 +136,34 @@ func (s *BlobStorage) PutBlob(digest string, data io.Reader, size int64) error {
136 }136 }
137 137
138 // DeleteBlob removes a blob by digest.138 // DeleteBlob removes a blob by digest.
139-func (s *BlobStorage) DeleteBlob(digest string) error {139+// Returns the size in bytes of the removed blob.
140+func (s *BlobStorage) DeleteBlob(digest string) (int64, error) {
140 blobPath, err := s.getBlobPath(digest)141 blobPath, err := s.getBlobPath(digest)
141 if err != nil {142 if err != nil {
142- return fmt.Errorf("invalid path: %w", err)143+ return 0, fmt.Errorf("invalid path: %w", err)
143 }144 }
144 145
145- if err := os.Remove(blobPath); err != nil {146+ info, err := os.Stat(blobPath)
147+ if err != nil {
146 if os.IsNotExist(err) {148 if os.IsNotExist(err) {
147- return fmt.Errorf("blob not found: %s", digest)149+ return 0, fmt.Errorf("blob not found: %s", digest)
148 }150 }
149- return fmt.Errorf("failed to delete blob: %w", err)151+ return 0, fmt.Errorf("failed to stat blob: %w", err)
152+ }
153+ size := info.Size()
154+
155+ if err := os.Remove(blobPath); err != nil {
156+ return 0, fmt.Errorf("failed to delete blob: %w", err)
150 }157 }
151 158
152 s.log.Info().159 s.log.Info().
153 Str(zerowrap.FieldLayer, "adapter").160 Str(zerowrap.FieldLayer, "adapter").
154 Str(zerowrap.FieldAdapter, "filesystem").161 Str(zerowrap.FieldAdapter, "filesystem").
155 Str("digest", digest).162 Str("digest", digest).
163+ Int64(zerowrap.FieldSize, size).
156 Msg("blob deleted")164 Msg("blob deleted")
157 165
158- return nil166+ return size, nil
159 }167 }
160 168
161 // BlobExists checks if a blob exists.169 // BlobExists checks if a blob exists.
M internal/adapters/out/filesystem/blob_test.go +4 -2
@@ -175,8 +175,9 @@ func TestBlobStorage_DeleteBlob(t *testing.T) {
175175 assert.True(t, storage.BlobExists(testDigest2))
176176
177177 // Delete blob
178- err = storage.DeleteBlob(testDigest2)
178+ size, err := storage.DeleteBlob(testDigest2)
179179 require.NoError(t, err)
180+ assert.Equal(t, int64(len(blobData)), size)
180181
181182 // Verify it's gone
182183 assert.False(t, storage.BlobExists(testDigest2))
@@ -189,9 +190,10 @@ func TestBlobStorage_DeleteBlob_NotFound(t *testing.T) {
189190 storage, err := NewBlobStorage(tmpDir, log)
190191 require.NoError(t, err)
191192
192- err = storage.DeleteBlob(testDigestMissing)
193+ size, err := storage.DeleteBlob(testDigestMissing)
193194
194195 assert.Error(t, err)
196+ assert.Equal(t, int64(0), size)
195197 assert.Contains(t, err.Error(), "blob not found")
196198 }
197199
@@ -175,8 +175,9 @@ func TestBlobStorage_DeleteBlob(t *testing.T) {
175 assert.True(t, storage.BlobExists(testDigest2))175 assert.True(t, storage.BlobExists(testDigest2))
176 176
177 // Delete blob177 // Delete blob
178- err = storage.DeleteBlob(testDigest2)178+ size, err := storage.DeleteBlob(testDigest2)
179 require.NoError(t, err)179 require.NoError(t, err)
180+ assert.Equal(t, int64(len(blobData)), size)
180 181
181 // Verify it's gone182 // Verify it's gone
182 assert.False(t, storage.BlobExists(testDigest2))183 assert.False(t, storage.BlobExists(testDigest2))
@@ -189,9 +190,10 @@ func TestBlobStorage_DeleteBlob_NotFound(t *testing.T) {
189 storage, err := NewBlobStorage(tmpDir, log)190 storage, err := NewBlobStorage(tmpDir, log)
190 require.NoError(t, err)191 require.NoError(t, err)
191 192
192- err = storage.DeleteBlob(testDigestMissing)193+ size, err := storage.DeleteBlob(testDigestMissing)
193 194
194 assert.Error(t, err)195 assert.Error(t, err)
196+ assert.Equal(t, int64(0), size)
195 assert.Contains(t, err.Error(), "blob not found")197 assert.Contains(t, err.Error(), "blob not found")
196 }198 }
197 199
M internal/boundaries/out/mocks/mock_blob_storage.go +17 -8
@@ -279,20 +279,29 @@ func (_c *MockBlobStorage_CleanupStaleUploads_Call) RunAndReturn(run func(maxAge
279279 }
280280
281281 // DeleteBlob provides a mock function for the type MockBlobStorage
282-func (_mock *MockBlobStorage) DeleteBlob(digest string) error {
282+func (_mock *MockBlobStorage) DeleteBlob(digest string) (int64, error) {
283283 ret := _mock.Called(digest)
284284
285285 if len(ret) == 0 {
286286 panic("no return value specified for DeleteBlob")
287287 }
288288
289- var r0 error
290- if returnFunc, ok := ret.Get(0).(func(string) error); ok {
289+ var r0 int64
290+ var r1 error
291+ if returnFunc, ok := ret.Get(0).(func(string) (int64, error)); ok {
292+ return returnFunc(digest)
293+ }
294+ if returnFunc, ok := ret.Get(0).(func(string) int64); ok {
291295 r0 = returnFunc(digest)
292296 } else {
293- r0 = ret.Error(0)
297+ r0 = ret.Get(0).(int64)
294298 }
295- return r0
299+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
300+ r1 = returnFunc(digest)
301+ } else {
302+ r1 = ret.Error(1)
303+ }
304+ return r0, r1
296305 }
297306
298307 // MockBlobStorage_DeleteBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteBlob'
@@ -319,12 +328,12 @@ func (_c *MockBlobStorage_DeleteBlob_Call) Run(run func(digest string)) *MockBlo
319328 return _c
320329 }
321330
322-func (_c *MockBlobStorage_DeleteBlob_Call) Return(err error) *MockBlobStorage_DeleteBlob_Call {
323- _c.Call.Return(err)
331+func (_c *MockBlobStorage_DeleteBlob_Call) Return(n int64, err error) *MockBlobStorage_DeleteBlob_Call {
332+ _c.Call.Return(n, err)
324333 return _c
325334 }
326335
327-func (_c *MockBlobStorage_DeleteBlob_Call) RunAndReturn(run func(digest string) error) *MockBlobStorage_DeleteBlob_Call {
336+func (_c *MockBlobStorage_DeleteBlob_Call) RunAndReturn(run func(digest string) (int64, error)) *MockBlobStorage_DeleteBlob_Call {
328337 _c.Call.Return(run)
329338 return _c
330339 }
@@ -279,20 +279,29 @@ func (_c *MockBlobStorage_CleanupStaleUploads_Call) RunAndReturn(run func(maxAge
279 }279 }
280 280
281 // DeleteBlob provides a mock function for the type MockBlobStorage281 // DeleteBlob provides a mock function for the type MockBlobStorage
282-func (_mock *MockBlobStorage) DeleteBlob(digest string) error {282+func (_mock *MockBlobStorage) DeleteBlob(digest string) (int64, error) {
283 ret := _mock.Called(digest)283 ret := _mock.Called(digest)
284 284
285 if len(ret) == 0 {285 if len(ret) == 0 {
286 panic("no return value specified for DeleteBlob")286 panic("no return value specified for DeleteBlob")
287 }287 }
288 288
289- var r0 error289+ var r0 int64
290- if returnFunc, ok := ret.Get(0).(func(string) error); ok {290+ var r1 error
291+ if returnFunc, ok := ret.Get(0).(func(string) (int64, error)); ok {
292+ return returnFunc(digest)
293+ }
294+ if returnFunc, ok := ret.Get(0).(func(string) int64); ok {
291 r0 = returnFunc(digest)295 r0 = returnFunc(digest)
292 } else {296 } else {
293- r0 = ret.Error(0)297+ r0 = ret.Get(0).(int64)
294 }298 }
295- return r0299+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
300+ r1 = returnFunc(digest)
301+ } else {
302+ r1 = ret.Error(1)
303+ }
304+ return r0, r1
296 }305 }
297 306
298 // MockBlobStorage_DeleteBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteBlob'307 // MockBlobStorage_DeleteBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteBlob'
@@ -319,12 +328,12 @@ func (_c *MockBlobStorage_DeleteBlob_Call) Run(run func(digest string)) *MockBlo
319 return _c328 return _c
320 }329 }
321 330
322-func (_c *MockBlobStorage_DeleteBlob_Call) Return(err error) *MockBlobStorage_DeleteBlob_Call {331+func (_c *MockBlobStorage_DeleteBlob_Call) Return(n int64, err error) *MockBlobStorage_DeleteBlob_Call {
323- _c.Call.Return(err)332+ _c.Call.Return(n, err)
324 return _c333 return _c
325 }334 }
326 335
327-func (_c *MockBlobStorage_DeleteBlob_Call) RunAndReturn(run func(digest string) error) *MockBlobStorage_DeleteBlob_Call {336+func (_c *MockBlobStorage_DeleteBlob_Call) RunAndReturn(run func(digest string) (int64, error)) *MockBlobStorage_DeleteBlob_Call {
328 _c.Call.Return(run)337 _c.Call.Return(run)
329 return _c338 return _c
330 }339 }
M internal/boundaries/out/storage.go +2 -1
@@ -17,7 +17,8 @@ type BlobStorage interface {
1717 PutBlob(digest string, data io.Reader, size int64) error
1818
1919 // DeleteBlob removes a blob by digest.
20- DeleteBlob(digest string) error
20+ // Returns the size in bytes of the removed blob.
21+ DeleteBlob(digest string) (int64, error)
2122
2223 // BlobExists checks if a blob exists.
2324 BlobExists(digest string) bool
@@ -17,7 +17,8 @@ type BlobStorage interface {
17 PutBlob(digest string, data io.Reader, size int64) error17 PutBlob(digest string, data io.Reader, size int64) error
18 18
19 // DeleteBlob removes a blob by digest.19 // DeleteBlob removes a blob by digest.
20- DeleteBlob(digest string) error20+ // Returns the size in bytes of the removed blob.
21+ DeleteBlob(digest string) (int64, error)
21 22
22 // BlobExists checks if a blob exists.23 // BlobExists checks if a blob exists.
23 BlobExists(digest string) bool24 BlobExists(digest string) bool
M internal/usecase/images/service.go +3 -1
@@ -243,10 +243,12 @@ func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.Image
243243 if _, referenced := referencedDigests[digest]; referenced {
244244 continue
245245 }
246- if err := s.blobStorage.DeleteBlob(digest); err != nil {
246+ size, err := s.blobStorage.DeleteBlob(digest)
247+ if err != nil {
247248 return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete blob")
248249 }
249250 report.Registry.BlobsRemoved++
251+ report.Registry.SpaceReclaimed += size
250252 }
251253
252254 // Clean up stale uploads (abandoned push operations)
@@ -243,10 +243,12 @@ func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.Image
243 if _, referenced := referencedDigests[digest]; referenced {243 if _, referenced := referencedDigests[digest]; referenced {
244 continue244 continue
245 }245 }
246- if err := s.blobStorage.DeleteBlob(digest); err != nil {246+ size, err := s.blobStorage.DeleteBlob(digest)
247+ if err != nil {
247 return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete blob")248 return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete blob")
248 }249 }
249 report.Registry.BlobsRemoved++250 report.Registry.BlobsRemoved++
251+ report.Registry.SpaceReclaimed += size
250 }252 }
251 253
252 // Clean up stale uploads (abandoned push operations)254 // Clean up stale uploads (abandoned push operations)
M internal/usecase/images/service_test.go +12 -3
@@ -318,13 +318,17 @@ func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) {
318318 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
319319 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
320320
321- blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
321+ blobStorage := &fakeBlobStorage{
322+ blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"},
323+ blobSizes: map[string]int64{"sha256:orphan": 4096},
324+ }
322325 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
323326
324327 report, err := svc.PruneRegistry(context.Background(), 1)
325328
326329 require.NoError(t, err)
327330 assert.Equal(t, 1, report.Registry.BlobsRemoved)
331+ assert.Equal(t, int64(4096), report.Registry.SpaceReclaimed)
328332 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
329333 }
330334
@@ -663,6 +667,7 @@ type fakeBlobStorage struct {
663667 out.BlobStorage
664668
665669 blobs []string
670+ blobSizes map[string]int64
666671 deletedBlobs []string
667672 }
668673
@@ -789,8 +794,12 @@ func (f *fakeBlobStorage) ListBlobs() ([]string, error) {
789794 return append([]string(nil), f.blobs...), nil
790795 }
791796
792-func (f *fakeBlobStorage) DeleteBlob(digest string) error {
797+func (f *fakeBlobStorage) DeleteBlob(digest string) (int64, error) {
793798 f.deletedBlobs = append(f.deletedBlobs, digest)
799+ var size int64
800+ if f.blobSizes != nil {
801+ size = f.blobSizes[digest]
802+ }
794803 for i, existing := range f.blobs {
795804 if existing != digest {
796805 continue
@@ -798,7 +807,7 @@ func (f *fakeBlobStorage) DeleteBlob(digest string) error {
798807 f.blobs = append(f.blobs[:i], f.blobs[i+1:]...)
799808 break
800809 }
801- return nil
810+ return size, nil
802811 }
803812
804813 func (f *fakeBlobStorage) CleanupStaleUploads(_ time.Duration) (int, int64, error) {
@@ -318,13 +318,17 @@ func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) {
318 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)318 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
319 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")319 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
320 320
321- blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}321+ blobStorage := &fakeBlobStorage{
322+ blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"},
323+ blobSizes: map[string]int64{"sha256:orphan": 4096},
324+ }
322 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())325 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
323 326
324 report, err := svc.PruneRegistry(context.Background(), 1)327 report, err := svc.PruneRegistry(context.Background(), 1)
325 328
326 require.NoError(t, err)329 require.NoError(t, err)
327 assert.Equal(t, 1, report.Registry.BlobsRemoved)330 assert.Equal(t, 1, report.Registry.BlobsRemoved)
331+ assert.Equal(t, int64(4096), report.Registry.SpaceReclaimed)
328 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)332 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
329 }333 }
330 334
@@ -663,6 +667,7 @@ type fakeBlobStorage struct {
663 out.BlobStorage667 out.BlobStorage
664 668
665 blobs []string669 blobs []string
670+ blobSizes map[string]int64
666 deletedBlobs []string671 deletedBlobs []string
667 }672 }
668 673
@@ -789,8 +794,12 @@ func (f *fakeBlobStorage) ListBlobs() ([]string, error) {
789 return append([]string(nil), f.blobs...), nil794 return append([]string(nil), f.blobs...), nil
790 }795 }
791 796
792-func (f *fakeBlobStorage) DeleteBlob(digest string) error {797+func (f *fakeBlobStorage) DeleteBlob(digest string) (int64, error) {
793 f.deletedBlobs = append(f.deletedBlobs, digest)798 f.deletedBlobs = append(f.deletedBlobs, digest)
799+ var size int64
800+ if f.blobSizes != nil {
801+ size = f.blobSizes[digest]
802+ }
794 for i, existing := range f.blobs {803 for i, existing := range f.blobs {
795 if existing != digest {804 if existing != digest {
796 continue805 continue
@@ -798,7 +807,7 @@ func (f *fakeBlobStorage) DeleteBlob(digest string) error {
798 f.blobs = append(f.blobs[:i], f.blobs[i+1:]...)807 f.blobs = append(f.blobs[:i], f.blobs[i+1:]...)
799 break808 break
800 }809 }
801- return nil810+ return size, nil
802 }811 }
803 812
804 func (f *fakeBlobStorage) CleanupStaleUploads(_ time.Duration) (int, int64, error) {813 func (f *fakeBlobStorage) CleanupStaleUploads(_ time.Duration) (int, int64, error) {

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