1package docker
2
3import (
4 "context"
5 "net/http"
6 "net/http/httptest"
7 "strings"
8 "testing"
9
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12
13 "github.com/bnema/gordon/internal/domain"
14)
15
16func TestRuntime_InventoryRuntime(t *testing.T) {
17 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18 w.Header().Set("Content-Type", "application/json")
19 switch r.URL.Path {
20 case "/v1.41/containers/json":
21 assert.Equal(t, "1", r.URL.Query().Get("all"))
22 _, _ = w.Write([]byte(`[
23 {"Id":"c-running","Names":["/shop-web-1"],"Image":"registry.example/shop:1","ImageID":"sha256:aaa","State":"running",
24 "Mounts":[{"Type":"volume","Name":"gordon-shop--web--vol--data"}]},
25 {"Id":"c-stopped","Names":["/shop-web-2"],"Image":"registry.example/shop:2","ImageID":"sha256:bbb","State":"exited","Mounts":[]},
26 {"Id":"c-opaque","Names":["/foreign"],"Image":"","ImageID":"","State":"exited","Mounts":[]}
27 ]`))
28 case "/v1.41/images/json":
29 assert.Equal(t, "1", r.URL.Query().Get("all"))
30 _, _ = w.Write([]byte(`[
31 {"Id":"sha256:aaa","RepoTags":["registry.example/shop:1","registry.example/shop:latest"],
32 "RepoDigests":["registry.example/shop@sha256:1111"],"Labels":{"gordon.app":"shop"},"Size":10,"Created":1700000000},
33 {"Id":"sha256:bbb","RepoTags":["<none>:<none>"],"RepoDigests":[],"Labels":{},"Size":20,"Created":1700000000}
34 ]`))
35 case "/v1.41/volumes":
36 _, _ = w.Write([]byte(`{"Volumes":[
37 {"Name":"gordon-shop--web--vol--data","Driver":"local","Mountpoint":"/var/lib/docker/volumes/x/_data","CreatedAt":"2026-01-01T00:00:00Z","Labels":{"gordon.managed":"true","gordon.app":"shop"}},
38 {"Name":"pgdata","Driver":"local","Mountpoint":"/var/lib/docker/volumes/pgdata/_data","CreatedAt":"not-a-time","Labels":{}}
39 ]}`))
40 default:
41 t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
42 w.WriteHeader(http.StatusNotFound)
43 }
44 }))
45 defer server.Close()
46
47 rt := newRuntimeForHTTPServer(t, server)
48 inventory, err := rt.InventoryRuntime(context.Background())
49 require.NoError(t, err)
50 require.True(t, inventory.Complete(), "inventory gaps: %+v", inventory.Gaps)
51
52 require.Len(t, inventory.Containers, 3)
53 byContainer := map[string]domain.RuntimeContainerUse{}
54 for _, use := range inventory.Containers {
55 byContainer[use.ContainerID] = use
56 }
57 assert.True(t, byContainer["c-running"].Running)
58 assert.False(t, byContainer["c-stopped"].Running)
59 assert.Equal(t, "sha256:bbb", byContainer["c-stopped"].ImageID)
60 assert.True(t, byContainer["c-opaque"].ImageUnknown, "unresolvable container image must be flagged")
61
62 require.Len(t, inventory.Images, 2)
63 byImage := map[string]domain.RuntimeImage{}
64 for _, image := range inventory.Images {
65 byImage[image.ID] = image
66 }
67 // One image ID may carry several repo tags and repo digests.
68 assert.Equal(t, []string{"registry.example/shop:1", "registry.example/shop:latest"}, byImage["sha256:aaa"].RepoTags)
69 assert.Equal(t, []string{"registry.example/shop@sha256:1111"}, byImage["sha256:aaa"].RepoDigests)
70 assert.Equal(t, "<none>:<none>", byImage["sha256:bbb"].RepoTags[0])
71
72 require.Len(t, inventory.Volumes, 2)
73 byVolume := map[string]*domain.VolumeInfo{}
74 for _, volume := range inventory.Volumes {
75 byVolume[volume.Name] = volume
76 }
77 assert.True(t, byVolume["gordon-shop--web--vol--data"].InUse)
78 assert.Equal(t, []string{"shop-web-1"}, byVolume["gordon-shop--web--vol--data"].Containers)
79 assert.Equal(t, "shop", byVolume["gordon-shop--web--vol--data"].Labels[domain.LabelApp])
80 assert.False(t, byVolume["pgdata"].InUse)
81 assert.Empty(t, byVolume["pgdata"].Labels)
82}
83
84func TestRuntime_InventoryRuntimeReportsGapsNotAbsence(t *testing.T) {
85 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
86 w.Header().Set("Content-Type", "application/json")
87 if r.URL.Path == "/v1.41/images/json" {
88 w.WriteHeader(http.StatusInternalServerError)
89 _, _ = w.Write([]byte(`{"message":"boom"}`))
90 return
91 }
92 switch r.URL.Path {
93 case "/v1.41/containers/json":
94 _, _ = w.Write([]byte(`[]`))
95 case "/v1.41/volumes":
96 _, _ = w.Write([]byte(`{"Volumes":[]}`))
97 }
98 }))
99 defer server.Close()
100
101 rt := newRuntimeForHTTPServer(t, server)
102 inventory, err := rt.InventoryRuntime(context.Background())
103 require.NoError(t, err)
104 require.False(t, inventory.Complete())
105 require.Len(t, inventory.Gaps, 1)
106 assert.Equal(t, domain.InventorySourceRuntimeImages, inventory.Gaps[0].Source)
107 assert.Equal(t, domain.PruneReasonUnknownInventory, inventory.Gaps[0].Reason)
108}
109
110func TestRuntime_InventoryRuntimeContainerListFailureProtectsImages(t *testing.T) {
111 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112 w.Header().Set("Content-Type", "application/json")
113 switch r.URL.Path {
114 case "/v1.41/containers/json":
115 w.WriteHeader(http.StatusInternalServerError)
116 _, _ = w.Write([]byte(`{"message":"boom"}`))
117 case "/v1.41/images/json":
118 _, _ = w.Write([]byte(`[]`))
119 case "/v1.41/volumes":
120 _, _ = w.Write([]byte(`{"Volumes":[]}`))
121 }
122 }))
123 defer server.Close()
124
125 rt := newRuntimeForHTTPServer(t, server)
126 inventory, err := rt.InventoryRuntime(context.Background())
127 require.NoError(t, err)
128 require.False(t, inventory.Complete())
129 require.Len(t, inventory.Gaps, 1)
130 assert.Equal(t, domain.InventorySourceRuntimeContainers, inventory.Gaps[0].Source)
131 assert.Equal(t, domain.PruneReasonUnknownContainerUse, inventory.Gaps[0].Reason)
132}
133
134func TestRuntime_RemoveImageExactUsesExactIdentityWithoutForce(t *testing.T) {
135 var method, path string
136 var force string
137 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
138 method = r.Method
139 path = r.URL.Path
140 force = r.URL.Query().Get("force")
141 w.Header().Set("Content-Type", "application/json")
142 _, _ = w.Write([]byte(`[]`))
143 }))
144 defer server.Close()
145
146 rt := newRuntimeForHTTPServer(t, server)
147 ref, err := domain.NewRuntimeImageRef("sha256:deadbeef")
148 require.NoError(t, err)
149 require.NoError(t, rt.RemoveImageExact(context.Background(), ref))
150
151 assert.Equal(t, http.MethodDelete, method)
152 assert.Equal(t, "/v1.41/images/sha256:deadbeef", path)
153 assert.NotEqual(t, "true", force)
154 assert.NotEqual(t, "1", force)
155}
156
157func TestRuntime_RemoveVolumeExactUsesExactIdentityWithoutForce(t *testing.T) {
158 var method, path string
159 var force string
160 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
161 method = r.Method
162 path = r.URL.Path
163 force = r.URL.Query().Get("force")
164 w.Header().Set("Content-Type", "application/json")
165 _, _ = w.Write([]byte(`{}`))
166 }))
167 defer server.Close()
168
169 rt := newRuntimeForHTTPServer(t, server)
170 ref, err := domain.NewRuntimeVolumeRef("gordon-shop--web--vol--data")
171 require.NoError(t, err)
172 require.NoError(t, rt.RemoveVolumeExact(context.Background(), ref))
173
174 assert.Equal(t, http.MethodDelete, method)
175 assert.Equal(t, "/v1.41/volumes/gordon-shop--web--vol--data", path)
176 assert.NotEqual(t, "true", force)
177 assert.NotEqual(t, "1", force)
178}
179
180func TestRuntime_ExactRemovalRejectsInvalidIdentity(t *testing.T) {
181 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
182 t.Errorf("invalid identity must not reach the runtime: %s %s", r.Method, r.URL.Path)
183 }))
184 defer server.Close()
185
186 rt := newRuntimeForHTTPServer(t, server)
187 err := rt.RemoveImageExact(context.Background(), domain.RuntimeImageRef{})
188 require.Error(t, err)
189 assert.Contains(t, err.Error(), "invalid identity")
190
191 err = rt.RemoveVolumeExact(context.Background(), domain.RuntimeVolumeRef{})
192 require.Error(t, err)
193 assert.Contains(t, err.Error(), "invalid identity")
194
195 err = rt.RemoveImageExact(context.Background(), domain.RuntimeImageRef{ID: "<none>"})
196 require.Error(t, err)
197}
198
199func TestRuntime_ExactRemovalPropagatesRuntimeErrors(t *testing.T) {
200 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
201 w.Header().Set("Content-Type", "application/json")
202 w.WriteHeader(http.StatusConflict)
203 _, _ = w.Write([]byte(`{"message":"volume is in use"}`))
204 }))
205 defer server.Close()
206
207 rt := newRuntimeForHTTPServer(t, server)
208 ref, err := domain.NewRuntimeVolumeRef("in-use")
209 require.NoError(t, err)
210 err = rt.RemoveVolumeExact(context.Background(), ref)
211 require.Error(t, err)
212 assert.True(t, strings.Contains(err.Error(), "remove volume") || strings.Contains(err.Error(), "in use"),
213 "unexpected error: %v", err)
214}