Skip to content
This repository was archived by the owner on Jun 26, 2023. It is now read-only.

Commit c487e76

Browse files
author
Chaim Lev-Ari
committed
feat(compose): check stack status [EE-5554]
close [EE-5554]
1 parent 3dbc6ab commit c487e76

File tree

6 files changed

+245
-0
lines changed

6 files changed

+245
-0
lines changed
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package composeplugin
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
8+
libstack "github.com/portainer/docker-compose-wrapper"
9+
)
10+
11+
type publisher struct {
12+
URL string
13+
TargetPort int
14+
PublishedPort int
15+
Protocol string
16+
}
17+
18+
type service struct {
19+
ID string
20+
Name string
21+
Image string
22+
Command string
23+
Project string
24+
Service string
25+
Created int64
26+
State string
27+
Status string
28+
Health string
29+
ExitCode int
30+
Publishers []publisher
31+
}
32+
33+
// docker container state can be one of "created", "running", "paused", "restarting", "removing", "exited", or "dead"
34+
func getServiceStatus(service service) (libstack.Status, string) {
35+
switch service.State {
36+
case "created", "restarting", "paused":
37+
return libstack.StatusStarting, ""
38+
case "running":
39+
return libstack.StatusRunning, ""
40+
case "removing":
41+
return libstack.StatusRemoving, ""
42+
case "exited", "dead":
43+
if service.ExitCode != 0 {
44+
return libstack.StatusError, fmt.Sprintf("service %s exited with code %d", service.Name, service.ExitCode)
45+
}
46+
47+
return libstack.StatusRemoved, ""
48+
default:
49+
return libstack.StatusUnknown, ""
50+
}
51+
}
52+
53+
func aggregateStatuses(services []service) (libstack.Status, string) {
54+
statusCounts := make(map[libstack.Status]int)
55+
servicesCount := len(services)
56+
57+
if servicesCount == 0 {
58+
return libstack.StatusRemoved, ""
59+
}
60+
61+
errorMessage := ""
62+
for _, service := range services {
63+
status, serviceError := getServiceStatus(service)
64+
if serviceError != "" {
65+
errorMessage = serviceError
66+
}
67+
statusCounts[status]++
68+
}
69+
70+
switch {
71+
case errorMessage != "":
72+
return libstack.StatusError, errorMessage
73+
case statusCounts[libstack.StatusStarting] > 0:
74+
return libstack.StatusStarting, ""
75+
case statusCounts[libstack.StatusRemoving] > 0:
76+
return libstack.StatusRemoving, ""
77+
case statusCounts[libstack.StatusRunning] == servicesCount:
78+
return libstack.StatusRunning, ""
79+
case statusCounts[libstack.StatusStopped] == servicesCount:
80+
return libstack.StatusStopped, ""
81+
case statusCounts[libstack.StatusRemoved] == servicesCount:
82+
return libstack.StatusRemoved, ""
83+
default:
84+
return libstack.StatusUnknown, ""
85+
}
86+
87+
}
88+
89+
func (wrapper *PluginWrapper) Status(ctx context.Context, projectName string) (libstack.Status, string, error) {
90+
output, err := wrapper.command(newCommand([]string{"ps", "-a", "--format", "json"}, nil), libstack.Options{
91+
ProjectName: projectName,
92+
})
93+
if len(output) == 0 || err != nil {
94+
return "", "", err
95+
}
96+
97+
var services []service
98+
err = json.Unmarshal(output, &services)
99+
if err != nil {
100+
return "", "", fmt.Errorf("failed to parse docker compose output: %w", err)
101+
}
102+
103+
aggregateStatus, statusMessage := aggregateStatuses(services)
104+
return aggregateStatus, statusMessage, nil
105+
106+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package composeplugin
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
libstack "github.com/portainer/docker-compose-wrapper"
9+
)
10+
11+
/*
12+
13+
1. starting = docker compose file that runs several services, one of them should be with status starting
14+
2. running = docker compose file that runs successfully and returns status running
15+
3. removing = run docker compose config, remove the stack, and return removing status
16+
4. failed = run a valid docker compose file, but one of the services should fail to start (so "docker compose up" should run successfully, but one of the services should do something like `CMD ["exit", "1"]
17+
5. removed = remove a compose stack and return status removed
18+
19+
*/
20+
21+
func TestComposeProjectStatus(t *testing.T) {
22+
testCases := []struct {
23+
TestName string
24+
ComposeFile string
25+
ExpectedStatus libstack.Status
26+
ExpectedStatusMessage bool
27+
}{
28+
// {
29+
// TestName: "starting",
30+
// ComposeFile: "status_test_files/starting.yml",
31+
// ExpectedStatus: libstack.StatusStarting,
32+
// },
33+
{
34+
TestName: "running",
35+
ComposeFile: "status_test_files/running.yml",
36+
ExpectedStatus: libstack.StatusRunning,
37+
},
38+
// {
39+
// TestName: "removing",
40+
// ComposeFile: "status_test_files/removing.yml",
41+
// ExpectedStatus: libstack.StatusRemoving,
42+
// },
43+
{
44+
TestName: "failed",
45+
ComposeFile: "status_test_files/failed.yml",
46+
ExpectedStatus: libstack.StatusError,
47+
ExpectedStatusMessage: true,
48+
},
49+
// {
50+
// TestName: "removed",
51+
// ComposeFile: "status_test_files/removed.yml",
52+
// ExpectedStatus: libstack.StatusRemoved,
53+
// },
54+
}
55+
56+
w := setup(t)
57+
ctx := context.Background()
58+
59+
for _, testCase := range testCases {
60+
t.Run(testCase.ComposeFile, func(t *testing.T) {
61+
projectName := testCase.TestName
62+
err := w.Deploy(ctx, []string{testCase.ComposeFile}, libstack.DeployOptions{
63+
Options: libstack.Options{
64+
ProjectName: projectName,
65+
},
66+
})
67+
if err != nil {
68+
t.Fatalf("[test: %s] Failed to deploy compose file: %v", testCase.TestName, err)
69+
}
70+
71+
time.Sleep(5 * time.Second)
72+
73+
status, statusMessage, err := w.Status(ctx, projectName)
74+
if err != nil {
75+
t.Fatalf("[test: %s] Failed to get compose project status: %v", testCase.TestName, err)
76+
}
77+
78+
if status != testCase.ExpectedStatus {
79+
t.Fatalf("[test: %s] Expected status: %s, got: %s", testCase.TestName, testCase.ExpectedStatus, status)
80+
}
81+
82+
if testCase.ExpectedStatusMessage && statusMessage == "" {
83+
t.Fatalf("[test: %s] Expected status message but got empty", testCase.TestName)
84+
}
85+
86+
err = w.Remove(ctx, projectName, nil, libstack.Options{})
87+
if err != nil {
88+
t.Fatalf("[test: %s] Failed to remove compose project: %v", testCase.TestName, err)
89+
}
90+
91+
time.Sleep(20 * time.Second)
92+
93+
status, statusMessage, err = w.Status(ctx, projectName)
94+
if err != nil {
95+
t.Fatalf("[test: %s] Failed to get compose project status: %v", testCase.TestName, err)
96+
}
97+
98+
if status != libstack.StatusRemoved {
99+
t.Fatalf("[test: %s] Expected stack to be removed, got %s", testCase.TestName, status)
100+
}
101+
102+
if statusMessage != "" {
103+
t.Fatalf("[test: %s] Expected empty status message: %s, got: %s", "", testCase.TestName, statusMessage)
104+
}
105+
})
106+
}
107+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
version: '3'
2+
services:
3+
web:
4+
image: nginx:latest
5+
failing-service:
6+
image: busybox
7+
command: ["false"]
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
version: '3'
2+
services:
3+
web:
4+
image: nginx:latest
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
version: '3'
2+
services:
3+
web:
4+
image: nginx:latest
5+
slow-service:
6+
image: busybox
7+
command: sh -c "sleep 100s"

libstack.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,22 @@ type Deployer interface {
1313
Remove(ctx context.Context, projectName string, filePaths []string, options Options) error
1414
Pull(ctx context.Context, filePaths []string, options Options) error
1515
Validate(ctx context.Context, filePaths []string, options Options) error
16+
// Status returns the status of the stack
17+
Status(ctx context.Context, projectName string) (Status, string, error)
1618
}
1719

20+
type Status string
21+
22+
const (
23+
StatusUnknown Status = "unknown"
24+
StatusStarting Status = "starting"
25+
StatusRunning Status = "running"
26+
StatusStopped Status = "stopped"
27+
StatusError Status = "error"
28+
StatusRemoving Status = "removing"
29+
StatusRemoved Status = "removed"
30+
)
31+
1832
type Options struct {
1933
WorkingDir string
2034
Host string

0 commit comments

Comments
 (0)