detached aa1b244

feat(images): add runtime image prune and detailed docker image listing

commit
aa1b2441c7e75595e7637d0df707597a91a0d678
author
brice · · 2026-02-08 06:01 UTC
committer
brice · · 2026-02-08 09:40 UTC
parent
5f60208
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +288 -0
M internal/adapters/out/docker/runtime.go +71 -0
@@ -10,6 +10,7 @@ import (
1010 "errors"
1111 "fmt"
1212 "io"
13+ "math"
1314 "path/filepath"
1415 "sort"
1516 "strconv"
@@ -20,6 +21,7 @@ import (
2021 "github.com/bnema/zerowrap"
2122 cerrdefs "github.com/containerd/errdefs"
2223 "github.com/docker/docker/api/types/container"
24+ "github.com/docker/docker/api/types/filters"
2325 "github.com/docker/docker/api/types/image"
2426 "github.com/docker/docker/api/types/network"
2527 "github.com/docker/docker/api/types/registry"
@@ -30,6 +32,7 @@ import (
3032
3133 "github.com/bnema/gordon/internal/boundaries/out"
3234 "github.com/bnema/gordon/internal/domain"
35+ runtimepkg "github.com/bnema/gordon/pkg/runtime"
3336 )
3437
3538 // Runtime implements the ContainerRuntime interface using Docker API.
@@ -37,6 +40,8 @@ type Runtime struct {
3740 client *client.Client
3841 }
3942
43+var errSpaceReclaimedOverflow = errors.New("space reclaimed exceeds int64 range")
44+
4045 type pipeReadCloser struct {
4146 pr *io.PipeReader
4247 pw *io.PipeWriter
@@ -555,6 +560,72 @@ func (r *Runtime) ListImages(ctx context.Context) ([]string, error) {
555560 return result, nil
556561 }
557562
563+// ListImagesDetailed lists images with metadata.
564+func (r *Runtime) ListImagesDetailed(ctx context.Context) ([]runtimepkg.ImageDetail, error) {
565+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
566+ zerowrap.FieldLayer: "adapter",
567+ zerowrap.FieldAdapter: "docker",
568+ zerowrap.FieldAction: "ListImagesDetailed",
569+ })
570+ log := zerowrap.FromCtx(ctx)
571+
572+ images, err := r.client.ImageList(ctx, image.ListOptions{All: true})
573+ if err != nil {
574+ return nil, log.WrapErr(err, "failed to list detailed images")
575+ }
576+
577+ result := make([]runtimepkg.ImageDetail, 0, len(images))
578+ for _, img := range images {
579+ result = append(result, runtimepkg.ImageDetail{
580+ ID: img.ID,
581+ RepoTags: img.RepoTags,
582+ Size: img.Size,
583+ Created: time.Unix(img.Created, 0),
584+ })
585+ }
586+
587+ return result, nil
588+}
589+
590+// PruneImages prunes unused images and reports reclaimed space.
591+func (r *Runtime) PruneImages(ctx context.Context, danglingOnly bool) (runtimepkg.PruneReport, error) {
592+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
593+ zerowrap.FieldLayer: "adapter",
594+ zerowrap.FieldAdapter: "docker",
595+ zerowrap.FieldAction: "PruneImages",
596+ "dangling_only": danglingOnly,
597+ })
598+ log := zerowrap.FromCtx(ctx)
599+
600+ pruneFilters := filters.NewArgs()
601+ pruneFilters.Add("dangling", strconv.FormatBool(danglingOnly))
602+
603+ pruneResult, err := r.client.ImagesPrune(ctx, pruneFilters)
604+ if err != nil {
605+ return runtimepkg.PruneReport{}, log.WrapErr(err, "failed to prune images")
606+ }
607+
608+ deletedIDs := make([]string, 0, len(pruneResult.ImagesDeleted))
609+ for _, deleted := range pruneResult.ImagesDeleted {
610+ if deleted.Deleted != "" {
611+ deletedIDs = append(deletedIDs, deleted.Deleted)
612+ }
613+ if deleted.Untagged != "" {
614+ deletedIDs = append(deletedIDs, deleted.Untagged)
615+ }
616+ }
617+
618+ if pruneResult.SpaceReclaimed > math.MaxInt64 {
619+ err := fmt.Errorf("space reclaimed %d exceeds max int64: %w", pruneResult.SpaceReclaimed, errSpaceReclaimedOverflow)
620+ return runtimepkg.PruneReport{}, log.WrapErr(err, "failed to map prune result")
621+ }
622+
623+ return runtimepkg.PruneReport{
624+ DeletedIDs: deletedIDs,
625+ SpaceReclaimed: int64(pruneResult.SpaceReclaimed),
626+ }, nil
627+}
628+
558629 // Ping checks if Docker is responsive.
559630 func (r *Runtime) Ping(ctx context.Context) error {
560631 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -10,6 +10,7 @@ import (
10 "errors"10 "errors"
11 "fmt"11 "fmt"
12 "io"12 "io"
13+ "math"
13 "path/filepath"14 "path/filepath"
14 "sort"15 "sort"
15 "strconv"16 "strconv"
@@ -20,6 +21,7 @@ import (
20 "github.com/bnema/zerowrap"21 "github.com/bnema/zerowrap"
21 cerrdefs "github.com/containerd/errdefs"22 cerrdefs "github.com/containerd/errdefs"
22 "github.com/docker/docker/api/types/container"23 "github.com/docker/docker/api/types/container"
24+ "github.com/docker/docker/api/types/filters"
23 "github.com/docker/docker/api/types/image"25 "github.com/docker/docker/api/types/image"
24 "github.com/docker/docker/api/types/network"26 "github.com/docker/docker/api/types/network"
25 "github.com/docker/docker/api/types/registry"27 "github.com/docker/docker/api/types/registry"
@@ -30,6 +32,7 @@ import (
30 32
31 "github.com/bnema/gordon/internal/boundaries/out"33 "github.com/bnema/gordon/internal/boundaries/out"
32 "github.com/bnema/gordon/internal/domain"34 "github.com/bnema/gordon/internal/domain"
35+ runtimepkg "github.com/bnema/gordon/pkg/runtime"
33 )36 )
34 37
35 // Runtime implements the ContainerRuntime interface using Docker API.38 // Runtime implements the ContainerRuntime interface using Docker API.
@@ -37,6 +40,8 @@ type Runtime struct {
37 client *client.Client40 client *client.Client
38 }41 }
39 42
43+var errSpaceReclaimedOverflow = errors.New("space reclaimed exceeds int64 range")
44+
40 type pipeReadCloser struct {45 type pipeReadCloser struct {
41 pr *io.PipeReader46 pr *io.PipeReader
42 pw *io.PipeWriter47 pw *io.PipeWriter
@@ -555,6 +560,72 @@ func (r *Runtime) ListImages(ctx context.Context) ([]string, error) {
555 return result, nil560 return result, nil
556 }561 }
557 562
563+// ListImagesDetailed lists images with metadata.
564+func (r *Runtime) ListImagesDetailed(ctx context.Context) ([]runtimepkg.ImageDetail, error) {
565+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
566+ zerowrap.FieldLayer: "adapter",
567+ zerowrap.FieldAdapter: "docker",
568+ zerowrap.FieldAction: "ListImagesDetailed",
569+ })
570+ log := zerowrap.FromCtx(ctx)
571+
572+ images, err := r.client.ImageList(ctx, image.ListOptions{All: true})
573+ if err != nil {
574+ return nil, log.WrapErr(err, "failed to list detailed images")
575+ }
576+
577+ result := make([]runtimepkg.ImageDetail, 0, len(images))
578+ for _, img := range images {
579+ result = append(result, runtimepkg.ImageDetail{
580+ ID: img.ID,
581+ RepoTags: img.RepoTags,
582+ Size: img.Size,
583+ Created: time.Unix(img.Created, 0),
584+ })
585+ }
586+
587+ return result, nil
588+}
589+
590+// PruneImages prunes unused images and reports reclaimed space.
591+func (r *Runtime) PruneImages(ctx context.Context, danglingOnly bool) (runtimepkg.PruneReport, error) {
592+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
593+ zerowrap.FieldLayer: "adapter",
594+ zerowrap.FieldAdapter: "docker",
595+ zerowrap.FieldAction: "PruneImages",
596+ "dangling_only": danglingOnly,
597+ })
598+ log := zerowrap.FromCtx(ctx)
599+
600+ pruneFilters := filters.NewArgs()
601+ pruneFilters.Add("dangling", strconv.FormatBool(danglingOnly))
602+
603+ pruneResult, err := r.client.ImagesPrune(ctx, pruneFilters)
604+ if err != nil {
605+ return runtimepkg.PruneReport{}, log.WrapErr(err, "failed to prune images")
606+ }
607+
608+ deletedIDs := make([]string, 0, len(pruneResult.ImagesDeleted))
609+ for _, deleted := range pruneResult.ImagesDeleted {
610+ if deleted.Deleted != "" {
611+ deletedIDs = append(deletedIDs, deleted.Deleted)
612+ }
613+ if deleted.Untagged != "" {
614+ deletedIDs = append(deletedIDs, deleted.Untagged)
615+ }
616+ }
617+
618+ if pruneResult.SpaceReclaimed > math.MaxInt64 {
619+ err := fmt.Errorf("space reclaimed %d exceeds max int64: %w", pruneResult.SpaceReclaimed, errSpaceReclaimedOverflow)
620+ return runtimepkg.PruneReport{}, log.WrapErr(err, "failed to map prune result")
621+ }
622+
623+ return runtimepkg.PruneReport{
624+ DeletedIDs: deletedIDs,
625+ SpaceReclaimed: int64(pruneResult.SpaceReclaimed),
626+ }, nil
627+}
628+
558 // Ping checks if Docker is responsive.629 // Ping checks if Docker is responsive.
559 func (r *Runtime) Ping(ctx context.Context) error {630 func (r *Runtime) Ping(ctx context.Context) error {
560 ctx = zerowrap.CtxWithFields(ctx, map[string]any{631 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
A internal/adapters/out/docker/runtime_image_cleanup_test.go +191 -0
@@ -0,0 +1,191 @@
1+package docker
2+
3+import (
4+ "context"
5+ "math"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/docker/docker/api/types/filters"
13+ "github.com/docker/docker/client"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func TestRuntime_PruneImages_DanglingOnlyFilter(t *testing.T) {
19+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+ assert.Equal(t, http.MethodPost, r.Method)
21+ assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
22+
23+ parsedFilters, err := filters.FromJSON(r.URL.Query().Get("filters"))
24+ require.NoError(t, err)
25+ assert.Equal(t, []string{"true"}, parsedFilters.Get("dangling"))
26+
27+ w.Header().Set("Content-Type", "application/json")
28+ _, _ = w.Write([]byte(`{"ImagesDeleted":[{"Deleted":"sha256:abc"},{"Untagged":"example:old"}],"SpaceReclaimed":1234}`))
29+ }))
30+ defer server.Close()
31+
32+ runtime := newRuntimeForHTTPServer(t, server)
33+ report, err := runtime.PruneImages(context.Background(), true)
34+
35+ require.NoError(t, err)
36+ assert.Equal(t, []string{"sha256:abc", "example:old"}, report.DeletedIDs)
37+ assert.EqualValues(t, 1234, report.SpaceReclaimed)
38+}
39+
40+func TestRuntime_PruneImages_FullUnusedFilter(t *testing.T) {
41+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
42+ assert.Equal(t, http.MethodPost, r.Method)
43+ assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
44+
45+ parsedFilters, err := filters.FromJSON(r.URL.Query().Get("filters"))
46+ require.NoError(t, err)
47+ assert.Equal(t, []string{"false"}, parsedFilters.Get("dangling"))
48+
49+ w.Header().Set("Content-Type", "application/json")
50+ _, _ = w.Write([]byte(`{"ImagesDeleted":[{"Deleted":"sha256:def"}],"SpaceReclaimed":5678}`))
51+ }))
52+ defer server.Close()
53+
54+ runtime := newRuntimeForHTTPServer(t, server)
55+ report, err := runtime.PruneImages(context.Background(), false)
56+
57+ require.NoError(t, err)
58+ assert.Equal(t, []string{"sha256:def"}, report.DeletedIDs)
59+ assert.EqualValues(t, 5678, report.SpaceReclaimed)
60+}
61+
62+func TestRuntime_PruneImages_ReturnsErrorOnNon2xxResponse(t *testing.T) {
63+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
64+ assert.Equal(t, http.MethodPost, r.Method)
65+ assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
66+
67+ w.WriteHeader(http.StatusInternalServerError)
68+ _, _ = w.Write([]byte(`{"message":"internal error"}`))
69+ }))
70+ defer server.Close()
71+
72+ runtime := newRuntimeForHTTPServer(t, server)
73+ _, err := runtime.PruneImages(context.Background(), true)
74+
75+ require.Error(t, err)
76+}
77+
78+func TestRuntime_PruneImages_ReturnsErrorOnInvalidPayload(t *testing.T) {
79+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
80+ assert.Equal(t, http.MethodPost, r.Method)
81+ assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
82+
83+ w.Header().Set("Content-Type", "application/json")
84+ _, _ = w.Write([]byte(`not-json`))
85+ }))
86+ defer server.Close()
87+
88+ runtime := newRuntimeForHTTPServer(t, server)
89+ _, err := runtime.PruneImages(context.Background(), true)
90+
91+ require.Error(t, err)
92+}
93+
94+func TestRuntime_PruneImages_ReturnsErrorOnSpaceReclaimedOverflow(t *testing.T) {
95+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
96+ assert.Equal(t, http.MethodPost, r.Method)
97+ assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
98+
99+ w.Header().Set("Content-Type", "application/json")
100+ _, _ = w.Write([]byte(`{"ImagesDeleted":[],"SpaceReclaimed":9223372036854775808}`))
101+ }))
102+ defer server.Close()
103+
104+ runtime := newRuntimeForHTTPServer(t, server)
105+ _, err := runtime.PruneImages(context.Background(), true)
106+
107+ require.Error(t, err)
108+ assert.ErrorContains(t, err, "space reclaimed")
109+ assert.ErrorContains(t, err, "int64")
110+}
111+
112+func TestRuntime_ListImagesDetailed_MapsImageSummary(t *testing.T) {
113+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
114+ assert.Equal(t, http.MethodGet, r.Method)
115+ assert.Equal(t, "/v1.41/images/json", r.URL.Path)
116+ assert.Equal(t, "1", r.URL.Query().Get("all"))
117+
118+ w.Header().Set("Content-Type", "application/json")
119+ _, _ = w.Write([]byte(`[{"Id":"sha256:img1","RepoTags":["alpine:latest"],"Size":321,"Created":1700000000}]`))
120+ }))
121+ defer server.Close()
122+
123+ runtime := newRuntimeForHTTPServer(t, server)
124+ images, err := runtime.ListImagesDetailed(context.Background())
125+
126+ require.NoError(t, err)
127+ require.Len(t, images, 1)
128+ assert.Equal(t, "sha256:img1", images[0].ID)
129+ assert.Equal(t, []string{"alpine:latest"}, images[0].RepoTags)
130+ assert.EqualValues(t, 321, images[0].Size)
131+ assert.Equal(t, time.Unix(1700000000, 0), images[0].Created)
132+}
133+
134+func TestRuntime_ListImagesDetailed_ReturnsErrorOnNon2xxResponse(t *testing.T) {
135+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
136+ assert.Equal(t, http.MethodGet, r.Method)
137+ assert.Equal(t, "/v1.41/images/json", r.URL.Path)
138+
139+ w.WriteHeader(http.StatusBadGateway)
140+ _, _ = w.Write([]byte(`{"message":"upstream error"}`))
141+ }))
142+ defer server.Close()
143+
144+ runtime := newRuntimeForHTTPServer(t, server)
145+ _, err := runtime.ListImagesDetailed(context.Background())
146+
147+ require.Error(t, err)
148+}
149+
150+func TestRuntime_ListImagesDetailed_ReturnsErrorOnInvalidPayload(t *testing.T) {
151+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
152+ assert.Equal(t, http.MethodGet, r.Method)
153+ assert.Equal(t, "/v1.41/images/json", r.URL.Path)
154+
155+ w.Header().Set("Content-Type", "application/json")
156+ _, _ = w.Write([]byte(`{"not":`))
157+ }))
158+ defer server.Close()
159+
160+ runtime := newRuntimeForHTTPServer(t, server)
161+ _, err := runtime.ListImagesDetailed(context.Background())
162+
163+ require.Error(t, err)
164+}
165+
166+func TestRuntime_PruneImages_UsesMaxInt64Boundary(t *testing.T) {
167+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
168+ assert.Equal(t, http.MethodPost, r.Method)
169+ assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
170+
171+ w.Header().Set("Content-Type", "application/json")
172+ _, _ = w.Write([]byte(`{"ImagesDeleted":[],"SpaceReclaimed":9223372036854775807}`))
173+ }))
174+ defer server.Close()
175+
176+ runtime := newRuntimeForHTTPServer(t, server)
177+ report, err := runtime.PruneImages(context.Background(), true)
178+
179+ require.NoError(t, err)
180+ assert.EqualValues(t, math.MaxInt64, report.SpaceReclaimed)
181+}
182+
183+func newRuntimeForHTTPServer(t *testing.T, server *httptest.Server) *Runtime {
184+ t.Helper()
185+
186+ host := strings.TrimPrefix(server.URL, "http://")
187+ cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
188+ require.NoError(t, err)
189+
190+ return NewRuntimeWithClient(cli)
191+}
@@ -0,0 +1,191 @@
1+package docker
2+
3+import (
4+ "context"
5+ "math"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/docker/docker/api/types/filters"
13+ "github.com/docker/docker/client"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func TestRuntime_PruneImages_DanglingOnlyFilter(t *testing.T) {
19+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+ assert.Equal(t, http.MethodPost, r.Method)
21+ assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
22+
23+ parsedFilters, err := filters.FromJSON(r.URL.Query().Get("filters"))
24+ require.NoError(t, err)
25+ assert.Equal(t, []string{"true"}, parsedFilters.Get("dangling"))
26+
27+ w.Header().Set("Content-Type", "application/json")
28+ _, _ = w.Write([]byte(`{"ImagesDeleted":[{"Deleted":"sha256:abc"},{"Untagged":"example:old"}],"SpaceReclaimed":1234}`))
29+ }))
30+ defer server.Close()
31+
32+ runtime := newRuntimeForHTTPServer(t, server)
33+ report, err := runtime.PruneImages(context.Background(), true)
34+
35+ require.NoError(t, err)
36+ assert.Equal(t, []string{"sha256:abc", "example:old"}, report.DeletedIDs)
37+ assert.EqualValues(t, 1234, report.SpaceReclaimed)
38+}
39+
40+func TestRuntime_PruneImages_FullUnusedFilter(t *testing.T) {
41+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
42+ assert.Equal(t, http.MethodPost, r.Method)
43+ assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
44+
45+ parsedFilters, err := filters.FromJSON(r.URL.Query().Get("filters"))
46+ require.NoError(t, err)
47+ assert.Equal(t, []string{"false"}, parsedFilters.Get("dangling"))
48+
49+ w.Header().Set("Content-Type", "application/json")
50+ _, _ = w.Write([]byte(`{"ImagesDeleted":[{"Deleted":"sha256:def"}],"SpaceReclaimed":5678}`))
51+ }))
52+ defer server.Close()
53+
54+ runtime := newRuntimeForHTTPServer(t, server)
55+ report, err := runtime.PruneImages(context.Background(), false)
56+
57+ require.NoError(t, err)
58+ assert.Equal(t, []string{"sha256:def"}, report.DeletedIDs)
59+ assert.EqualValues(t, 5678, report.SpaceReclaimed)
60+}
61+
62+func TestRuntime_PruneImages_ReturnsErrorOnNon2xxResponse(t *testing.T) {
63+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
64+ assert.Equal(t, http.MethodPost, r.Method)
65+ assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
66+
67+ w.WriteHeader(http.StatusInternalServerError)
68+ _, _ = w.Write([]byte(`{"message":"internal error"}`))
69+ }))
70+ defer server.Close()
71+
72+ runtime := newRuntimeForHTTPServer(t, server)
73+ _, err := runtime.PruneImages(context.Background(), true)
74+
75+ require.Error(t, err)
76+}
77+
78+func TestRuntime_PruneImages_ReturnsErrorOnInvalidPayload(t *testing.T) {
79+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
80+ assert.Equal(t, http.MethodPost, r.Method)
81+ assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
82+
83+ w.Header().Set("Content-Type", "application/json")
84+ _, _ = w.Write([]byte(`not-json`))
85+ }))
86+ defer server.Close()
87+
88+ runtime := newRuntimeForHTTPServer(t, server)
89+ _, err := runtime.PruneImages(context.Background(), true)
90+
91+ require.Error(t, err)
92+}
93+
94+func TestRuntime_PruneImages_ReturnsErrorOnSpaceReclaimedOverflow(t *testing.T) {
95+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
96+ assert.Equal(t, http.MethodPost, r.Method)
97+ assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
98+
99+ w.Header().Set("Content-Type", "application/json")
100+ _, _ = w.Write([]byte(`{"ImagesDeleted":[],"SpaceReclaimed":9223372036854775808}`))
101+ }))
102+ defer server.Close()
103+
104+ runtime := newRuntimeForHTTPServer(t, server)
105+ _, err := runtime.PruneImages(context.Background(), true)
106+
107+ require.Error(t, err)
108+ assert.ErrorContains(t, err, "space reclaimed")
109+ assert.ErrorContains(t, err, "int64")
110+}
111+
112+func TestRuntime_ListImagesDetailed_MapsImageSummary(t *testing.T) {
113+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
114+ assert.Equal(t, http.MethodGet, r.Method)
115+ assert.Equal(t, "/v1.41/images/json", r.URL.Path)
116+ assert.Equal(t, "1", r.URL.Query().Get("all"))
117+
118+ w.Header().Set("Content-Type", "application/json")
119+ _, _ = w.Write([]byte(`[{"Id":"sha256:img1","RepoTags":["alpine:latest"],"Size":321,"Created":1700000000}]`))
120+ }))
121+ defer server.Close()
122+
123+ runtime := newRuntimeForHTTPServer(t, server)
124+ images, err := runtime.ListImagesDetailed(context.Background())
125+
126+ require.NoError(t, err)
127+ require.Len(t, images, 1)
128+ assert.Equal(t, "sha256:img1", images[0].ID)
129+ assert.Equal(t, []string{"alpine:latest"}, images[0].RepoTags)
130+ assert.EqualValues(t, 321, images[0].Size)
131+ assert.Equal(t, time.Unix(1700000000, 0), images[0].Created)
132+}
133+
134+func TestRuntime_ListImagesDetailed_ReturnsErrorOnNon2xxResponse(t *testing.T) {
135+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
136+ assert.Equal(t, http.MethodGet, r.Method)
137+ assert.Equal(t, "/v1.41/images/json", r.URL.Path)
138+
139+ w.WriteHeader(http.StatusBadGateway)
140+ _, _ = w.Write([]byte(`{"message":"upstream error"}`))
141+ }))
142+ defer server.Close()
143+
144+ runtime := newRuntimeForHTTPServer(t, server)
145+ _, err := runtime.ListImagesDetailed(context.Background())
146+
147+ require.Error(t, err)
148+}
149+
150+func TestRuntime_ListImagesDetailed_ReturnsErrorOnInvalidPayload(t *testing.T) {
151+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
152+ assert.Equal(t, http.MethodGet, r.Method)
153+ assert.Equal(t, "/v1.41/images/json", r.URL.Path)
154+
155+ w.Header().Set("Content-Type", "application/json")
156+ _, _ = w.Write([]byte(`{"not":`))
157+ }))
158+ defer server.Close()
159+
160+ runtime := newRuntimeForHTTPServer(t, server)
161+ _, err := runtime.ListImagesDetailed(context.Background())
162+
163+ require.Error(t, err)
164+}
165+
166+func TestRuntime_PruneImages_UsesMaxInt64Boundary(t *testing.T) {
167+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
168+ assert.Equal(t, http.MethodPost, r.Method)
169+ assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
170+
171+ w.Header().Set("Content-Type", "application/json")
172+ _, _ = w.Write([]byte(`{"ImagesDeleted":[],"SpaceReclaimed":9223372036854775807}`))
173+ }))
174+ defer server.Close()
175+
176+ runtime := newRuntimeForHTTPServer(t, server)
177+ report, err := runtime.PruneImages(context.Background(), true)
178+
179+ require.NoError(t, err)
180+ assert.EqualValues(t, math.MaxInt64, report.SpaceReclaimed)
181+}
182+
183+func newRuntimeForHTTPServer(t *testing.T, server *httptest.Server) *Runtime {
184+ t.Helper()
185+
186+ host := strings.TrimPrefix(server.URL, "http://")
187+ cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
188+ require.NoError(t, err)
189+
190+ return NewRuntimeWithClient(cli)
191+}
M pkg/runtime/interface.go +26 -0
@@ -3,6 +3,7 @@ package runtime
33 import (
44 "context"
55 "io"
6+ "time"
67 )
78
89 // Container represents a running container
@@ -40,6 +41,26 @@ type ContainerConfig struct {
4041 Aliases []string // Additional network aliases
4142 }
4243
44+// PruneReport represents the result of an image prune operation.
45+type PruneReport struct {
46+ // DeletedIDs contains runtime-provided image identifiers removed by prune.
47+ DeletedIDs []string
48+ // SpaceReclaimed is the number of bytes reclaimed by prune.
49+ SpaceReclaimed int64
50+}
51+
52+// ImageDetail represents detailed metadata for an image.
53+type ImageDetail struct {
54+ // ID is the runtime image identifier (for example, image ID/digest).
55+ ID string
56+ // RepoTags contains repository:tag references known for the image.
57+ RepoTags []string
58+ // Size is the image size in bytes.
59+ Size int64
60+ // Created is the image creation timestamp as reported by the runtime.
61+ Created time.Time
62+}
63+
4364 // Runtime interface defines the contract for container runtime implementations
4465 type Runtime interface {
4566 // Container lifecycle
@@ -59,6 +80,11 @@ type Runtime interface {
5980 PullImageWithAuth(ctx context.Context, image, username, password string) error
6081 RemoveImage(ctx context.Context, image string, force bool) error
6182 ListImages(ctx context.Context) ([]string, error)
83+ // ListImagesDetailed returns metadata for all images visible to the runtime.
84+ ListImagesDetailed(ctx context.Context) ([]ImageDetail, error)
85+ // PruneImages removes images eligible for prune; when danglingOnly is true,
86+ // only dangling images are pruned.
87+ PruneImages(ctx context.Context, danglingOnly bool) (PruneReport, error)
6288
6389 // Runtime information
6490 Ping(ctx context.Context) error
@@ -3,6 +3,7 @@ package runtime
3 import (3 import (
4 "context"4 "context"
5 "io"5 "io"
6+ "time"
6 )7 )
7 8
8 // Container represents a running container9 // Container represents a running container
@@ -40,6 +41,26 @@ type ContainerConfig struct {
40 Aliases []string // Additional network aliases41 Aliases []string // Additional network aliases
41 }42 }
42 43
44+// PruneReport represents the result of an image prune operation.
45+type PruneReport struct {
46+ // DeletedIDs contains runtime-provided image identifiers removed by prune.
47+ DeletedIDs []string
48+ // SpaceReclaimed is the number of bytes reclaimed by prune.
49+ SpaceReclaimed int64
50+}
51+
52+// ImageDetail represents detailed metadata for an image.
53+type ImageDetail struct {
54+ // ID is the runtime image identifier (for example, image ID/digest).
55+ ID string
56+ // RepoTags contains repository:tag references known for the image.
57+ RepoTags []string
58+ // Size is the image size in bytes.
59+ Size int64
60+ // Created is the image creation timestamp as reported by the runtime.
61+ Created time.Time
62+}
63+
43 // Runtime interface defines the contract for container runtime implementations64 // Runtime interface defines the contract for container runtime implementations
44 type Runtime interface {65 type Runtime interface {
45 // Container lifecycle66 // Container lifecycle
@@ -59,6 +80,11 @@ type Runtime interface {
59 PullImageWithAuth(ctx context.Context, image, username, password string) error80 PullImageWithAuth(ctx context.Context, image, username, password string) error
60 RemoveImage(ctx context.Context, image string, force bool) error81 RemoveImage(ctx context.Context, image string, force bool) error
61 ListImages(ctx context.Context) ([]string, error)82 ListImages(ctx context.Context) ([]string, error)
83+ // ListImagesDetailed returns metadata for all images visible to the runtime.
84+ ListImagesDetailed(ctx context.Context) ([]ImageDetail, error)
85+ // PruneImages removes images eligible for prune; when danglingOnly is true,
86+ // only dangling images are pruned.
87+ PruneImages(ctx context.Context, danglingOnly bool) (PruneReport, error)
62 88
63 // Runtime information89 // Runtime information
64 Ping(ctx context.Context) error90 Ping(ctx context.Context) 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