detached 579cdb0

feat(images): add registry retention and blob garbage collection logic

commit
579cdb08c2a926e7354e57cc31466bc9cebc3da8
author
brice · · 2026-02-08 06:30 UTC
committer
brice · · 2026-02-08 09:40 UTC
parent
03ad744
signature
unsigned
2 files · +629 -0
M internal/usecase/images/service.go +223 -0
@@ -3,7 +3,10 @@ package images
33
44 import (
55 "context"
6+ "encoding/json"
7+ "sort"
68 "strings"
9+ "time"
710
811 "github.com/bnema/zerowrap"
912
@@ -102,6 +105,226 @@ func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, er
102105 }, nil
103106 }
104107
108+// PruneRegistry applies tag retention and blob garbage collection.
109+func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
110+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
111+ zerowrap.FieldLayer: "usecase",
112+ zerowrap.FieldUseCase: "PruneRegistry",
113+ "keepLast": keepLast,
114+ })
115+ log := zerowrap.FromCtx(ctx)
116+
117+ if keepLast <= 0 {
118+ return domain.ImagePruneReport{}, nil
119+ }
120+
121+ repositories, err := s.manifestStorage.ListRepositories()
122+ if err != nil {
123+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list repositories")
124+ }
125+
126+ referencedDigests := make(map[string]struct{})
127+ report := domain.ImagePruneReport{}
128+
129+ for _, repository := range repositories {
130+ tags, err := s.manifestStorage.ListTags(repository)
131+ if err != nil {
132+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list repository tags")
133+ }
134+
135+ if len(tags) == 0 {
136+ continue
137+ }
138+
139+ tagInfos := make([]registryTag, 0, len(tags))
140+ for _, tag := range tags {
141+ modTime, err := s.manifestStorage.GetManifestModTime(repository, tag)
142+ if err != nil {
143+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to read manifest modification time")
144+ }
145+ tagInfos = append(tagInfos, registryTag{name: tag, modTime: modTime})
146+ }
147+
148+ sort.Slice(tagInfos, func(i, j int) bool {
149+ if tagInfos[i].modTime.Equal(tagInfos[j].modTime) {
150+ return tagInfos[i].name > tagInfos[j].name
151+ }
152+ return tagInfos[i].modTime.After(tagInfos[j].modTime)
153+ })
154+
155+ keptTags := make(map[string]struct{})
156+ if containsTag(tagInfos, "latest") {
157+ keptTags["latest"] = struct{}{}
158+ }
159+ for i := 0; i < len(tagInfos) && i < keepLast; i++ {
160+ keptTags[tagInfos[i].name] = struct{}{}
161+ }
162+
163+ for _, tag := range tagInfos {
164+ if _, kept := keptTags[tag.name]; kept {
165+ continue
166+ }
167+ if err := s.manifestStorage.DeleteManifest(repository, tag.name); err != nil {
168+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete manifest")
169+ }
170+ report.Registry.TagsRemoved++
171+ }
172+
173+ for _, tag := range tagInfos {
174+ if _, kept := keptTags[tag.name]; !kept {
175+ continue
176+ }
177+
178+ if err := s.collectReferencedDigests(log, repository, tag.name, referencedDigests, make(map[string]struct{})); err != nil {
179+ return domain.ImagePruneReport{}, err
180+ }
181+ }
182+ }
183+
184+ blobs, err := s.blobStorage.ListBlobs()
185+ if err != nil {
186+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs")
187+ }
188+
189+ for _, digest := range blobs {
190+ if _, referenced := referencedDigests[digest]; referenced {
191+ continue
192+ }
193+ if err := s.blobStorage.DeleteBlob(digest); err != nil {
194+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete blob")
195+ }
196+ report.Registry.BlobsRemoved++
197+ }
198+
199+ return report, nil
200+}
201+
202+// Prune runs runtime prune and registry prune and aggregates reports.
203+func (s *Service) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
204+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
205+ zerowrap.FieldLayer: "usecase",
206+ zerowrap.FieldUseCase: "Prune",
207+ "keepLast": keepLast,
208+ })
209+ log := zerowrap.FromCtx(ctx)
210+
211+ var report domain.ImagePruneReport
212+
213+ runtimeReport, err := s.PruneRuntime(ctx)
214+ if err != nil {
215+ log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune")
216+ } else {
217+ report.Runtime = runtimeReport.Runtime
218+ }
219+
220+ registryReport, err := s.PruneRegistry(ctx, keepLast)
221+ if err != nil {
222+ return report, err
223+ }
224+
225+ report.Registry = registryReport.Registry
226+ return report, nil
227+}
228+
229+type registryTag struct {
230+ name string
231+ modTime time.Time
232+}
233+
234+type manifestReferences struct {
235+ blobs []string
236+ childManifests []string
237+}
238+
239+func containsTag(tags []registryTag, name string) bool {
240+ for _, tag := range tags {
241+ if tag.name == name {
242+ return true
243+ }
244+ }
245+
246+ return false
247+}
248+
249+func (s *Service) collectReferencedDigests(
250+ log zerowrap.Logger,
251+ repository, reference string,
252+ referencedDigests map[string]struct{},
253+ visited map[string]struct{},
254+) error {
255+ visitKey := repository + "@" + reference
256+ if _, seen := visited[visitKey]; seen {
257+ return nil
258+ }
259+ visited[visitKey] = struct{}{}
260+
261+ manifestData, _, err := s.manifestStorage.GetManifest(repository, reference)
262+ if err != nil {
263+ return log.WrapErr(err, "failed to read manifest")
264+ }
265+
266+ refs, err := parseManifestReferences(manifestData)
267+ if err != nil {
268+ return log.WrapErr(err, "failed to parse manifest")
269+ }
270+
271+ for _, digest := range refs.blobs {
272+ referencedDigests[digest] = struct{}{}
273+ }
274+
275+ for _, childRef := range refs.childManifests {
276+ referencedDigests[childRef] = struct{}{}
277+ if err := s.collectReferencedDigests(log, repository, childRef, referencedDigests, visited); err != nil {
278+ return err
279+ }
280+ }
281+
282+ return nil
283+}
284+
285+func parseManifestReferences(data []byte) (manifestReferences, error) {
286+ var manifest struct {
287+ Config struct {
288+ Digest string `json:"digest"`
289+ } `json:"config"`
290+ Layers []struct {
291+ Digest string `json:"digest"`
292+ } `json:"layers"`
293+ Manifests []struct {
294+ Digest string `json:"digest"`
295+ } `json:"manifests"`
296+ }
297+
298+ if err := json.Unmarshal(data, &manifest); err != nil {
299+ return manifestReferences{}, err
300+ }
301+
302+ refs := manifestReferences{
303+ blobs: make([]string, 0, len(manifest.Layers)+1),
304+ childManifests: make([]string, 0, len(manifest.Manifests)),
305+ }
306+
307+ if manifest.Config.Digest != "" {
308+ refs.blobs = append(refs.blobs, manifest.Config.Digest)
309+ }
310+
311+ for _, layer := range manifest.Layers {
312+ if layer.Digest == "" {
313+ continue
314+ }
315+ refs.blobs = append(refs.blobs, layer.Digest)
316+ }
317+
318+ for _, child := range manifest.Manifests {
319+ if child.Digest == "" {
320+ continue
321+ }
322+ refs.childManifests = append(refs.childManifests, child.Digest)
323+ }
324+
325+ return refs, nil
326+}
327+
105328 func isDanglingImage(repoTags []string) bool {
106329 if len(repoTags) == 0 {
107330 return true
@@ -3,7 +3,10 @@ package images
3 3
4 import (4 import (
5 "context"5 "context"
6+ "encoding/json"
7+ "sort"
6 "strings"8 "strings"
9+ "time"
7 10
8 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
9 12
@@ -102,6 +105,226 @@ func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, er
102 }, nil105 }, nil
103 }106 }
104 107
108+// PruneRegistry applies tag retention and blob garbage collection.
109+func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
110+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
111+ zerowrap.FieldLayer: "usecase",
112+ zerowrap.FieldUseCase: "PruneRegistry",
113+ "keepLast": keepLast,
114+ })
115+ log := zerowrap.FromCtx(ctx)
116+
117+ if keepLast <= 0 {
118+ return domain.ImagePruneReport{}, nil
119+ }
120+
121+ repositories, err := s.manifestStorage.ListRepositories()
122+ if err != nil {
123+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list repositories")
124+ }
125+
126+ referencedDigests := make(map[string]struct{})
127+ report := domain.ImagePruneReport{}
128+
129+ for _, repository := range repositories {
130+ tags, err := s.manifestStorage.ListTags(repository)
131+ if err != nil {
132+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list repository tags")
133+ }
134+
135+ if len(tags) == 0 {
136+ continue
137+ }
138+
139+ tagInfos := make([]registryTag, 0, len(tags))
140+ for _, tag := range tags {
141+ modTime, err := s.manifestStorage.GetManifestModTime(repository, tag)
142+ if err != nil {
143+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to read manifest modification time")
144+ }
145+ tagInfos = append(tagInfos, registryTag{name: tag, modTime: modTime})
146+ }
147+
148+ sort.Slice(tagInfos, func(i, j int) bool {
149+ if tagInfos[i].modTime.Equal(tagInfos[j].modTime) {
150+ return tagInfos[i].name > tagInfos[j].name
151+ }
152+ return tagInfos[i].modTime.After(tagInfos[j].modTime)
153+ })
154+
155+ keptTags := make(map[string]struct{})
156+ if containsTag(tagInfos, "latest") {
157+ keptTags["latest"] = struct{}{}
158+ }
159+ for i := 0; i < len(tagInfos) && i < keepLast; i++ {
160+ keptTags[tagInfos[i].name] = struct{}{}
161+ }
162+
163+ for _, tag := range tagInfos {
164+ if _, kept := keptTags[tag.name]; kept {
165+ continue
166+ }
167+ if err := s.manifestStorage.DeleteManifest(repository, tag.name); err != nil {
168+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete manifest")
169+ }
170+ report.Registry.TagsRemoved++
171+ }
172+
173+ for _, tag := range tagInfos {
174+ if _, kept := keptTags[tag.name]; !kept {
175+ continue
176+ }
177+
178+ if err := s.collectReferencedDigests(log, repository, tag.name, referencedDigests, make(map[string]struct{})); err != nil {
179+ return domain.ImagePruneReport{}, err
180+ }
181+ }
182+ }
183+
184+ blobs, err := s.blobStorage.ListBlobs()
185+ if err != nil {
186+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs")
187+ }
188+
189+ for _, digest := range blobs {
190+ if _, referenced := referencedDigests[digest]; referenced {
191+ continue
192+ }
193+ if err := s.blobStorage.DeleteBlob(digest); err != nil {
194+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete blob")
195+ }
196+ report.Registry.BlobsRemoved++
197+ }
198+
199+ return report, nil
200+}
201+
202+// Prune runs runtime prune and registry prune and aggregates reports.
203+func (s *Service) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
204+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
205+ zerowrap.FieldLayer: "usecase",
206+ zerowrap.FieldUseCase: "Prune",
207+ "keepLast": keepLast,
208+ })
209+ log := zerowrap.FromCtx(ctx)
210+
211+ var report domain.ImagePruneReport
212+
213+ runtimeReport, err := s.PruneRuntime(ctx)
214+ if err != nil {
215+ log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune")
216+ } else {
217+ report.Runtime = runtimeReport.Runtime
218+ }
219+
220+ registryReport, err := s.PruneRegistry(ctx, keepLast)
221+ if err != nil {
222+ return report, err
223+ }
224+
225+ report.Registry = registryReport.Registry
226+ return report, nil
227+}
228+
229+type registryTag struct {
230+ name string
231+ modTime time.Time
232+}
233+
234+type manifestReferences struct {
235+ blobs []string
236+ childManifests []string
237+}
238+
239+func containsTag(tags []registryTag, name string) bool {
240+ for _, tag := range tags {
241+ if tag.name == name {
242+ return true
243+ }
244+ }
245+
246+ return false
247+}
248+
249+func (s *Service) collectReferencedDigests(
250+ log zerowrap.Logger,
251+ repository, reference string,
252+ referencedDigests map[string]struct{},
253+ visited map[string]struct{},
254+) error {
255+ visitKey := repository + "@" + reference
256+ if _, seen := visited[visitKey]; seen {
257+ return nil
258+ }
259+ visited[visitKey] = struct{}{}
260+
261+ manifestData, _, err := s.manifestStorage.GetManifest(repository, reference)
262+ if err != nil {
263+ return log.WrapErr(err, "failed to read manifest")
264+ }
265+
266+ refs, err := parseManifestReferences(manifestData)
267+ if err != nil {
268+ return log.WrapErr(err, "failed to parse manifest")
269+ }
270+
271+ for _, digest := range refs.blobs {
272+ referencedDigests[digest] = struct{}{}
273+ }
274+
275+ for _, childRef := range refs.childManifests {
276+ referencedDigests[childRef] = struct{}{}
277+ if err := s.collectReferencedDigests(log, repository, childRef, referencedDigests, visited); err != nil {
278+ return err
279+ }
280+ }
281+
282+ return nil
283+}
284+
285+func parseManifestReferences(data []byte) (manifestReferences, error) {
286+ var manifest struct {
287+ Config struct {
288+ Digest string `json:"digest"`
289+ } `json:"config"`
290+ Layers []struct {
291+ Digest string `json:"digest"`
292+ } `json:"layers"`
293+ Manifests []struct {
294+ Digest string `json:"digest"`
295+ } `json:"manifests"`
296+ }
297+
298+ if err := json.Unmarshal(data, &manifest); err != nil {
299+ return manifestReferences{}, err
300+ }
301+
302+ refs := manifestReferences{
303+ blobs: make([]string, 0, len(manifest.Layers)+1),
304+ childManifests: make([]string, 0, len(manifest.Manifests)),
305+ }
306+
307+ if manifest.Config.Digest != "" {
308+ refs.blobs = append(refs.blobs, manifest.Config.Digest)
309+ }
310+
311+ for _, layer := range manifest.Layers {
312+ if layer.Digest == "" {
313+ continue
314+ }
315+ refs.blobs = append(refs.blobs, layer.Digest)
316+ }
317+
318+ for _, child := range manifest.Manifests {
319+ if child.Digest == "" {
320+ continue
321+ }
322+ refs.childManifests = append(refs.childManifests, child.Digest)
323+ }
324+
325+ return refs, nil
326+}
327+
105 func isDanglingImage(repoTags []string) bool {328 func isDanglingImage(repoTags []string) bool {
106 if len(repoTags) == 0 {329 if len(repoTags) == 0 {
107 return true330 return true
M internal/usecase/images/service_test.go +406 -0
@@ -2,7 +2,9 @@ package images
22
33 import (
44 "context"
5+ "encoding/json"
56 "errors"
7+ "sort"
68 "testing"
79 "time"
810
@@ -180,6 +182,272 @@ func TestService_PruneRuntime_ReturnsErrorWhenRuntimeFails(t *testing.T) {
180182 assert.True(t, rt.pruneDanglingOnly)
181183 }
182184
185+func TestService_PruneRegistry_KeepsLastNTags(t *testing.T) {
186+ manifestStorage := newFakeManifestStorage()
187+ manifestStorage.repositories = []string{"gordon/api"}
188+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v3", "v2", "v1"}
189+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
190+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
191+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
192+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC)
193+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest")
194+ manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-v3")
195+
196+ blobStorage := &fakeBlobStorage{}
197+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
198+
199+ report, err := svc.PruneRegistry(context.Background(), 2)
200+
201+ require.NoError(t, err)
202+ assert.Equal(t, 2, report.Registry.TagsRemoved)
203+ assert.ElementsMatch(t, []manifestRef{
204+ {name: "gordon/api", reference: "v2"},
205+ {name: "gordon/api", reference: "v1"},
206+ }, manifestStorage.deletedManifests)
207+}
208+
209+func TestService_PruneRegistry_AlwaysKeepsLatestTag(t *testing.T) {
210+ manifestStorage := newFakeManifestStorage()
211+ manifestStorage.repositories = []string{"gordon/api"}
212+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v3", "v2"}
213+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 8, 0, 0, 0, time.UTC)
214+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
215+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
216+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared")
217+ manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-shared")
218+
219+ blobStorage := &fakeBlobStorage{}
220+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
221+
222+ report, err := svc.PruneRegistry(context.Background(), 1)
223+
224+ require.NoError(t, err)
225+ assert.Equal(t, 1, report.Registry.TagsRemoved)
226+ assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v2"}}, manifestStorage.deletedManifests)
227+}
228+
229+func TestService_PruneRegistry_SkipsWhenFewerThanKeepLast(t *testing.T) {
230+ manifestStorage := newFakeManifestStorage()
231+ manifestStorage.repositories = []string{"gordon/api"}
232+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
233+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
234+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
235+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest")
236+ manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-v1")
237+
238+ blobStorage := &fakeBlobStorage{}
239+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
240+
241+ report, err := svc.PruneRegistry(context.Background(), 5)
242+
243+ require.NoError(t, err)
244+ assert.Equal(t, 0, report.Registry.TagsRemoved)
245+ assert.Empty(t, manifestStorage.deletedManifests)
246+}
247+
248+func TestService_PruneRegistry_KeepLastZeroSkipsRegistryCleanup(t *testing.T) {
249+ manifestStorage := newFakeManifestStorage()
250+ manifestStorage.repositories = []string{"gordon/api"}
251+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:orphan"}}
252+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
253+
254+ report, err := svc.PruneRegistry(context.Background(), 0)
255+
256+ require.NoError(t, err)
257+ assert.Equal(t, domain.RegistryPruneResult{}, report.Registry)
258+ assert.Equal(t, 0, manifestStorage.listRepositoriesCalls)
259+ assert.Empty(t, manifestStorage.deletedManifests)
260+ assert.Empty(t, blobStorage.deletedBlobs)
261+}
262+
263+func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) {
264+ manifestStorage := newFakeManifestStorage()
265+ manifestStorage.repositories = []string{"gordon/api"}
266+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
267+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
268+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
269+
270+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
271+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
272+
273+ report, err := svc.PruneRegistry(context.Background(), 1)
274+
275+ require.NoError(t, err)
276+ assert.Equal(t, 1, report.Registry.BlobsRemoved)
277+ assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
278+}
279+
280+func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) {
281+ manifestStorage := newFakeManifestStorage()
282+ manifestStorage.repositories = []string{"gordon/api"}
283+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
284+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
285+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
286+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared")
287+ manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-shared")
288+
289+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-latest", "sha256:layer-shared", "sha256:cfg-v1"}}
290+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
291+
292+ report, err := svc.PruneRegistry(context.Background(), 1)
293+
294+ require.NoError(t, err)
295+ assert.Equal(t, 1, report.Registry.BlobsRemoved)
296+ assert.Equal(t, []string{"sha256:cfg-v1"}, blobStorage.deletedBlobs)
297+}
298+
299+func TestService_PruneRegistry_PreservesManifestListChildDigests(t *testing.T) {
300+ manifestStorage := newFakeManifestStorage()
301+ manifestStorage.repositories = []string{"gordon/api"}
302+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
303+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
304+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-amd64", "sha256:child-arm64")
305+ manifestStorage.manifests[manifestRefKey("gordon/api", "sha256:child-amd64")] = mustManifestJSON(t, "sha256:cfg-amd64", "sha256:layer-amd64")
306+ manifestStorage.manifests[manifestRefKey("gordon/api", "sha256:child-arm64")] = mustManifestJSON(t, "sha256:cfg-arm64", "sha256:layer-arm64")
307+
308+ blobStorage := &fakeBlobStorage{blobs: []string{
309+ "sha256:child-amd64",
310+ "sha256:child-arm64",
311+ "sha256:cfg-amd64",
312+ "sha256:layer-amd64",
313+ "sha256:cfg-arm64",
314+ "sha256:layer-arm64",
315+ "sha256:orphan",
316+ }}
317+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
318+
319+ report, err := svc.PruneRegistry(context.Background(), 1)
320+
321+ require.NoError(t, err)
322+ assert.Equal(t, 1, report.Registry.BlobsRemoved)
323+ assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
324+}
325+
326+func TestService_PruneRegistry_TieBreaksByTagName(t *testing.T) {
327+ manifestStorage := newFakeManifestStorage()
328+ manifestStorage.repositories = []string{"gordon/api"}
329+ manifestStorage.tagsByRepo["gordon/api"] = []string{"v1", "v2"}
330+ tie := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
331+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = tie
332+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = tie
333+ manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
334+
335+ blobStorage := &fakeBlobStorage{}
336+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
337+
338+ report, err := svc.PruneRegistry(context.Background(), 1)
339+
340+ require.NoError(t, err)
341+ assert.Equal(t, 1, report.Registry.TagsRemoved)
342+ assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
343+}
344+
345+func TestService_PruneRegistry_ReturnsErrorWhenChildManifestMissing(t *testing.T) {
346+ manifestStorage := newFakeManifestStorage()
347+ manifestStorage.repositories = []string{"gordon/api"}
348+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
349+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
350+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-missing")
351+
352+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:child-missing", "sha256:orphan"}}
353+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
354+
355+ _, err := svc.PruneRegistry(context.Background(), 1)
356+
357+ require.Error(t, err)
358+ assert.Contains(t, err.Error(), "failed to read manifest")
359+}
360+
361+func TestService_PruneRegistry_MultipleRepositories(t *testing.T) {
362+ manifestStorage := newFakeManifestStorage()
363+ manifestStorage.repositories = []string{"gordon/api", "gordon/web"}
364+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
365+ manifestStorage.tagsByRepo["gordon/web"] = []string{"latest", "v3", "v2"}
366+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
367+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
368+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
369+ manifestStorage.modTimes[manifestRefKey("gordon/web", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
370+ manifestStorage.modTimes[manifestRefKey("gordon/web", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
371+ manifestStorage.modTimes[manifestRefKey("gordon/web", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
372+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-api-latest", "sha256:layer-api-latest")
373+ manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-api-v2", "sha256:layer-api-v2")
374+ manifestStorage.manifests[manifestRefKey("gordon/web", "latest")] = mustManifestJSON(t, "sha256:cfg-web-latest", "sha256:layer-web-latest")
375+ manifestStorage.manifests[manifestRefKey("gordon/web", "v3")] = mustManifestJSON(t, "sha256:cfg-web-v3", "sha256:layer-web-v3")
376+
377+ blobStorage := &fakeBlobStorage{}
378+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
379+
380+ report, err := svc.PruneRegistry(context.Background(), 2)
381+
382+ require.NoError(t, err)
383+ assert.Equal(t, 2, report.Registry.TagsRemoved)
384+ assert.ElementsMatch(t, []manifestRef{
385+ {name: "gordon/api", reference: "v1"},
386+ {name: "gordon/web", reference: "v2"},
387+ }, manifestStorage.deletedManifests)
388+}
389+
390+func TestService_PruneRegistry_EmptyRepository(t *testing.T) {
391+ manifestStorage := newFakeManifestStorage()
392+ manifestStorage.repositories = []string{"gordon/api"}
393+ manifestStorage.tagsByRepo["gordon/api"] = []string{}
394+ blobStorage := &fakeBlobStorage{}
395+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
396+
397+ report, err := svc.PruneRegistry(context.Background(), 2)
398+
399+ require.NoError(t, err)
400+ assert.Equal(t, domain.RegistryPruneResult{}, report.Registry)
401+ assert.Empty(t, manifestStorage.deletedManifests)
402+ assert.Empty(t, blobStorage.deletedBlobs)
403+}
404+
405+func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
406+ manifestStorage := newFakeManifestStorage()
407+ manifestStorage.repositories = []string{"gordon/api"}
408+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
409+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
410+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
411+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
412+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
413+ rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}}
414+
415+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
416+
417+ report, err := svc.Prune(context.Background(), 1)
418+
419+ require.NoError(t, err)
420+ assert.Equal(t, 1, report.Runtime.DeletedCount)
421+ assert.Equal(t, int64(1024), report.Runtime.SpaceReclaimed)
422+ assert.Equal(t, 1, report.Registry.TagsRemoved)
423+ assert.Equal(t, 1, report.Registry.BlobsRemoved)
424+ assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
425+ assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
426+}
427+
428+func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) {
429+ manifestStorage := newFakeManifestStorage()
430+ manifestStorage.repositories = []string{"gordon/api"}
431+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
432+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
433+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
434+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
435+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
436+ rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")}
437+
438+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
439+
440+ report, err := svc.Prune(context.Background(), 1)
441+
442+ require.NoError(t, err)
443+ assert.Equal(t, 0, report.Runtime.DeletedCount)
444+ assert.Equal(t, int64(0), report.Runtime.SpaceReclaimed)
445+ assert.Equal(t, 1, report.Registry.TagsRemoved)
446+ assert.Equal(t, 1, report.Registry.BlobsRemoved)
447+ assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
448+ assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
449+}
450+
183451 type fakeRuntime struct {
184452 pkgruntime.Runtime
185453
@@ -200,6 +468,30 @@ type noopBlobStorage struct {
200468 out.BlobStorage
201469 }
202470
471+type manifestRef struct {
472+ name string
473+ reference string
474+}
475+
476+type fakeManifestStorage struct {
477+ out.ManifestStorage
478+
479+ repositories []string
480+ tagsByRepo map[string][]string
481+ modTimes map[string]time.Time
482+ manifests map[string][]byte
483+
484+ listRepositoriesCalls int
485+ deletedManifests []manifestRef
486+}
487+
488+type fakeBlobStorage struct {
489+ out.BlobStorage
490+
491+ blobs []string
492+ deletedBlobs []string
493+}
494+
203495 func (f *fakeRuntime) ListImagesDetailed(context.Context) ([]pkgruntime.ImageDetail, error) {
204496 return f.listDetails, f.listErr
205497 }
@@ -210,4 +502,118 @@ func (f *fakeRuntime) PruneImages(_ context.Context, danglingOnly bool) (pkgrunt
210502 return f.pruneReport, f.pruneErr
211503 }
212504
505+func newFakeManifestStorage() *fakeManifestStorage {
506+ return &fakeManifestStorage{
507+ tagsByRepo: make(map[string][]string),
508+ modTimes: make(map[string]time.Time),
509+ manifests: make(map[string][]byte),
510+ }
511+}
512+
513+func manifestRefKey(name, reference string) string {
514+ return name + "@" + reference
515+}
516+
517+func mustManifestJSON(t *testing.T, configDigest string, layerDigests ...string) []byte {
518+ t.Helper()
519+
520+ type descriptor struct {
521+ Digest string `json:"digest"`
522+ }
523+
524+ payload := struct {
525+ Config descriptor `json:"config"`
526+ Layers []descriptor `json:"layers"`
527+ }{
528+ Config: descriptor{Digest: configDigest},
529+ Layers: make([]descriptor, 0, len(layerDigests)),
530+ }
531+
532+ for _, digest := range layerDigests {
533+ payload.Layers = append(payload.Layers, descriptor{Digest: digest})
534+ }
535+
536+ data, err := json.Marshal(payload)
537+ require.NoError(t, err)
538+ return data
539+}
540+
541+func mustManifestIndexJSON(t *testing.T, digests ...string) []byte {
542+ t.Helper()
543+
544+ type descriptor struct {
545+ Digest string `json:"digest"`
546+ }
547+
548+ payload := struct {
549+ Manifests []descriptor `json:"manifests"`
550+ }{
551+ Manifests: make([]descriptor, 0, len(digests)),
552+ }
553+
554+ for _, digest := range digests {
555+ payload.Manifests = append(payload.Manifests, descriptor{Digest: digest})
556+ }
557+
558+ data, err := json.Marshal(payload)
559+ require.NoError(t, err)
560+ return data
561+}
562+
563+func (f *fakeManifestStorage) ListRepositories() ([]string, error) {
564+ f.listRepositoriesCalls++
565+ return append([]string(nil), f.repositories...), nil
566+}
567+
568+func (f *fakeManifestStorage) ListTags(name string) ([]string, error) {
569+ return append([]string(nil), f.tagsByRepo[name]...), nil
570+}
571+
572+func (f *fakeManifestStorage) GetManifestModTime(name, reference string) (time.Time, error) {
573+ modTime, ok := f.modTimes[manifestRefKey(name, reference)]
574+ if !ok {
575+ return time.Time{}, errors.New("manifest modtime not found")
576+ }
577+ return modTime, nil
578+}
579+
580+func (f *fakeManifestStorage) DeleteManifest(name, reference string) error {
581+ f.deletedManifests = append(f.deletedManifests, manifestRef{name: name, reference: reference})
582+ tags := f.tagsByRepo[name]
583+ filtered := tags[:0]
584+ for _, tag := range tags {
585+ if tag != reference {
586+ filtered = append(filtered, tag)
587+ }
588+ }
589+ f.tagsByRepo[name] = filtered
590+ delete(f.manifests, manifestRefKey(name, reference))
591+ return nil
592+}
593+
594+func (f *fakeManifestStorage) GetManifest(name, reference string) ([]byte, string, error) {
595+ manifest, ok := f.manifests[manifestRefKey(name, reference)]
596+ if !ok {
597+ return nil, "", errors.New("manifest not found")
598+ }
599+ return manifest, "application/vnd.oci.image.manifest.v1+json", nil
600+}
601+
602+func (f *fakeBlobStorage) ListBlobs() ([]string, error) {
603+ return append([]string(nil), f.blobs...), nil
604+}
605+
606+func (f *fakeBlobStorage) DeleteBlob(digest string) error {
607+ f.deletedBlobs = append(f.deletedBlobs, digest)
608+ for i, existing := range f.blobs {
609+ if existing != digest {
610+ continue
611+ }
612+ f.blobs = append(f.blobs[:i], f.blobs[i+1:]...)
613+ break
614+ }
615+ sort.Strings(f.deletedBlobs)
616+ return nil
617+}
618+
213619 var _ pkgruntime.Runtime = (*fakeRuntime)(nil)
@@ -2,7 +2,9 @@ package images
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "errors"6 "errors"
7+ "sort"
6 "testing"8 "testing"
7 "time"9 "time"
8 10
@@ -180,6 +182,272 @@ func TestService_PruneRuntime_ReturnsErrorWhenRuntimeFails(t *testing.T) {
180 assert.True(t, rt.pruneDanglingOnly)182 assert.True(t, rt.pruneDanglingOnly)
181 }183 }
182 184
185+func TestService_PruneRegistry_KeepsLastNTags(t *testing.T) {
186+ manifestStorage := newFakeManifestStorage()
187+ manifestStorage.repositories = []string{"gordon/api"}
188+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v3", "v2", "v1"}
189+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
190+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
191+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
192+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC)
193+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest")
194+ manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-v3")
195+
196+ blobStorage := &fakeBlobStorage{}
197+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
198+
199+ report, err := svc.PruneRegistry(context.Background(), 2)
200+
201+ require.NoError(t, err)
202+ assert.Equal(t, 2, report.Registry.TagsRemoved)
203+ assert.ElementsMatch(t, []manifestRef{
204+ {name: "gordon/api", reference: "v2"},
205+ {name: "gordon/api", reference: "v1"},
206+ }, manifestStorage.deletedManifests)
207+}
208+
209+func TestService_PruneRegistry_AlwaysKeepsLatestTag(t *testing.T) {
210+ manifestStorage := newFakeManifestStorage()
211+ manifestStorage.repositories = []string{"gordon/api"}
212+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v3", "v2"}
213+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 8, 0, 0, 0, time.UTC)
214+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
215+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
216+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared")
217+ manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-shared")
218+
219+ blobStorage := &fakeBlobStorage{}
220+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
221+
222+ report, err := svc.PruneRegistry(context.Background(), 1)
223+
224+ require.NoError(t, err)
225+ assert.Equal(t, 1, report.Registry.TagsRemoved)
226+ assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v2"}}, manifestStorage.deletedManifests)
227+}
228+
229+func TestService_PruneRegistry_SkipsWhenFewerThanKeepLast(t *testing.T) {
230+ manifestStorage := newFakeManifestStorage()
231+ manifestStorage.repositories = []string{"gordon/api"}
232+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
233+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
234+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
235+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest")
236+ manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-v1")
237+
238+ blobStorage := &fakeBlobStorage{}
239+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
240+
241+ report, err := svc.PruneRegistry(context.Background(), 5)
242+
243+ require.NoError(t, err)
244+ assert.Equal(t, 0, report.Registry.TagsRemoved)
245+ assert.Empty(t, manifestStorage.deletedManifests)
246+}
247+
248+func TestService_PruneRegistry_KeepLastZeroSkipsRegistryCleanup(t *testing.T) {
249+ manifestStorage := newFakeManifestStorage()
250+ manifestStorage.repositories = []string{"gordon/api"}
251+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:orphan"}}
252+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
253+
254+ report, err := svc.PruneRegistry(context.Background(), 0)
255+
256+ require.NoError(t, err)
257+ assert.Equal(t, domain.RegistryPruneResult{}, report.Registry)
258+ assert.Equal(t, 0, manifestStorage.listRepositoriesCalls)
259+ assert.Empty(t, manifestStorage.deletedManifests)
260+ assert.Empty(t, blobStorage.deletedBlobs)
261+}
262+
263+func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) {
264+ manifestStorage := newFakeManifestStorage()
265+ manifestStorage.repositories = []string{"gordon/api"}
266+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
267+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
268+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
269+
270+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
271+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
272+
273+ report, err := svc.PruneRegistry(context.Background(), 1)
274+
275+ require.NoError(t, err)
276+ assert.Equal(t, 1, report.Registry.BlobsRemoved)
277+ assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
278+}
279+
280+func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) {
281+ manifestStorage := newFakeManifestStorage()
282+ manifestStorage.repositories = []string{"gordon/api"}
283+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
284+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
285+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
286+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared")
287+ manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-shared")
288+
289+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-latest", "sha256:layer-shared", "sha256:cfg-v1"}}
290+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
291+
292+ report, err := svc.PruneRegistry(context.Background(), 1)
293+
294+ require.NoError(t, err)
295+ assert.Equal(t, 1, report.Registry.BlobsRemoved)
296+ assert.Equal(t, []string{"sha256:cfg-v1"}, blobStorage.deletedBlobs)
297+}
298+
299+func TestService_PruneRegistry_PreservesManifestListChildDigests(t *testing.T) {
300+ manifestStorage := newFakeManifestStorage()
301+ manifestStorage.repositories = []string{"gordon/api"}
302+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
303+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
304+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-amd64", "sha256:child-arm64")
305+ manifestStorage.manifests[manifestRefKey("gordon/api", "sha256:child-amd64")] = mustManifestJSON(t, "sha256:cfg-amd64", "sha256:layer-amd64")
306+ manifestStorage.manifests[manifestRefKey("gordon/api", "sha256:child-arm64")] = mustManifestJSON(t, "sha256:cfg-arm64", "sha256:layer-arm64")
307+
308+ blobStorage := &fakeBlobStorage{blobs: []string{
309+ "sha256:child-amd64",
310+ "sha256:child-arm64",
311+ "sha256:cfg-amd64",
312+ "sha256:layer-amd64",
313+ "sha256:cfg-arm64",
314+ "sha256:layer-arm64",
315+ "sha256:orphan",
316+ }}
317+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
318+
319+ report, err := svc.PruneRegistry(context.Background(), 1)
320+
321+ require.NoError(t, err)
322+ assert.Equal(t, 1, report.Registry.BlobsRemoved)
323+ assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
324+}
325+
326+func TestService_PruneRegistry_TieBreaksByTagName(t *testing.T) {
327+ manifestStorage := newFakeManifestStorage()
328+ manifestStorage.repositories = []string{"gordon/api"}
329+ manifestStorage.tagsByRepo["gordon/api"] = []string{"v1", "v2"}
330+ tie := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
331+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = tie
332+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = tie
333+ manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
334+
335+ blobStorage := &fakeBlobStorage{}
336+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
337+
338+ report, err := svc.PruneRegistry(context.Background(), 1)
339+
340+ require.NoError(t, err)
341+ assert.Equal(t, 1, report.Registry.TagsRemoved)
342+ assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
343+}
344+
345+func TestService_PruneRegistry_ReturnsErrorWhenChildManifestMissing(t *testing.T) {
346+ manifestStorage := newFakeManifestStorage()
347+ manifestStorage.repositories = []string{"gordon/api"}
348+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
349+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
350+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-missing")
351+
352+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:child-missing", "sha256:orphan"}}
353+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
354+
355+ _, err := svc.PruneRegistry(context.Background(), 1)
356+
357+ require.Error(t, err)
358+ assert.Contains(t, err.Error(), "failed to read manifest")
359+}
360+
361+func TestService_PruneRegistry_MultipleRepositories(t *testing.T) {
362+ manifestStorage := newFakeManifestStorage()
363+ manifestStorage.repositories = []string{"gordon/api", "gordon/web"}
364+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
365+ manifestStorage.tagsByRepo["gordon/web"] = []string{"latest", "v3", "v2"}
366+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
367+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
368+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
369+ manifestStorage.modTimes[manifestRefKey("gordon/web", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
370+ manifestStorage.modTimes[manifestRefKey("gordon/web", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
371+ manifestStorage.modTimes[manifestRefKey("gordon/web", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
372+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-api-latest", "sha256:layer-api-latest")
373+ manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-api-v2", "sha256:layer-api-v2")
374+ manifestStorage.manifests[manifestRefKey("gordon/web", "latest")] = mustManifestJSON(t, "sha256:cfg-web-latest", "sha256:layer-web-latest")
375+ manifestStorage.manifests[manifestRefKey("gordon/web", "v3")] = mustManifestJSON(t, "sha256:cfg-web-v3", "sha256:layer-web-v3")
376+
377+ blobStorage := &fakeBlobStorage{}
378+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
379+
380+ report, err := svc.PruneRegistry(context.Background(), 2)
381+
382+ require.NoError(t, err)
383+ assert.Equal(t, 2, report.Registry.TagsRemoved)
384+ assert.ElementsMatch(t, []manifestRef{
385+ {name: "gordon/api", reference: "v1"},
386+ {name: "gordon/web", reference: "v2"},
387+ }, manifestStorage.deletedManifests)
388+}
389+
390+func TestService_PruneRegistry_EmptyRepository(t *testing.T) {
391+ manifestStorage := newFakeManifestStorage()
392+ manifestStorage.repositories = []string{"gordon/api"}
393+ manifestStorage.tagsByRepo["gordon/api"] = []string{}
394+ blobStorage := &fakeBlobStorage{}
395+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
396+
397+ report, err := svc.PruneRegistry(context.Background(), 2)
398+
399+ require.NoError(t, err)
400+ assert.Equal(t, domain.RegistryPruneResult{}, report.Registry)
401+ assert.Empty(t, manifestStorage.deletedManifests)
402+ assert.Empty(t, blobStorage.deletedBlobs)
403+}
404+
405+func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
406+ manifestStorage := newFakeManifestStorage()
407+ manifestStorage.repositories = []string{"gordon/api"}
408+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
409+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
410+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
411+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
412+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
413+ rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}}
414+
415+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
416+
417+ report, err := svc.Prune(context.Background(), 1)
418+
419+ require.NoError(t, err)
420+ assert.Equal(t, 1, report.Runtime.DeletedCount)
421+ assert.Equal(t, int64(1024), report.Runtime.SpaceReclaimed)
422+ assert.Equal(t, 1, report.Registry.TagsRemoved)
423+ assert.Equal(t, 1, report.Registry.BlobsRemoved)
424+ assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
425+ assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
426+}
427+
428+func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) {
429+ manifestStorage := newFakeManifestStorage()
430+ manifestStorage.repositories = []string{"gordon/api"}
431+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
432+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
433+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
434+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
435+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
436+ rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")}
437+
438+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
439+
440+ report, err := svc.Prune(context.Background(), 1)
441+
442+ require.NoError(t, err)
443+ assert.Equal(t, 0, report.Runtime.DeletedCount)
444+ assert.Equal(t, int64(0), report.Runtime.SpaceReclaimed)
445+ assert.Equal(t, 1, report.Registry.TagsRemoved)
446+ assert.Equal(t, 1, report.Registry.BlobsRemoved)
447+ assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
448+ assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
449+}
450+
183 type fakeRuntime struct {451 type fakeRuntime struct {
184 pkgruntime.Runtime452 pkgruntime.Runtime
185 453
@@ -200,6 +468,30 @@ type noopBlobStorage struct {
200 out.BlobStorage468 out.BlobStorage
201 }469 }
202 470
471+type manifestRef struct {
472+ name string
473+ reference string
474+}
475+
476+type fakeManifestStorage struct {
477+ out.ManifestStorage
478+
479+ repositories []string
480+ tagsByRepo map[string][]string
481+ modTimes map[string]time.Time
482+ manifests map[string][]byte
483+
484+ listRepositoriesCalls int
485+ deletedManifests []manifestRef
486+}
487+
488+type fakeBlobStorage struct {
489+ out.BlobStorage
490+
491+ blobs []string
492+ deletedBlobs []string
493+}
494+
203 func (f *fakeRuntime) ListImagesDetailed(context.Context) ([]pkgruntime.ImageDetail, error) {495 func (f *fakeRuntime) ListImagesDetailed(context.Context) ([]pkgruntime.ImageDetail, error) {
204 return f.listDetails, f.listErr496 return f.listDetails, f.listErr
205 }497 }
@@ -210,4 +502,118 @@ func (f *fakeRuntime) PruneImages(_ context.Context, danglingOnly bool) (pkgrunt
210 return f.pruneReport, f.pruneErr502 return f.pruneReport, f.pruneErr
211 }503 }
212 504
505+func newFakeManifestStorage() *fakeManifestStorage {
506+ return &fakeManifestStorage{
507+ tagsByRepo: make(map[string][]string),
508+ modTimes: make(map[string]time.Time),
509+ manifests: make(map[string][]byte),
510+ }
511+}
512+
513+func manifestRefKey(name, reference string) string {
514+ return name + "@" + reference
515+}
516+
517+func mustManifestJSON(t *testing.T, configDigest string, layerDigests ...string) []byte {
518+ t.Helper()
519+
520+ type descriptor struct {
521+ Digest string `json:"digest"`
522+ }
523+
524+ payload := struct {
525+ Config descriptor `json:"config"`
526+ Layers []descriptor `json:"layers"`
527+ }{
528+ Config: descriptor{Digest: configDigest},
529+ Layers: make([]descriptor, 0, len(layerDigests)),
530+ }
531+
532+ for _, digest := range layerDigests {
533+ payload.Layers = append(payload.Layers, descriptor{Digest: digest})
534+ }
535+
536+ data, err := json.Marshal(payload)
537+ require.NoError(t, err)
538+ return data
539+}
540+
541+func mustManifestIndexJSON(t *testing.T, digests ...string) []byte {
542+ t.Helper()
543+
544+ type descriptor struct {
545+ Digest string `json:"digest"`
546+ }
547+
548+ payload := struct {
549+ Manifests []descriptor `json:"manifests"`
550+ }{
551+ Manifests: make([]descriptor, 0, len(digests)),
552+ }
553+
554+ for _, digest := range digests {
555+ payload.Manifests = append(payload.Manifests, descriptor{Digest: digest})
556+ }
557+
558+ data, err := json.Marshal(payload)
559+ require.NoError(t, err)
560+ return data
561+}
562+
563+func (f *fakeManifestStorage) ListRepositories() ([]string, error) {
564+ f.listRepositoriesCalls++
565+ return append([]string(nil), f.repositories...), nil
566+}
567+
568+func (f *fakeManifestStorage) ListTags(name string) ([]string, error) {
569+ return append([]string(nil), f.tagsByRepo[name]...), nil
570+}
571+
572+func (f *fakeManifestStorage) GetManifestModTime(name, reference string) (time.Time, error) {
573+ modTime, ok := f.modTimes[manifestRefKey(name, reference)]
574+ if !ok {
575+ return time.Time{}, errors.New("manifest modtime not found")
576+ }
577+ return modTime, nil
578+}
579+
580+func (f *fakeManifestStorage) DeleteManifest(name, reference string) error {
581+ f.deletedManifests = append(f.deletedManifests, manifestRef{name: name, reference: reference})
582+ tags := f.tagsByRepo[name]
583+ filtered := tags[:0]
584+ for _, tag := range tags {
585+ if tag != reference {
586+ filtered = append(filtered, tag)
587+ }
588+ }
589+ f.tagsByRepo[name] = filtered
590+ delete(f.manifests, manifestRefKey(name, reference))
591+ return nil
592+}
593+
594+func (f *fakeManifestStorage) GetManifest(name, reference string) ([]byte, string, error) {
595+ manifest, ok := f.manifests[manifestRefKey(name, reference)]
596+ if !ok {
597+ return nil, "", errors.New("manifest not found")
598+ }
599+ return manifest, "application/vnd.oci.image.manifest.v1+json", nil
600+}
601+
602+func (f *fakeBlobStorage) ListBlobs() ([]string, error) {
603+ return append([]string(nil), f.blobs...), nil
604+}
605+
606+func (f *fakeBlobStorage) DeleteBlob(digest string) error {
607+ f.deletedBlobs = append(f.deletedBlobs, digest)
608+ for i, existing := range f.blobs {
609+ if existing != digest {
610+ continue
611+ }
612+ f.blobs = append(f.blobs[:i], f.blobs[i+1:]...)
613+ break
614+ }
615+ sort.Strings(f.deletedBlobs)
616+ return nil
617+}
618+
213 var _ pkgruntime.Runtime = (*fakeRuntime)(nil)619 var _ pkgruntime.Runtime = (*fakeRuntime)(nil)

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