1package docker
2
3import (
4 "context"
5 "fmt"
6 "sort"
7 "strings"
8 "time"
9
10 "github.com/bnema/zerowrap"
11 "github.com/moby/moby/api/types/container"
12 "github.com/moby/moby/api/types/image"
13 "github.com/moby/moby/api/types/volume"
14 "github.com/moby/moby/client"
15
16 "github.com/bnema/gordon/internal/boundaries/out"
17 "github.com/bnema/gordon/internal/domain"
18)
19
20var _ out.PruneRuntime = (*Runtime)(nil)
21
22// InventoryRuntime implements out.PruneRuntime.
23//
24// It reads containers (running and stopped), images, and volumes in one
25// pass. A failed list is recorded as an inventory gap, never as
26// absence: the caller then fails closed per candidate instead of
27// planning against an empty runtime.
28func (r *Runtime) InventoryRuntime(ctx context.Context) (*domain.RuntimeInventory, error) {
29 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
30 zerowrap.FieldLayer: "adapter",
31 zerowrap.FieldAdapter: "docker",
32 zerowrap.FieldAction: "InventoryRuntime",
33 })
34 log := zerowrap.FromCtx(ctx)
35
36 inventory := &domain.RuntimeInventory{}
37
38 containerList, containerErr := r.client.ContainerList(ctx, client.ContainerListOptions{All: true})
39 if containerErr != nil {
40 log.Warn().Err(containerErr).Msg("failed to list containers for prune inventory")
41 inventory.Gaps = append(inventory.Gaps, domain.InventoryGap{
42 Source: domain.InventorySourceRuntimeContainers,
43 Reason: domain.PruneReasonUnknownContainerUse,
44 Detail: "list containers failed",
45 })
46 } else {
47 inventory.Containers = containerImageUses(containerList.Items)
48 }
49
50 imageList, imageErr := r.client.ImageList(ctx, client.ImageListOptions{All: true})
51 if imageErr != nil {
52 log.Warn().Err(imageErr).Msg("failed to list images for prune inventory")
53 inventory.Gaps = append(inventory.Gaps, domain.InventoryGap{
54 Source: domain.InventorySourceRuntimeImages,
55 Reason: domain.PruneReasonUnknownInventory,
56 Detail: "list images failed",
57 })
58 } else {
59 inventory.Images = runtimeImages(imageList.Items)
60 }
61
62 volumeList, volumeErr := r.client.VolumeList(ctx, client.VolumeListOptions{})
63 if volumeErr != nil {
64 log.Warn().Err(volumeErr).Msg("failed to list volumes for prune inventory")
65 inventory.Gaps = append(inventory.Gaps, domain.InventoryGap{
66 Source: domain.InventorySourceRuntimeVolumes,
67 Reason: domain.PruneReasonUnknownInventory,
68 Detail: "list volumes failed",
69 })
70 } else {
71 var mounts []volumeMount
72 if containerErr == nil {
73 mounts = volumeMounts(containerList.Items)
74 }
75 inventory.Volumes = volumeInfos(volumeList.Items, mounts)
76 }
77
78 return inventory, nil
79}
80
81// volumeMount is one volume mount observed on a container.
82type volumeMount struct {
83 volume string
84 container string
85}
86
87func volumeMounts(containers []container.Summary) []volumeMount {
88 var mounts []volumeMount
89 for _, container := range containers {
90 name := ""
91 if len(container.Names) > 0 {
92 name = strings.TrimPrefix(container.Names[0], "/")
93 }
94 if name == "" {
95 name = container.ID
96 }
97 for _, mount := range container.Mounts {
98 if mount.Type != "volume" || mount.Name == "" {
99 continue
100 }
101 mounts = append(mounts, volumeMount{volume: mount.Name, container: name})
102 }
103 }
104 sort.Slice(mounts, func(i, j int) bool {
105 if mounts[i].volume != mounts[j].volume {
106 return mounts[i].volume < mounts[j].volume
107 }
108 return mounts[i].container < mounts[j].container
109 })
110 return mounts
111}
112
113// containerImageUses maps every container, running or not, to the image
114// identity it was created from. A container whose image identity cannot
115// be resolved marks ImageUnknown so no candidate can be proven unused.
116func containerImageUses(containers []container.Summary) []domain.RuntimeContainerUse {
117 uses := make([]domain.RuntimeContainerUse, 0, len(containers))
118 for _, container := range containers {
119 use := domain.RuntimeContainerUse{
120 ContainerID: container.ID,
121 ImageID: container.ImageID,
122 ImageRef: container.Image,
123 Running: container.State == "running",
124 }
125 if use.ImageID == "" && use.ImageRef == "" {
126 use.ImageUnknown = true
127 }
128 uses = append(uses, use)
129 }
130 sort.Slice(uses, func(i, j int) bool { return uses[i].ContainerID < uses[j].ContainerID })
131 return uses
132}
133
134// runtimeImages maps the runtime image list to inventory entries.
135func runtimeImages(images []image.Summary) []domain.RuntimeImage {
136 out := make([]domain.RuntimeImage, 0, len(images))
137 for _, image := range images {
138 out = append(out, domain.RuntimeImage{
139 ID: image.ID,
140 RepoTags: append([]string(nil), image.RepoTags...),
141 RepoDigests: append([]string(nil), image.RepoDigests...),
142 Labels: image.Labels,
143 Size: image.Size,
144 Created: time.Unix(image.Created, 0),
145 })
146 }
147 sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
148 return out
149}
150
151// volumeInfos maps volumes plus observed container mounts to inventory
152// entries.
153func volumeInfos(volumes []volume.Volume, mounts []volumeMount) []*domain.VolumeInfo {
154 users := make(map[string][]string)
155 for _, mount := range mounts {
156 users[mount.volume] = append(users[mount.volume], mount.container)
157 }
158
159 out := make([]*domain.VolumeInfo, 0, len(volumes))
160 for _, volume := range volumes {
161 var size int64
162 if volume.UsageData != nil {
163 size = volume.UsageData.Size
164 }
165 // A volume driver that cannot report creation time yields the
166 // zero time (for example some Podman responses). That is not a
167 // safety input: eligibility comes from ownership, not age.
168 created, _ := time.Parse(time.RFC3339, volume.CreatedAt)
169 containers := users[volume.Name]
170 out = append(out, &domain.VolumeInfo{
171 Name: volume.Name,
172 Driver: volume.Driver,
173 MountPoint: volume.Mountpoint,
174 Size: size,
175 CreatedAt: created,
176 InUse: len(containers) > 0,
177 Containers: containers,
178 Labels: volume.Labels,
179 })
180 }
181 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
182 return out
183}
184
185// RemoveImageExact implements out.PruneRuntime. It removes one exact
186// runtime image ID and never forces: an image that is still referenced
187// fails loudly instead of being untagged behind the caller's back.
188func (r *Runtime) RemoveImageExact(ctx context.Context, ref domain.RuntimeImageRef) error {
189 if !ref.Valid() {
190 return fmt.Errorf("docker: refusing to remove image with invalid identity %q", ref.ID)
191 }
192 return r.RemoveImage(ctx, ref.ID, false)
193}
194
195// RemoveVolumeExact implements out.PruneRuntime. It removes one exact
196// runtime volume name and never forces.
197func (r *Runtime) RemoveVolumeExact(ctx context.Context, ref domain.RuntimeVolumeRef) error {
198 if !ref.Valid() {
199 return fmt.Errorf("docker: refusing to remove volume with invalid identity %q", ref.Name)
200 }
201 return r.RemoveVolume(ctx, ref.Name, false)
202}