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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 10 | 10 | "errors" | |
| 11 | 11 | "fmt" | |
| 12 | 12 | "io" | |
| 13 | + | "math" | |
| 13 | 14 | "path/filepath" | |
| 14 | 15 | "sort" | |
| 15 | 16 | "strconv" | |
| @@ -20,6 +21,7 @@ import ( | |||
| 20 | 21 | "github.com/bnema/zerowrap" | |
| 21 | 22 | cerrdefs "github.com/containerd/errdefs" | |
| 22 | 23 | "github.com/docker/docker/api/types/container" | |
| 24 | + | "github.com/docker/docker/api/types/filters" | |
| 23 | 25 | "github.com/docker/docker/api/types/image" | |
| 24 | 26 | "github.com/docker/docker/api/types/network" | |
| 25 | 27 | "github.com/docker/docker/api/types/registry" | |
| @@ -30,6 +32,7 @@ import ( | |||
| 30 | 32 | ||
| 31 | 33 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 32 | 34 | "github.com/bnema/gordon/internal/domain" | |
| 35 | + | runtimepkg "github.com/bnema/gordon/pkg/runtime" | |
| 33 | 36 | ) | |
| 34 | 37 | ||
| 35 | 38 | // Runtime implements the ContainerRuntime interface using Docker API. | |
| @@ -37,6 +40,8 @@ type Runtime struct { | |||
| 37 | 40 | client *client.Client | |
| 38 | 41 | } | |
| 39 | 42 | ||
| 43 | + | var errSpaceReclaimedOverflow = errors.New("space reclaimed exceeds int64 range") | |
| 44 | + | ||
| 40 | 45 | type pipeReadCloser struct { | |
| 41 | 46 | pr *io.PipeReader | |
| 42 | 47 | pw *io.PipeWriter | |
| @@ -555,6 +560,72 @@ func (r *Runtime) ListImages(ctx context.Context) ([]string, error) { | |||
| 555 | 560 | 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 | 629 | // Ping checks if Docker is responsive. | |
| 559 | 630 | func (r *Runtime) Ping(ctx context.Context) error { | |
| 560 | 631 | 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.Client | 40 | 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.PipeReader | 46 | pr *io.PipeReader | ||
| 42 | pw *io.PipeWriter | 47 | pw *io.PipeWriter | ||
| @@ -555,6 +560,72 @@ func (r *Runtime) ListImages(ctx context.Context) ([]string, error) { | |||||
| 555 | return result, nil | 560 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "io" | |
| 6 | + | "time" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| 8 | 9 | // Container represents a running container | |
| @@ -40,6 +41,26 @@ type ContainerConfig struct { | |||
| 40 | 41 | 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 | 64 | // Runtime interface defines the contract for container runtime implementations | |
| 44 | 65 | type Runtime interface { | |
| 45 | 66 | // Container lifecycle | |
| @@ -59,6 +80,11 @@ type Runtime interface { | |||
| 59 | 80 | PullImageWithAuth(ctx context.Context, image, username, password string) error | |
| 60 | 81 | RemoveImage(ctx context.Context, image string, force bool) error | |
| 61 | 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 | 89 | // Runtime information | |
| 64 | 90 | 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 container | 9 | // Container represents a running container | ||
| @@ -40,6 +41,26 @@ type ContainerConfig struct { | |||||
| 40 | Aliases []string // Additional network aliases | 41 | 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 implementations | 64 | // Runtime interface defines the contract for container runtime implementations | ||
| 44 | type Runtime interface { | 65 | type Runtime interface { | ||
| 45 | // Container lifecycle | 66 | // Container lifecycle | ||
| @@ -59,6 +80,11 @@ type Runtime interface { | |||||
| 59 | PullImageWithAuth(ctx context.Context, image, username, password string) error | 80 | PullImageWithAuth(ctx context.Context, image, username, password string) error | ||
| 60 | RemoveImage(ctx context.Context, image string, force bool) error | 81 | 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 information | 89 | // Runtime information | ||
| 64 | Ping(ctx context.Context) error | 90 | Ping(ctx context.Context) error | ||