Skip to content

Commit 291607b

Browse files
lucarin91Xayton
andauthored
feat(cleanup): remove containers before images
Co-authored-by: Xayton <30591904+Xayton@users.noreply.github.com>
1 parent 0181b45 commit 291607b

File tree

5 files changed

+207
-105
lines changed

5 files changed

+207
-105
lines changed

cmd/arduino-app-cli/system/system.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/arduino/arduino-app-cli/internal/update"
1515
"github.com/arduino/arduino-app-cli/internal/update/apt"
1616
"github.com/arduino/arduino-app-cli/internal/update/arduino"
17+
"github.com/arduino/arduino-app-cli/pkg/x"
1718
)
1819

1920
func NewSystemCmd(cfg config.Configuration) *cobra.Command {
@@ -34,7 +35,7 @@ func newDownloadImage(cfg config.Configuration) *cobra.Command {
3435
Args: cobra.ExactArgs(0),
3536
Hidden: true,
3637
RunE: func(cmd *cobra.Command, _ []string) error {
37-
return orchestrator.SystemInit(cmd.Context(), cfg, servicelocator.GetStaticStore())
38+
return orchestrator.SystemInit(cmd.Context(), cfg, servicelocator.GetStaticStore(), servicelocator.GetDockerClient())
3839
},
3940
}
4041

@@ -126,20 +127,26 @@ func newCleanUpCmd(cfg config.Configuration, docker command.Cli) *cobra.Command
126127
Short: "Removes unused and obsolete application images to free up disk space.",
127128
Args: cobra.ExactArgs(0),
128129
RunE: func(cmd *cobra.Command, _ []string) error {
129-
130130
staticStore := servicelocator.GetStaticStore()
131131

132132
feedback.Printf("Running cleanup...")
133-
result, err := orchestrator.SystemCleanupSoft(cmd.Context(), cfg, staticStore, docker)
133+
result, err := orchestrator.SystemCleanup(cmd.Context(), cfg, staticStore, docker)
134134
if err != nil {
135135
return err
136136
}
137137

138-
if result > 0 {
139-
feedback.Printf("Cleanup successful. Freed up %d bytes of disk space.", result)
140-
} else {
141-
feedback.Printf("No removable images found.")
138+
if result.IsEmpty() {
139+
feedback.Print("Nothing to clean up.")
140+
return nil
141+
}
142+
143+
feedback.Print("Cleanup successful.")
144+
feedback.Print("Freed up")
145+
if result.RunningAppRemoved {
146+
feedback.Print(" - 1 running app")
142147
}
148+
feedback.Printf(" - %d containers", result.ContainersRemoved)
149+
feedback.Printf(" - %d images (%v)", result.ImagesRemoved, x.ToHumanMiB(result.SpaceFreed))
143150
return nil
144151
},
145152
}

debian/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM golang:1.25.0 AS go
1+
FROM golang:1.25.1 AS go
22

33
ARG BINARY_NAME
44
RUN test -n "${BINARY_NAME}" || (echo "Error: BINARY_NAME is not set" && exit 1)

internal/orchestrator/system.go

Lines changed: 129 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
package orchestrator
22

33
import (
4-
"bytes"
4+
"bufio"
55
"context"
66
"encoding/json"
77
"fmt"
8+
"io"
89
"os"
910
"slices"
10-
"strconv"
1111
"strings"
1212

13-
"github.com/arduino/go-paths-helper"
1413
"github.com/compose-spec/compose-go/v2/loader"
1514
"github.com/compose-spec/compose-go/v2/types"
1615
"github.com/docker/cli/cli/command"
16+
"github.com/docker/docker/api/types/container"
17+
"github.com/docker/docker/api/types/filters"
18+
"github.com/docker/docker/api/types/image"
19+
dockerClient "github.com/docker/docker/client"
1720
"go.bug.st/f"
1821

1922
"github.com/arduino/arduino-app-cli/cmd/feedback"
@@ -22,15 +25,15 @@ import (
2225
)
2326

2427
// SystemInit pulls necessary Docker images.
25-
func SystemInit(ctx context.Context, cfg config.Configuration, staticStore *store.StaticStore) error {
28+
func SystemInit(ctx context.Context, cfg config.Configuration, staticStore *store.StaticStore, docker *command.DockerCli) error {
2629
containersToPreinstall := []string{cfg.PythonImage}
2730
additionalContainers, err := parseAllModelsRunnerImageTag(staticStore)
2831
if err != nil {
2932
return err
3033
}
3134
containersToPreinstall = append(containersToPreinstall, additionalContainers...)
3235

33-
pulledImages, err := listImagesAlreadyPulled(ctx)
36+
pulledImages, err := listImagesAlreadyPulled(ctx, docker.Client())
3437
if err != nil {
3538
return err
3639
}
@@ -47,64 +50,73 @@ func SystemInit(ctx context.Context, cfg config.Configuration, staticStore *stor
4750
}
4851

4952
for _, container := range containersToPreinstall {
53+
feedback.Printf("Pulling container image %s ...", container)
54+
if err := pullImage(ctx, stdout, docker.Client(), container); err != nil {
55+
feedback.Printf("Warning: failed to read image pull response - %v", err)
56+
}
57+
}
5058

51-
cmd, err := paths.NewProcess(nil, "docker", "pull", container)
52-
if err != nil {
53-
return err
59+
return nil
60+
}
61+
62+
func pullImage(ctx context.Context, stdout io.Writer, docker dockerClient.APIClient, imageName string) error {
63+
out, err := docker.ImagePull(ctx, imageName, image.PullOptions{})
64+
if err != nil {
65+
return err
66+
}
67+
defer out.Close()
68+
69+
scanner := bufio.NewScanner(out)
70+
for scanner.Scan() {
71+
type Payload struct {
72+
Status string `json:"status"`
73+
Progress string `json:"progress"`
74+
ID string `json:"id"`
5475
}
55-
cmd.RedirectStderrTo(stdout)
56-
cmd.RedirectStdoutTo(stdout)
57-
if err := cmd.RunWithinContext(ctx); err != nil {
58-
return err
76+
77+
var payload Payload
78+
if err := json.Unmarshal(scanner.Bytes(), &payload); err == nil {
79+
if payload.Status != "" {
80+
fmt.Fprintf(stdout, "%s", payload.Status)
81+
}
82+
if payload.Progress != "" {
83+
fmt.Fprintf(stdout, "[%s] %s\r", payload.ID, payload.Progress)
84+
} else {
85+
fmt.Fprintln(stdout)
86+
}
5987
}
6088
}
89+
if err := scanner.Err(); err != nil {
90+
return err
91+
}
6192
return nil
6293
}
6394

95+
// Container images matching this list will be pulled by 'system init' and included in the Linux images.
96+
var imagePrefixes = []string{"ghcr.io/bcmi-labs/", "public.ecr.aws/arduino/", "influxdb"}
97+
6498
// listImagesAlreadyPulled
6599
// TODO make reference constant in a dedicated file as single source of truth
66-
func listImagesAlreadyPulled(ctx context.Context) ([]string, error) {
67-
cmd, err := paths.NewProcess(nil,
68-
"docker", "images", "--format", "json",
69-
"-f", "reference=ghcr.io/bcmi-labs/*",
70-
"-f", "reference=public.ecr.aws/arduino/app-bricks/*",
71-
"-f", "reference=influxdb",
72-
)
73-
if err != nil {
74-
return nil, err
75-
}
76-
77-
// Capture the output to check if the image exists
78-
stdout, _, err := cmd.RunAndCaptureOutput(ctx)
100+
func listImagesAlreadyPulled(ctx context.Context, docker dockerClient.APIClient) ([]string, error) {
101+
images, err := docker.ImageList(ctx, image.ListOptions{})
79102
if err != nil {
80103
return nil, err
81104
}
82105

83-
type dockerImage struct {
84-
Repository string `json:"Repository"`
85-
Tag string `json:"Tag"`
86-
}
87-
var resp dockerImage
88-
result := []string{}
89-
for img := range bytes.Lines(stdout) {
90-
if len(img) == 0 {
91-
continue
92-
}
93-
if err := json.Unmarshal(img, &resp); err != nil {
94-
return nil, err
95-
}
96-
if resp.Tag == "<none>" {
97-
continue
106+
result := make([]string, 0, len(images))
107+
for _, image := range images {
108+
for _, tag := range image.RepoTags {
109+
for _, prefix := range imagePrefixes {
110+
if strings.HasPrefix(tag, prefix) {
111+
result = append(result, tag)
112+
}
113+
}
98114
}
99-
result = append(result, resp.Repository+":"+resp.Tag)
100115
}
101116

102117
return result, nil
103118
}
104119

105-
// Container images matching this list will be pulled by 'system init' and included in the Linux images.
106-
var imagePrefixes = []string{"ghcr.io/bcmi-labs/", "public.ecr.aws/arduino/", "influxdb"}
107-
108120
func parseAllModelsRunnerImageTag(staticStore *store.StaticStore) ([]string, error) {
109121
composePath := staticStore.GetComposeFolder()
110122
brickNamespace := "arduino"
@@ -143,85 +155,84 @@ func parseAllModelsRunnerImageTag(staticStore *store.StaticStore) ([]string, err
143155
return f.Uniq(result), nil
144156
}
145157

146-
func SystemCleanupSoft(ctx context.Context, cfg config.Configuration, staticStore *store.StaticStore, docker command.Cli) (int64, error) {
147-
totalCleaned := int64(0)
148-
149-
containersMustStay, err := getRequiredImages(cfg, staticStore)
150-
if err != nil {
151-
return totalCleaned, err
152-
}
153-
154-
allImages, err := listImagesAlreadyPulled(ctx)
155-
if err != nil {
156-
return totalCleaned, err
157-
}
158+
type SystemCleanupResult struct {
159+
ContainersRemoved int
160+
ImagesRemoved int
161+
RunningAppRemoved bool
162+
SpaceFreed int64 // in bytes
163+
}
158164

159-
imagesToRemove := slices.DeleteFunc(allImages, func(v string) bool {
160-
return slices.Contains(containersMustStay, v)
161-
})
165+
func (s SystemCleanupResult) IsEmpty() bool {
166+
return s == SystemCleanupResult{}
167+
}
162168

163-
if len(imagesToRemove) == 0 {
164-
return totalCleaned, nil
165-
}
169+
// SystemCleanup removes dangling containers and unused images.
170+
// Also running apps are stopped and removed.
171+
func SystemCleanup(ctx context.Context, cfg config.Configuration, staticStore *store.StaticStore, docker command.Cli) (SystemCleanupResult, error) {
172+
var result SystemCleanupResult
166173

174+
// Remove running app and dangling containers
167175
runningApp, err := getRunningApp(ctx, docker.Client())
168176
if err != nil {
169-
return totalCleaned, fmt.Errorf("failed to get running app: %w", err)
177+
feedback.Printf("Warning: failed to get running app - %v", err)
170178
}
171179
if runningApp != nil {
172180
for item := range StopAndDestroyApp(ctx, *runningApp) {
173181
if item.GetType() == ErrorType {
174-
return totalCleaned, item.GetError()
182+
feedback.Printf("Warning: failed to stop and destroy running app - %v", item.GetError())
183+
break
175184
}
176185
}
186+
result.RunningAppRemoved = true
177187
}
178-
179-
for _, container := range imagesToRemove {
180-
imageSize, err := removeImage(ctx, container)
181-
if err != nil {
182-
feedback.Printf("Warning: failed to remove image %s - %v", container, err)
183-
continue
184-
}
185-
totalCleaned += imageSize
188+
if count, err := removeDanglingContainers(ctx, docker.Client()); err != nil {
189+
feedback.Printf("Warning: failed to remove dangling containers - %v", err)
190+
} else {
191+
result.ContainersRemoved = count
186192
}
187-
return totalCleaned, nil
188-
}
189193

190-
func removeImage(ctx context.Context, imageName string) (int64, error) {
191-
imageSize, err := getImageSize(imageName, ctx)
194+
// Remove unused images
195+
containersMustStay, err := getRequiredImages(cfg, staticStore)
192196
if err != nil {
193-
return 0, fmt.Errorf("failed to get size of image %s: %w", imageName, err)
197+
return result, err
194198
}
195-
196-
cmd, err := paths.NewProcess(nil, "docker", "rmi", "-f", imageName)
199+
allImages, err := listImagesAlreadyPulled(ctx, docker.Client())
197200
if err != nil {
198-
return 0, fmt.Errorf("failed to create command to remove docker image %s: %w", imageName, err)
201+
return result, err
199202
}
203+
imagesToRemove := slices.DeleteFunc(allImages, func(v string) bool {
204+
return slices.Contains(containersMustStay, v)
205+
})
200206

201-
if err := cmd.RunWithinContext(ctx); err != nil {
202-
return 0, fmt.Errorf("failed to remove image %s: %v", imageName, err)
207+
for _, image := range imagesToRemove {
208+
imageSize, err := removeImage(ctx, docker.Client(), image)
209+
if err != nil {
210+
feedback.Printf("Warning: failed to remove image %s - %v", image, err)
211+
continue
212+
}
213+
result.SpaceFreed += imageSize
214+
result.ImagesRemoved++
203215
}
204216

205-
return imageSize, nil
217+
return result, nil
206218
}
207219

208-
func getImageSize(container string, ctx context.Context) (int64, error) {
209-
cmdImageSize, err := paths.NewProcess(nil, "docker", "image", "inspect", container, "--format", "{{.Size}}")
210-
if err != nil {
211-
return 0, err
212-
}
213-
containersize, err := cmdImageSize.RunAndCaptureCombinedOutput(ctx)
214-
if err != nil {
215-
return 0, err
220+
func removeImage(ctx context.Context, docker dockerClient.APIClient, imageName string) (int64, error) {
221+
var size int64
222+
if info, err := docker.ImageInspect(ctx, imageName); err != nil {
223+
feedback.Printf("Warning: failed to inspect image %s - %v", imageName, err)
224+
} else {
225+
size = info.Size
216226
}
217-
trimmedOutput := bytes.TrimSpace(containersize)
218227

219-
sizeInt64, err := strconv.ParseInt(string(trimmedOutput), 10, 64)
220-
if err != nil {
221-
return 0, err
228+
if _, err := docker.ImageRemove(ctx, imageName, image.RemoveOptions{
229+
Force: true,
230+
PruneChildren: true,
231+
}); err != nil {
232+
return 0, fmt.Errorf("failed to remove image %s: %w", imageName, err)
222233
}
223234

224-
return sizeInt64, nil
235+
return size, nil
225236
}
226237

227238
// imgages required by the system
@@ -236,3 +247,25 @@ func getRequiredImages(cfg config.Configuration, staticStore *store.StaticStore)
236247
requiredImages = append(requiredImages, modelsRunnersContainers...)
237248
return requiredImages, nil
238249
}
250+
251+
func removeDanglingContainers(ctx context.Context, docker dockerClient.APIClient) (int, error) {
252+
containers, err := docker.ContainerList(ctx, container.ListOptions{
253+
All: true,
254+
Filters: filters.NewArgs(filters.Arg("label", DockerAppLabel+"=true")),
255+
})
256+
if err != nil {
257+
return 0, fmt.Errorf("failed to list containers: %w", err)
258+
}
259+
260+
var counter int
261+
for _, info := range containers {
262+
if err := docker.ContainerRemove(ctx, info.ID, container.RemoveOptions{
263+
Force: true,
264+
RemoveVolumes: true,
265+
}); err != nil {
266+
return 0, fmt.Errorf("failed to remove container %s: %w", info.ID, err)
267+
}
268+
counter++
269+
}
270+
return counter, nil
271+
}

0 commit comments

Comments
 (0)