Skip to content

Commit 3d0024f

Browse files
authored
Feat/compose images check (#30)
2 parents 18ae701 + 1c4f802 commit 3d0024f

3 files changed

Lines changed: 159 additions & 0 deletions

File tree

internal/check/compose.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,96 @@ func (c *ComposeCheck) Run(_ context.Context) Result {
7373
Message: "all services are running",
7474
}
7575
}
76+
77+
type ComposeImageCheck struct {
78+
runner func() ([]byte, error)
79+
}
80+
81+
func (c *ComposeImageCheck) Name() string {
82+
return "Docker Compose images pulled"
83+
}
84+
85+
type composeImage struct {
86+
ContainerName string `json:"ContainerName"`
87+
Repository string `json:"Repository"`
88+
ID string `json:"ID"`
89+
}
90+
91+
func (c *ComposeImageCheck) Run(_ context.Context) Result {
92+
run := c.runner
93+
if run == nil {
94+
run = func() ([]byte, error) {
95+
return exec.Command("docker", "compose", "images", "--format", "json").Output()
96+
}
97+
}
98+
99+
out, err := run()
100+
if err != nil {
101+
return Result{
102+
Name: c.Name(),
103+
Status: StatusFail,
104+
Message: "could not run docker compose images",
105+
Fix: "make sure Docker is running and you are in the project directory",
106+
}
107+
}
108+
109+
images, parseErr := parseComposeImages(out)
110+
if parseErr != nil {
111+
return Result{
112+
Name: c.Name(),
113+
Status: StatusFail,
114+
Message: "could not parse docker compose images output",
115+
}
116+
}
117+
118+
var missing []string
119+
for _, img := range images {
120+
if img.Repository == "" || img.Repository == "<none>" || img.ID == "" {
121+
missing = append(missing, img.ContainerName)
122+
}
123+
}
124+
125+
if len(missing) > 0 {
126+
return Result{
127+
Name: c.Name(),
128+
Status: StatusFail,
129+
Message: fmt.Sprintf("images not pulled for: %s", strings.Join(missing, ", ")),
130+
Fix: "run docker compose pull to pull all images",
131+
}
132+
}
133+
134+
return Result{
135+
Name: c.Name(),
136+
Status: StatusPass,
137+
Message: "all service images are pulled",
138+
}
139+
}
140+
141+
func parseComposeImages(data []byte) ([]composeImage, error) {
142+
data = bytes.TrimSpace(data)
143+
if len(data) == 0 {
144+
return nil, nil
145+
}
146+
// newer Docker versions output a JSON array; older versions output JSONL
147+
if data[0] == '[' {
148+
var images []composeImage
149+
if err := json.Unmarshal(data, &images); err != nil {
150+
return nil, err
151+
}
152+
return images, nil
153+
}
154+
var images []composeImage
155+
scanner := bufio.NewScanner(bytes.NewReader(data))
156+
for scanner.Scan() {
157+
line := strings.TrimSpace(scanner.Text())
158+
if line == "" {
159+
continue
160+
}
161+
var img composeImage
162+
if err := json.Unmarshal([]byte(line), &img); err != nil {
163+
continue
164+
}
165+
images = append(images, img)
166+
}
167+
return images, nil
168+
}

internal/check/compose_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package check
33
import (
44
"context"
55
"errors"
6+
"strings"
67
"testing"
78
)
89

@@ -47,3 +48,67 @@ func TestComposeCheck_NoServices(t *testing.T) {
4748
t.Errorf("expected pass, got %v: %s", result.Status, result.Message)
4849
}
4950
}
51+
52+
func TestComposeImageCheck_AllPulled(t *testing.T) {
53+
c := &ComposeImageCheck{runner: func() ([]byte, error) {
54+
return []byte(`[{"ContainerName":"app_web_1","Repository":"nginx","ID":"sha256:abc"}]`), nil
55+
}}
56+
result := c.Run(context.Background())
57+
if result.Status != StatusPass {
58+
t.Errorf("expected pass, got %v: %s", result.Status, result.Message)
59+
}
60+
}
61+
62+
func TestComposeImageCheck_MissingImage(t *testing.T) {
63+
c := &ComposeImageCheck{runner: func() ([]byte, error) {
64+
return []byte(`[{"ContainerName":"app_web_1","Repository":"","ID":""}]`), nil
65+
}}
66+
result := c.Run(context.Background())
67+
if result.Status != StatusFail {
68+
t.Errorf("expected fail, got %v", result.Status)
69+
}
70+
if !strings.Contains(result.Message, "app_web_1") {
71+
t.Errorf("expected message to mention service name, got: %s", result.Message)
72+
}
73+
}
74+
75+
func TestComposeImageCheck_NoneRepository(t *testing.T) {
76+
c := &ComposeImageCheck{runner: func() ([]byte, error) {
77+
return []byte(`[{"ContainerName":"app_db_1","Repository":"<none>","ID":""}]`), nil
78+
}}
79+
result := c.Run(context.Background())
80+
if result.Status != StatusFail {
81+
t.Errorf("expected fail, got %v", result.Status)
82+
}
83+
}
84+
85+
func TestComposeImageCheck_JSONLFormat(t *testing.T) {
86+
c := &ComposeImageCheck{runner: func() ([]byte, error) {
87+
return []byte(`{"ContainerName":"app_web_1","Repository":"nginx","ID":"sha256:abc"}` + "\n" +
88+
`{"ContainerName":"app_db_1","Repository":"postgres","ID":"sha256:def"}` + "\n"), nil
89+
}}
90+
result := c.Run(context.Background())
91+
if result.Status != StatusPass {
92+
t.Errorf("expected pass, got %v: %s", result.Status, result.Message)
93+
}
94+
}
95+
96+
func TestComposeImageCheck_CommandFails(t *testing.T) {
97+
c := &ComposeImageCheck{runner: func() ([]byte, error) {
98+
return nil, errors.New("docker not found")
99+
}}
100+
result := c.Run(context.Background())
101+
if result.Status != StatusFail {
102+
t.Errorf("expected fail, got %v", result.Status)
103+
}
104+
}
105+
106+
func TestComposeImageCheck_NoOutput(t *testing.T) {
107+
c := &ComposeImageCheck{runner: func() ([]byte, error) {
108+
return []byte(""), nil
109+
}}
110+
result := c.Run(context.Background())
111+
if result.Status != StatusPass {
112+
t.Errorf("expected pass, got %v: %s", result.Status, result.Message)
113+
}
114+
}

internal/check/registry.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ func Build(stack detector.DetectedStack) []Check {
4646
}
4747
if stack.DockerCompose {
4848
cs = append(cs, &ComposeCheck{})
49+
cs = append(cs, &ComposeImageCheck{})
4950
}
5051
if stack.Postgres {
5152
cs = append(cs, &PostgresCheck{URL: os.Getenv("DATABASE_URL")})

0 commit comments

Comments
 (0)